mirror of https://github.com/nextcloud/server.git
Sort app scripts topologically by its dependencies
Implement a proper topological sorting algorithm. Based on the implementation by https://github.com/marcj/topsort.php Logs an error in case a circular dependency is detected. Fixes: #30278 Signed-off-by: Jonas Meurer <jonas@freesources.org>pull/30385/head
parent
a37909f61c
commit
491bd6260c
@ -0,0 +1,97 @@
|
||||
<?php
|
||||
/**
|
||||
* @copyright Copyright (c) 2021, Jonas Meurer <jonas@freesources.org>
|
||||
*
|
||||
* @author Jonas Meurer <jonas@freesources.org>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OC;
|
||||
|
||||
class AppScriptDependency {
|
||||
/** @var string */
|
||||
private $id;
|
||||
|
||||
/** @var array */
|
||||
private $deps;
|
||||
|
||||
/** @var bool */
|
||||
private $visited;
|
||||
|
||||
/**
|
||||
* @param string $id
|
||||
* @param array $deps
|
||||
* @param bool $visited
|
||||
*/
|
||||
public function __construct(string $id, array $deps = [], bool $visited = false) {
|
||||
$this->setId($id);
|
||||
$this->setDeps($deps);
|
||||
$this->setVisited($visited);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getId(): string {
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $id
|
||||
*/
|
||||
public function setId(string $id): void {
|
||||
$this->id = $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function getDeps(): array {
|
||||
return $this->deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $deps
|
||||
*/
|
||||
public function setDeps(array $deps): void {
|
||||
$this->deps = $deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $dep
|
||||
*/
|
||||
public function addDep(string $dep): void {
|
||||
if (!in_array($dep, $this->deps, true)) {
|
||||
$this->deps[] = $dep;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function isVisited(): bool {
|
||||
return $this->visited;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $visited
|
||||
*/
|
||||
public function setVisited(bool $visited): void {
|
||||
$this->visited = $visited;
|
||||
}
|
||||
}
|
@ -0,0 +1,105 @@
|
||||
<?php
|
||||
/**
|
||||
* @copyright Copyright (c) 2021, Jonas Meurer <jonas@freesources.org>
|
||||
*
|
||||
* @author Jonas Meurer <jonas@freesources.org>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OC;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Sort scripts topologically by their dependencies
|
||||
* Implementation based on https://github.com/marcj/topsort.php
|
||||
*/
|
||||
class AppScriptSort {
|
||||
/** @var LoggerInterface */
|
||||
private $logger;
|
||||
|
||||
public function __construct(LoggerInterface $logger) {
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive topological sorting
|
||||
*
|
||||
* @param AppScriptDependency $app
|
||||
* @param array $parents
|
||||
* @param array $scriptDeps
|
||||
* @param array $sortedScriptDeps
|
||||
*/
|
||||
private function topSortVisit(
|
||||
AppScriptDependency $app,
|
||||
array &$parents,
|
||||
array &$scriptDeps,
|
||||
array &$sortedScriptDeps): void {
|
||||
// Detect and log circular dependencies
|
||||
if (isset($parents[$app->getId()])) {
|
||||
$this->logger->error('Circular dependency in app scripts at app ' . $app->getId());
|
||||
}
|
||||
|
||||
// If app has not been visited
|
||||
if (!$app->isVisited()) {
|
||||
$parents[$app->getId()] = true;
|
||||
$app->setVisited(true);
|
||||
|
||||
foreach ($app->getDeps() as $dep) {
|
||||
if ($app->getId() === $dep) {
|
||||
// Ignore dependency on itself
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($scriptDeps[$dep])) {
|
||||
$newParents = $parents;
|
||||
$this->topSortVisit($scriptDeps[$dep], $newParents, $scriptDeps, $sortedScriptDeps);
|
||||
}
|
||||
}
|
||||
|
||||
$sortedScriptDeps[] = $app->getId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array scripts sorted by dependencies
|
||||
*/
|
||||
public function sort(array $scripts, array $scriptDeps): array {
|
||||
// Sort scriptDeps into sortedScriptDeps
|
||||
$sortedScriptDeps = [];
|
||||
foreach ($scriptDeps as $app) {
|
||||
$parents = [];
|
||||
$this->topSortVisit($app, $parents, $scriptDeps, $sortedScriptDeps);
|
||||
}
|
||||
|
||||
// Sort scripts into sortedScripts based on sortedScriptDeps order
|
||||
$sortedScripts = [];
|
||||
foreach ($sortedScriptDeps as $app) {
|
||||
$sortedScripts[$app] = $scripts[$app] ?? [];
|
||||
}
|
||||
|
||||
// Add remaining scripts
|
||||
foreach (array_keys($scripts) as $app) {
|
||||
if (!isset($sortedScripts[$app])) {
|
||||
$sortedScripts[$app] = $scripts[$app];
|
||||
}
|
||||
}
|
||||
|
||||
return $sortedScripts;
|
||||
}
|
||||
}
|
@ -0,0 +1,124 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright (c) 2012 Lukas Reschke <lukas@statuscode.ch>
|
||||
* This file is licensed under the Affero General Public License version 3 or
|
||||
* later.
|
||||
* See the COPYING-README file.
|
||||
*/
|
||||
|
||||
namespace Test;
|
||||
|
||||
use OC\AppScriptDependency;
|
||||
use OC\AppScriptSort;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Class AppScriptSortTest
|
||||
*
|
||||
* @package Test
|
||||
* @group DB
|
||||
*/
|
||||
class AppScriptSortTest extends \Test\TestCase {
|
||||
private $logger;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->logger = $this->getMockBuilder(LoggerInterface::class)
|
||||
->disableOriginalConstructor()
|
||||
->getMock();
|
||||
|
||||
parent::setUp();
|
||||
}
|
||||
|
||||
public function testSort(): void {
|
||||
$scripts = [
|
||||
'first' => ['myFirstJSFile'],
|
||||
'core' => [
|
||||
'core/js/myFancyJSFile1',
|
||||
'core/js/myFancyJSFile4',
|
||||
'core/js/myFancyJSFile5',
|
||||
'core/js/myFancyJSFile1',
|
||||
],
|
||||
'files' => ['files/js/myFancyJSFile2'],
|
||||
'myApp5' => ['myApp5/js/myApp5JSFile'],
|
||||
'myApp' => ['myApp/js/myFancyJSFile3'],
|
||||
'myApp4' => ['myApp4/js/myApp4JSFile'],
|
||||
'myApp3' => ['myApp3/js/myApp3JSFile'],
|
||||
'myApp2' => ['myApp2/js/myApp2JSFile'],
|
||||
];
|
||||
$scriptDeps = [
|
||||
'first' => new AppScriptDependency('first', ['core']),
|
||||
'core' => new AppScriptDependency('core', ['core']),
|
||||
'files' => new AppScriptDependency('files', ['core']),
|
||||
'myApp5' => new AppScriptDependency('myApp5', ['myApp2']),
|
||||
'myApp' => new AppScriptDependency('myApp', ['core']),
|
||||
'myApp4' => new AppScriptDependency('myApp4', ['myApp3']),
|
||||
'myApp3' => new AppScriptDependency('myApp3', ['myApp2']),
|
||||
'myApp2' => new AppScriptDependency('myApp2', ['myApp']),
|
||||
];
|
||||
|
||||
// No circular dependency is detected and logged as an error
|
||||
$this->logger->expects(self::never())->method('error');
|
||||
|
||||
$scriptSort = new AppScriptSort($this->logger);
|
||||
$sortedScripts = $scriptSort->sort($scripts, $scriptDeps);
|
||||
|
||||
$sortedScriptKeys = array_keys($sortedScripts);
|
||||
|
||||
// Core should appear first
|
||||
$this->assertEquals(
|
||||
0,
|
||||
array_search('core', $sortedScriptKeys, true)
|
||||
);
|
||||
|
||||
// Dependencies should appear before their children
|
||||
$this->assertLessThan(
|
||||
array_search('files', $sortedScriptKeys, true),
|
||||
array_search('core', $sortedScriptKeys, true)
|
||||
);
|
||||
$this->assertLessThan(
|
||||
array_search('myApp2', $sortedScriptKeys, true),
|
||||
array_search('myApp', $sortedScriptKeys, true)
|
||||
);
|
||||
$this->assertLessThan(
|
||||
array_search('myApp3', $sortedScriptKeys, true),
|
||||
array_search('myApp2', $sortedScriptKeys, true)
|
||||
);
|
||||
$this->assertLessThan(
|
||||
array_search('myApp4', $sortedScriptKeys, true),
|
||||
array_search('myApp3', $sortedScriptKeys, true)
|
||||
);
|
||||
$this->assertLessThan(
|
||||
array_search('myApp5', $sortedScriptKeys, true),
|
||||
array_search('myApp2', $sortedScriptKeys, true)
|
||||
);
|
||||
|
||||
// All apps still there
|
||||
foreach ($scripts as $app => $_) {
|
||||
$this->assertContains($app, $sortedScriptKeys);
|
||||
}
|
||||
}
|
||||
|
||||
public function testSortCircularDependency(): void {
|
||||
$scripts = [
|
||||
'circular' => ['circular/js/file1'],
|
||||
'dependency' => ['dependency/js/file2'],
|
||||
];
|
||||
$scriptDeps = [
|
||||
'circular' => new AppScriptDependency('circular', ['dependency']),
|
||||
'dependency' => new AppScriptDependency('dependency', ['circular']),
|
||||
];
|
||||
|
||||
// A circular dependency is detected and logged as an error
|
||||
$this->logger->expects(self::once())->method('error');
|
||||
|
||||
$scriptSort = new AppScriptSort($this->logger);
|
||||
$sortedScripts = $scriptSort->sort($scripts, $scriptDeps);
|
||||
|
||||
$sortedScriptKeys = array_keys($sortedScripts);
|
||||
|
||||
// All apps still there
|
||||
foreach ($scripts as $app => $_) {
|
||||
$this->assertContains($app, $sortedScriptKeys);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue