From 34183f49f9228f5f0036de7f29993e05a372977c Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Thu, 1 Oct 2020 16:42:02 +0200 Subject: [PATCH] Implemented PoolOptimizer --- .github/workflows/continuous-integration.yml | 1 - doc/articles/troubleshooting.md | 24 ++ src/Composer/DependencyResolver/Pool.php | 8 + .../DependencyResolver/PoolBuilder.php | 38 +- .../DependencyResolver/PoolOptimizer.php | 345 ++++++++++++++++++ src/Composer/Installer.php | 20 +- src/Composer/Repository/RepositorySet.php | 6 +- .../multi-repo-replace-partial-update.test | 10 + .../poolbuilder/multi-repo-replace.test | 10 + .../partial-update-unfixing-locked-deps.test | 10 + ...artial-update-unfixing-with-replacers.test | 9 + ...minimum-stability-and-filter-packages.test | 7 + .../Fixtures/pooloptimizer/aliases.test | 99 +++++ .../pooloptimizer/basic-prefer-highest.test | 46 +++ .../pooloptimizer/basic-prefer-lowest.test | 47 +++ .../Fixtures/pooloptimizer/conflict.test | 107 ++++++ .../Fixtures/pooloptimizer/conflict2.test | 103 ++++++ .../pooloptimizer/group-by-required.test | 99 +++++ .../pooloptimizer/locked-fixed-untouched.test | 46 +++ .../Fixtures/pooloptimizer/replaces.test | 59 +++ .../DependencyResolver/PoolBuilderTest.php | 34 +- .../DependencyResolver/PoolOptimizerTest.php | 197 ++++++++++ .../installer/conflict-downgrade-nested.test | 39 ++ .../installer/conflict-downgrade.test | 35 ++ .../deduplicate-solver-problems.test | 9 + .../installer/github-issues-7051.test | 15 + .../installer/provider-conflicts3.test | 10 + tests/Composer/Test/InstallerTest.php | 62 +++- tests/bootstrap.php | 4 + tests/deprecations-8.1.json | 11 +- 30 files changed, 1492 insertions(+), 18 deletions(-) create mode 100644 src/Composer/DependencyResolver/PoolOptimizer.php create mode 100644 tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/aliases.test create mode 100644 tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/basic-prefer-highest.test create mode 100644 tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/basic-prefer-lowest.test create mode 100644 tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/conflict.test create mode 100644 tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/conflict2.test create mode 100644 tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/group-by-required.test create mode 100644 tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/locked-fixed-untouched.test create mode 100644 tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/replaces.test create mode 100644 tests/Composer/Test/DependencyResolver/PoolOptimizerTest.php create mode 100644 tests/Composer/Test/Fixtures/installer/conflict-downgrade-nested.test create mode 100644 tests/Composer/Test/Fixtures/installer/conflict-downgrade.test diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 21e583cc6..17200e2e7 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -11,7 +11,6 @@ on: env: COMPOSER_FLAGS: "--ansi --no-interaction --no-progress --prefer-dist" COMPOSER_UPDATE_FLAGS: "" - COMPOSER_TESTS_ARE_RUNNING: "1" jobs: tests: diff --git a/doc/articles/troubleshooting.md b/doc/articles/troubleshooting.md index 85419c61e..c401a6bf2 100644 --- a/doc/articles/troubleshooting.md +++ b/doc/articles/troubleshooting.md @@ -353,3 +353,27 @@ See also https://github.com/composer/composer/issues/4180 for more information. Composer can unpack zipballs using either a system-provided `unzip` or `7z` (7-Zip) utility, or PHP's native `ZipArchive` class. On OSes where ZIP files can contain permissions and symlinks, we recommend installing `unzip` or `7z` as these features are not supported by `ZipArchive`. + + +## Disabling the pool optimizer + +In Composer, the `Pool` class contains all the packages that are relevant for the dependency +resolving process. That is what is used to generate all the rules which are then +passed on to the dependency solver. +In order to improve performance, Composer tries to optimize this `Pool` by removing useless +package information early on. + +If all goes well, you should never notice any issues with it but in case you run into +an unexpected result such as an unresolvable set of dependencies or conflicts where you +think Composer is wrong, you might want to disable the optimizer by using the environment +variable `COMPOSER_POOL_OPTIMIZER` and run the update again like so: + +```bash +COMPOSER_POOL_OPTIMIZER=0 php composer.phar update +``` + +Now double check if the result is still the same. It will take significantly longer and use +a lot more memory to run the dependency resolving process. + +If the result is different, you likely hit a problem in the pool optimizer. +Please [report this issue](https://github.com/composer/composer/issues) so it can be fixed. diff --git a/src/Composer/DependencyResolver/Pool.php b/src/Composer/DependencyResolver/Pool.php index 0ee5c7f4c..aa9648129 100644 --- a/src/Composer/DependencyResolver/Pool.php +++ b/src/Composer/DependencyResolver/Pool.php @@ -221,6 +221,14 @@ class Pool implements \Countable return \in_array($package, $this->unacceptableFixedOrLockedPackages, true); } + /** + * @return BasePackage[] + */ + public function getUnacceptableFixedOrLockedPackages() + { + return $this->unacceptableFixedOrLockedPackages; + } + public function __toString() { $str = "Pool:\n"; diff --git a/src/Composer/DependencyResolver/PoolBuilder.php b/src/Composer/DependencyResolver/PoolBuilder.php index 2705f6181..289741107 100644 --- a/src/Composer/DependencyResolver/PoolBuilder.php +++ b/src/Composer/DependencyResolver/PoolBuilder.php @@ -61,6 +61,10 @@ class PoolBuilder * @var ?EventDispatcher */ private $eventDispatcher; + /** + * @var PoolOptimizer|null + */ + private $poolOptimizer; /** * @var IOInterface */ @@ -128,13 +132,14 @@ class PoolBuilder * @param string[] $rootReferences an array of package name => source reference * @phpstan-param array $rootReferences */ - public function __construct(array $acceptableStabilities, array $stabilityFlags, array $rootAliases, array $rootReferences, IOInterface $io, EventDispatcher $eventDispatcher = null) + public function __construct(array $acceptableStabilities, array $stabilityFlags, array $rootAliases, array $rootReferences, IOInterface $io, EventDispatcher $eventDispatcher = null, PoolOptimizer $poolOptimizer = null) { $this->acceptableStabilities = $acceptableStabilities; $this->stabilityFlags = $stabilityFlags; $this->rootAliases = $rootAliases; $this->rootReferences = $rootReferences; $this->eventDispatcher = $eventDispatcher; + $this->poolOptimizer = $poolOptimizer; $this->io = $io; } @@ -259,6 +264,8 @@ class PoolBuilder $this->skippedLoad = array(); $this->indexCounter = 0; + $pool = $this->runOptimizer($request, $pool); + Intervals::clear(); return $pool; @@ -572,4 +579,33 @@ class PoolBuilder unset($this->aliasMap[spl_object_hash($package)]); } } + + /** + * @return Pool + */ + private function runOptimizer(Request $request, Pool $pool) + { + if (null === $this->poolOptimizer) { + return $pool; + } + + $total = \count($pool->getPackages()); + + $pool = $this->poolOptimizer->optimize($request, $pool); + + $filtered = $total - \count($pool->getPackages()); + + if (0 === $filtered) { + return $pool; + } + + $this->io->write(sprintf( + 'Found %s package versions referenced in your dependency graph. %s (%d%%) were optimized away.', + number_format($total), + number_format($filtered), + round(100/$total*$filtered) + ), true, IOInterface::VERY_VERBOSE); + + return $pool; + } } diff --git a/src/Composer/DependencyResolver/PoolOptimizer.php b/src/Composer/DependencyResolver/PoolOptimizer.php new file mode 100644 index 000000000..3b08da0e0 --- /dev/null +++ b/src/Composer/DependencyResolver/PoolOptimizer.php @@ -0,0 +1,345 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\DependencyResolver; + +use Composer\Package\AliasPackage; +use Composer\Package\BasePackage; +use Composer\Semver\CompilingMatcher; +use Composer\Semver\Constraint\ConstraintInterface; +use Composer\Semver\Constraint\Constraint; +use Composer\Semver\Constraint\MultiConstraint; +use Composer\Semver\Intervals; + +/** + * Optimizes a given pool + * + * @author Yanick Witschi + */ +class PoolOptimizer +{ + /** + * @var PolicyInterface + */ + private $policy; + + /** + * @var array + */ + private $irremovablePackages = array(); + + /** + * @var array> + */ + private $requireConstraintsPerPackage = array(); + + /** + * @var array> + */ + private $conflictConstraintsPerPackage = array(); + + /** + * @var array + */ + private $packagesToRemove = array(); + + /** + * @var array + */ + private $aliasesPerPackage = array(); + + public function __construct(PolicyInterface $policy) + { + $this->policy = $policy; + } + + /** + * @return Pool + */ + public function optimize(Request $request, Pool $pool) + { + $this->prepare($request, $pool); + + $optimizedPool = $this->optimizeByIdenticalDependencies($pool); + + // No need to run this recursively at the moment + // because the current optimizations cannot provide + // even more gains when ran again. Might change + // in the future with additional optimizations. + + $this->irremovablePackages = array(); + $this->requireConstraintsPerPackage = array(); + $this->conflictConstraintsPerPackage = array(); + $this->packagesToRemove = array(); + $this->aliasesPerPackage = array(); + + return $optimizedPool; + } + + /** + * @return void + */ + private function prepare(Request $request, Pool $pool) + { + $irremovablePackageConstraintGroups = array(); + + // Mark fixed or locked packages as irremovable + foreach ($request->getFixedOrLockedPackages() as $package) { + $irremovablePackageConstraintGroups[$package->getName()][] = new Constraint('==', $package->getVersion()); + } + + // Extract requested package requirements + foreach ($request->getRequires() as $require => $constraint) { + $constraint = Intervals::compactConstraint($constraint); + $this->requireConstraintsPerPackage[$require][(string) $constraint] = $constraint; + } + + // First pass over all packages to extract information and mark package constraints irremovable + foreach ($pool->getPackages() as $package) { + // Extract package requirements + foreach ($package->getRequires() as $link) { + $constraint = Intervals::compactConstraint($link->getConstraint()); + $this->requireConstraintsPerPackage[$link->getTarget()][(string) $constraint] = $constraint; + } + // Extract package conflicts + foreach ($package->getConflicts() as $link) { + $constraint = Intervals::compactConstraint($link->getConstraint()); + $this->conflictConstraintsPerPackage[$link->getTarget()][(string) $constraint] = $constraint; + } + + // Keep track of alias packages for every package so if either the alias or aliased is kept + // we keep the others as they are a unit of packages really + if ($package instanceof AliasPackage) { + $this->aliasesPerPackage[$package->getAliasOf()->id][] = $package; + } + } + + $irremovablePackageConstraints = array(); + foreach ($irremovablePackageConstraintGroups as $packageName => $constraints) { + $irremovablePackageConstraints[$packageName] = 1 === \count($constraints) ? $constraints[0] : new MultiConstraint($constraints, false); + } + unset($irremovablePackageConstraintGroups); + + // Mark the packages as irremovable based on the constraints + foreach ($pool->getPackages() as $package) { + if (!isset($irremovablePackageConstraints[$package->getName()])) { + continue; + } + + if (CompilingMatcher::match($irremovablePackageConstraints[$package->getName()], Constraint::OP_EQ, $package->getVersion())) { + $this->markPackageIrremovable($package); + } + } + } + + /** + * @return void + */ + private function markPackageIrremovable(BasePackage $package) + { + $this->irremovablePackages[$package->id] = true; + if ($package instanceof AliasPackage) { + // recursing here so aliasesPerPackage for the aliasOf can be checked + // and all its aliases marked as irremovable as well + $this->markPackageIrremovable($package->getAliasOf()); + } + if (isset($this->aliasesPerPackage[$package->id])) { + foreach ($this->aliasesPerPackage[$package->id] as $aliasPackage) { + $this->irremovablePackages[$aliasPackage->id] = true; + } + } + } + + /** + * @return Pool Optimized pool + */ + private function applyRemovalsToPool(Pool $pool) + { + $packages = array(); + foreach ($pool->getPackages() as $package) { + if (!isset($this->packagesToRemove[$package->id])) { + $packages[] = $package; + } + } + + $optimizedPool = new Pool($packages, $pool->getUnacceptableFixedOrLockedPackages()); + + // Reset package removals + $this->packagesToRemove = array(); + + return $optimizedPool; + } + + /** + * @return Pool + */ + private function optimizeByIdenticalDependencies(Pool $pool) + { + $identicalDefinitionPerPackage = array(); + $packageIdsToRemove = array(); + + foreach ($pool->getPackages() as $package) { + + // If that package was already marked irremovable, we can skip + // the entire process for it + if (isset($this->irremovablePackages[$package->id])) { + continue; + } + + $packageIdsToRemove[$package->id] = true; + + $dependencyHash = $this->calculateDependencyHash($package); + + foreach ($package->getNames(false) as $packageName) { + + if (!isset($this->requireConstraintsPerPackage[$packageName])) { + continue; + } + + foreach ($this->requireConstraintsPerPackage[$packageName] as $requireConstraint) { + + $groupHashParts = array(); + + if (CompilingMatcher::match($requireConstraint, Constraint::OP_EQ, $package->getVersion())) { + $groupHashParts[] = 'require:' . (string) $requireConstraint; + } + + if ($package->getReplaces()) { + foreach ($package->getReplaces() as $link) { + if (CompilingMatcher::match($link->getConstraint(), Constraint::OP_EQ, $package->getVersion())) { + // Use the same hash part as the regular require hash because that's what the replacement does + $groupHashParts[] = 'require:' . (string) $link->getConstraint(); + } + } + } + + if (isset($this->conflictConstraintsPerPackage[$packageName])) { + foreach ($this->conflictConstraintsPerPackage[$packageName] as $conflictConstraint) { + if (CompilingMatcher::match($conflictConstraint, Constraint::OP_EQ, $package->getVersion())) { + $groupHashParts[] = 'conflict:' . (string) $conflictConstraint; + } + } + } + + if (!$groupHashParts) { + continue; + } + + $identicalDefinitionPerPackage[$packageName][implode('', $groupHashParts)][$dependencyHash][] = $package; + } + } + } + + $keepPackage = function (BasePackage $package, $aliasesPerPackage) use (&$packageIdsToRemove, &$keepPackage) { + unset($packageIdsToRemove[$package->id]); + if ($package instanceof AliasPackage) { + // recursing here so aliasesPerPackage for the aliasOf can be checked + // and all its aliases marked to be kept as well + $keepPackage($package->getAliasOf(), $aliasesPerPackage); + } + if (isset($aliasesPerPackage[$package->id])) { + foreach ($aliasesPerPackage[$package->id] as $aliasPackage) { + unset($packageIdsToRemove[$aliasPackage->id]); + } + } + }; + + foreach ($identicalDefinitionPerPackage as $package => $constraintGroups) { + foreach ($constraintGroups as $constraintGroup) { + foreach ($constraintGroup as $hash => $packages) { + + // Only one package in this constraint group has the same requirements, we're not allowed to remove that package + if (1 === \count($packages)) { + $keepPackage($packages[0], $this->aliasesPerPackage); + continue; + } + + // Otherwise we find out which one is the preferred package in this constraint group which is + // then not allowed to be removed either + $literals = array(); + + foreach ($packages as $package) { + $literals[] = $package->id; + } + + foreach ($this->policy->selectPreferredPackages($pool, $literals) as $preferredLiteral) { + $keepPackage($pool->literalToPackage($preferredLiteral), $this->aliasesPerPackage); + } + } + } + } + + foreach ($packageIdsToRemove as $id => $dummy) { + $this->markPackageForRemoval($id); + } + + // Apply removals + return $this->applyRemovalsToPool($pool); + } + + /** + * @return string + */ + private function calculateDependencyHash(BasePackage $package) + { + $hash = ''; + + $hashRelevantLinks = array( + 'requires' => $package->getRequires(), + 'conflicts' => $package->getConflicts(), + 'replaces' => $package->getReplaces(), + 'provides' => $package->getProvides() + ); + + foreach ($hashRelevantLinks as $key => $links) { + if (0 === \count($links)) { + continue; + } + + // start new hash section + $hash .= $key . ':'; + + $subhash = array(); + + foreach ($links as $link) { + // To get the best dependency hash matches we should use Intervals::compactConstraint() here. + // However, the majority of projects are going to specify their constraints already pretty + // much in the best variant possible. In other words, we'd be wasting time here and it would actually hurt + // performance more than the additional few packages that could be filtered out would benefit the process. + $subhash[$link->getTarget()] = (string) $link->getConstraint(); + } + + // Sort for best result + ksort($subhash); + + foreach ($subhash as $target => $constraint) { + $hash .= $target . '@' . $constraint; + } + } + + return $hash; + } + + /** + * @param int $id + * @return void + */ + private function markPackageForRemoval($id) + { + // We are not allowed to remove packages if they have been marked as irremovable + if (isset($this->irremovablePackages[$id])) { + throw new \LogicException('Attempted removing a package which was previously marked irremovable'); + } + + $this->packagesToRemove[$id] = true; + } +} diff --git a/src/Composer/Installer.php b/src/Composer/Installer.php index be1b560cf..a2f976ec6 100644 --- a/src/Composer/Installer.php +++ b/src/Composer/Installer.php @@ -20,6 +20,7 @@ use Composer\DependencyResolver\LockTransaction; use Composer\DependencyResolver\Operation\UpdateOperation; use Composer\DependencyResolver\Operation\InstallOperation; use Composer\DependencyResolver\Operation\UninstallOperation; +use Composer\DependencyResolver\PoolOptimizer; use Composer\DependencyResolver\Pool; use Composer\DependencyResolver\Request; use Composer\DependencyResolver\Solver; @@ -430,7 +431,7 @@ class Installer $request->setUpdateAllowList($this->updateAllowList, $this->updateAllowTransitiveDependencies); } - $pool = $repositorySet->createPool($request, $this->io, $this->eventDispatcher); + $pool = $repositorySet->createPool($request, $this->io, $this->eventDispatcher, $this->createPoolOptimizer($policy)); $this->io->writeError('Updating dependencies'); @@ -999,6 +1000,23 @@ class Installer ); } + /** + * @return PoolOptimizer|null + */ + private function createPoolOptimizer(PolicyInterface $policy) + { + // Not the best architectural decision here, would need to be able + // to configure from the outside of Installer but this is only + // a debugging tool and should never be required in any other use case + if ('0' === Platform::getEnv('COMPOSER_POOL_OPTIMIZER')) { + $this->io->write('Pool Optimizer was disabled for debugging purposes.', true, IOInterface::DEBUG); + + return null; + } + + return new PoolOptimizer($policy); + } + /** * Create Installer * diff --git a/src/Composer/Repository/RepositorySet.php b/src/Composer/Repository/RepositorySet.php index 44ae258e7..31028f271 100644 --- a/src/Composer/Repository/RepositorySet.php +++ b/src/Composer/Repository/RepositorySet.php @@ -12,6 +12,8 @@ namespace Composer\Repository; +use Composer\DependencyResolver\PoolOptimizer; +use Composer\DependencyResolver\PolicyInterface; use Composer\DependencyResolver\Pool; use Composer\DependencyResolver\PoolBuilder; use Composer\DependencyResolver\Request; @@ -244,9 +246,9 @@ class RepositorySet * * @return Pool */ - public function createPool(Request $request, IOInterface $io, EventDispatcher $eventDispatcher = null) + public function createPool(Request $request, IOInterface $io, EventDispatcher $eventDispatcher = null, PoolOptimizer $poolOptimizer = null) { - $poolBuilder = new PoolBuilder($this->acceptableStabilities, $this->stabilityFlags, $this->rootAliases, $this->rootReferences, $io, $eventDispatcher); + $poolBuilder = new PoolBuilder($this->acceptableStabilities, $this->stabilityFlags, $this->rootAliases, $this->rootReferences, $io, $eventDispatcher, $poolOptimizer); foreach ($this->repositories as $repo) { if (($repo instanceof InstalledRepositoryInterface || $repo instanceof InstalledRepository) && !$this->allowInstalledRepositories) { diff --git a/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/multi-repo-replace-partial-update.test b/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/multi-repo-replace-partial-update.test index 01eda5215..2e1705cc0 100644 --- a/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/multi-repo-replace-partial-update.test +++ b/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/multi-repo-replace-partial-update.test @@ -105,3 +105,13 @@ Check that replacers from additional repositories are loaded when doing a partia "shared/dep-1.0.0.0", "shared/dep-1.2.0.0" ] + +--EXPECT-OPTIMIZED-- +[ + "indirect/replacer-1.2.0.0", + "indirect/replacer-1.0.0.0", + "replacer/package-1.2.0.0", + "replacer/package-1.0.0.0", + "base/package-1.0.0.0", + "shared/dep-1.2.0.0" +] diff --git a/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/multi-repo-replace.test b/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/multi-repo-replace.test index 00c63e4f5..9071d825e 100644 --- a/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/multi-repo-replace.test +++ b/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/multi-repo-replace.test @@ -96,3 +96,13 @@ Check that replacers from additional repositories are loaded "replacer/package-1.0.0.0", "shared/dep-1.0.0.0" ] + +--EXPECT-OPTIMIZED-- +[ + "base/package-1.0.0.0", + "indirect/replacer-1.2.0.0", + "indirect/replacer-1.0.0.0", + "shared/dep-1.2.0.0", + "replacer/package-1.2.0.0", + "replacer/package-1.0.0.0" +] diff --git a/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/partial-update-unfixing-locked-deps.test b/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/partial-update-unfixing-locked-deps.test index bdd4aa216..984590843 100644 --- a/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/partial-update-unfixing-locked-deps.test +++ b/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/partial-update-unfixing-locked-deps.test @@ -48,3 +48,13 @@ locked packages still need to be taking into account for loading all necessary v "dep/pkg1-1.0.1.0", "dep/pkg1-2.0.0.0" ] + +--EXPECT-OPTIMIZED-- +[ + "root/req1-1.0.0.0 (locked)", + "root/req2-1.0.0.0 (locked)", + "dep/pkg2-1.0.0.0", + "dep/pkg2-1.2.0.0", + "dep/pkg1-1.0.1.0", + "dep/pkg1-2.0.0.0" +] diff --git a/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/partial-update-unfixing-with-replacers.test b/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/partial-update-unfixing-with-replacers.test index c4148d5ba..0100fe25f 100644 --- a/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/partial-update-unfixing-with-replacers.test +++ b/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/partial-update-unfixing-with-replacers.test @@ -50,3 +50,12 @@ Fixed packages and replacers get unfixed correctly (refs https://github.com/comp "replaced/pkg-1.2.3.0", "replaced/pkg-1.2.4.0" ] + +--EXPECT-OPTIMIZED-- +[ + "root/req3-1.0.0.0 (locked)", + "dep/dep-2.3.5.0 (locked)", + "root/req1-1.1.0.0", + "replacer/pkg-1.1.0.0", + "replaced/pkg-1.2.4.0" +] diff --git a/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/stability-flags-take-over-minimum-stability-and-filter-packages.test b/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/stability-flags-take-over-minimum-stability-and-filter-packages.test index 849033dcb..1b9cbcf7c 100644 --- a/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/stability-flags-take-over-minimum-stability-and-filter-packages.test +++ b/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/stability-flags-take-over-minimum-stability-and-filter-packages.test @@ -46,3 +46,10 @@ Stability flags apply 6, "default/pkg-1.2.0.0 (alias of 6)" ] + +--EXPECT-OPTIMIZED-- +[ + 1, + 6, + "default/pkg-1.2.0.0 (alias of 6)" +] diff --git a/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/aliases.test b/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/aliases.test new file mode 100644 index 000000000..ac40931f8 --- /dev/null +++ b/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/aliases.test @@ -0,0 +1,99 @@ +--TEST-- +Test aliased and aliasees remain untouched if either is required, but are still optimized away otherwise. + +--REQUEST-- +{ + "require": { + "package/a": "^1.0", + "package/required-aliasof-and-alias": "dev-main-both", + "package/required-aliasof": "dev-main-direct", + "package/required-alias": "1.*" + } +} + + +--POOL-BEFORE-- +[ + { + "name": "package/a", + "version": "1.0.0", + "require": { + "package/required-aliasof-and-alias": "^1.0" + } + }, + { + "name": "package/required-aliasof-and-alias", + "version": "dev-main-both", + "extra": { + "branch-alias": { + "dev-main-both": "1.x-dev" + } + } + }, + { + "name": "package/required-aliasof", + "version": "dev-main-direct", + "extra": { + "branch-alias": { + "dev-main-direct": "1.x-dev" + } + } + }, + { + "name": "package/required-alias", + "version": "dev-main-alias", + "extra": { + "branch-alias": { + "dev-main-alias": "1.x-dev" + } + } + }, + { + "name": "package/not-referenced", + "version": "dev-lonesome-pkg", + "extra": { + "branch-alias": { + "dev-lonesome-pkg": "1.x-dev" + } + } + } +] + + +--POOL-AFTER-- +[ + { + "name": "package/a", + "version": "1.0.0", + "require": { + "package/b": "^1.0" + } + }, + { + "name": "package/required-aliasof-and-alias", + "version": "dev-main-both", + "extra": { + "branch-alias": { + "dev-main-both": "1.x-dev" + } + } + }, + { + "name": "package/required-aliasof", + "version": "dev-main-direct", + "extra": { + "branch-alias": { + "dev-main-direct": "1.x-dev" + } + } + }, + { + "name": "package/required-alias", + "version": "dev-main-alias", + "extra": { + "branch-alias": { + "dev-main-alias": "1.x-dev" + } + } + } +] diff --git a/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/basic-prefer-highest.test b/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/basic-prefer-highest.test new file mode 100644 index 000000000..91131b790 --- /dev/null +++ b/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/basic-prefer-highest.test @@ -0,0 +1,46 @@ +--TEST-- +Test filters irrelevant package "package/b" in version 1.0.0 + +--REQUEST-- +{ + "require": { + "package/a": "^1.0" + } +} + + +--POOL-BEFORE-- +[ + { + "name": "package/a", + "version": "1.0.0", + "require": { + "package/b": "^1.0" + } + }, + { + "name": "package/b", + "version": "1.0.0" + }, + { + "name": "package/b", + "version": "1.0.1" + } +] + + +--POOL-AFTER-- +[ + { + "name": "package/a", + "version": "1.0.0", + "require": { + "package/b": "^1.0" + } + }, + { + "name": "package/b", + "version": "1.0.1" + } +] + diff --git a/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/basic-prefer-lowest.test b/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/basic-prefer-lowest.test new file mode 100644 index 000000000..f588a771f --- /dev/null +++ b/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/basic-prefer-lowest.test @@ -0,0 +1,47 @@ +--TEST-- +Test filters irrelevant package "package/b" in version 1.0.1 because prefer-lowest + +--REQUEST-- +{ + "require": { + "package/a": "^1.0" + }, + "preferLowest": true +} + + +--POOL-BEFORE-- +[ + { + "name": "package/a", + "version": "1.0.0", + "require": { + "package/b": "^1.0" + } + }, + { + "name": "package/b", + "version": "1.0.0" + }, + { + "name": "package/b", + "version": "1.0.1" + } +] + + +--POOL-AFTER-- +[ + { + "name": "package/a", + "version": "1.0.0", + "require": { + "package/b": "^1.0" + } + }, + { + "name": "package/b", + "version": "1.0.0" + } +] + diff --git a/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/conflict.test b/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/conflict.test new file mode 100644 index 000000000..30ed9e140 --- /dev/null +++ b/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/conflict.test @@ -0,0 +1,107 @@ +--TEST-- +We have to make sure, conflicts are considered in the grouping so we do not remove packages +from the pool which might end up being part of the solution. + +--REQUEST-- +{ + "require": { + "nesty/nest": "^1.0" + } +} + +--POOL-BEFORE-- +[ + { + "name": "nesty/nest", + "version": "1.0.0", + "require": { + "conflicter/pkg": "^1.0", + "victim/pkg": "^1 <1.2" + } + }, + { + "name": "conflicter/pkg", + "version": "1.0.1", + "conflict": { + "victim/pkg": "1.1.0 || 1.1.1" + } + }, + { + "name": "conflicter/pkg", + "version": "1.0.2", + "conflict": { + "victim/pkg": "1.1.1 || 1.1.2" + } + }, + { + "name": "victim/pkg", + "version": "1.0.0" + }, + { + "name": "victim/pkg", + "version": "1.0.1" + }, + { + "name": "victim/pkg", + "version": "1.0.2" + }, + { + "name": "victim/pkg", + "version": "1.1.0" + }, + { + "name": "victim/pkg", + "version": "1.1.1" + }, + { + "name": "victim/pkg", + "version": "1.1.2" + }, + { + "name": "victim/pkg", + "version": "1.2.0" + } +] + + +--POOL-AFTER-- +[ + { + "name": "nesty/nest", + "version": "1.0.0", + "require": { + "conflicter/pkg": "^1.0", + "victim/pkg": "^1 <1.2" + } + }, + { + "name": "conflicter/pkg", + "version": "1.0.1", + "conflict": { + "victim/pkg": "1.1.0 || 1.1.1" + } + }, + { + "name": "conflicter/pkg", + "version": "1.0.2", + "conflict": { + "victim/pkg": "1.1.2" + } + }, + { + "name": "victim/pkg", + "version": "1.0.2" + }, + { + "name": "victim/pkg", + "version": "1.1.0" + }, + { + "name": "victim/pkg", + "version": "1.1.1" + }, + { + "name": "victim/pkg", + "version": "1.1.2" + } +] diff --git a/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/conflict2.test b/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/conflict2.test new file mode 100644 index 000000000..a6a5209bb --- /dev/null +++ b/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/conflict2.test @@ -0,0 +1,103 @@ +--TEST-- +We have to make sure, conflicts are considered in the grouping so we do not remove packages +from the pool which might end up being part of the solution. + +--REQUEST-- +{ + "require": { + "nesty/nest": "^1.0" + } +} + +--POOL-BEFORE-- +[ + { + "name": "nesty/nest", + "version": "1.0.0", + "require": { + "conflicter/pkg": "^1.0", + "victim/pkg": "^1 <1.2" + } + }, + { + "name": "conflicter/pkg", + "version": "1.0.1", + "conflict": { + "victim/pkg": "1.1.0" + } + }, + { + "name": "conflicter/pkg", + "version": "1.0.2", + "conflict": { + "victim/pkg": "1.1.2" + } + }, + { + "name": "victim/pkg", + "version": "1.0.0" + }, + { + "name": "victim/pkg", + "version": "1.0.1" + }, + { + "name": "victim/pkg", + "version": "1.0.2" + }, + { + "name": "victim/pkg", + "version": "1.1.0" + }, + { + "name": "victim/pkg", + "version": "1.1.1" + }, + { + "name": "victim/pkg", + "version": "1.1.2" + }, + { + "name": "victim/pkg", + "version": "1.2.0" + } +] + + +--POOL-AFTER-- +[ + { + "name": "nesty/nest", + "version": "1.0.0", + "require": { + "conflicter/pkg": "^1.0", + "victim/pkg": "^1 <1.2" + } + }, + { + "name": "conflicter/pkg", + "version": "1.0.1", + "conflict": { + "victim/pkg": "1.1.0 || 1.1.1" + } + }, + { + "name": "conflicter/pkg", + "version": "1.0.2", + "conflict": { + "victim/pkg": "1.1.2" + } + }, + { + "name": "victim/pkg", + "version": "1.1.0" + }, + { + "name": "victim/pkg", + "version": "1.1.1" + }, + { + "name": "victim/pkg", + "version": "1.1.2" + } +] diff --git a/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/group-by-required.test b/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/group-by-required.test new file mode 100644 index 000000000..e3b2ff46b --- /dev/null +++ b/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/group-by-required.test @@ -0,0 +1,99 @@ +--TEST-- +We are not allowed to group packages only by their dependency definition. It's also relevant what other +packages require (package/b@1.0.1 must not be dropped although it has the very same definition as 2.0.0 and both are +allowed by the request). However, package/b@1.0.0 can be removed. + +--REQUEST-- +{ + "require": { + "package/a": "^1.0" + } +} + + +--POOL-BEFORE-- +[ + { + "name": "package/a", + "version": "1.0.0", + "require": { + "package/b": "^1.0 || ^2.0" + } + }, + { + "name": "package/b", + "version": "1.0.0", + "require": { + "package/c": "^1.0" + } + }, + { + "name": "package/b", + "version": "1.0.1", + "require": { + "package/c": "^1.0" + } + }, + { + "name": "package/b", + "version": "2.0.0", + "require": { + "package/c": "^1.0" + } + }, + { + "name": "package/c", + "version": "1.0.0", + "require": { + "package/d": "^1.0" + } + }, + { + "name": "package/d", + "version": "1.0.0", + "require": { + "package/b": ">=1.0 <1.1" + } + } +] + + +--POOL-AFTER-- +[ + { + "name": "package/a", + "version": "1.0.0", + "require": { + "package/b": "^1.0 || ^2.0" + } + }, + { + "name": "package/b", + "version": "1.0.1", + "require": { + "package/c": "^1.0" + } + }, + { + "name": "package/b", + "version": "2.0.0", + "require": { + "package/c": "^1.0" + } + }, + { + "name": "package/c", + "version": "1.0.0", + "require": { + "package/d": "^1.0" + } + }, + { + "name": "package/d", + "version": "1.0.0", + "require": { + "package/b": ">=1.0 <1.1" + } + } +] + diff --git a/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/locked-fixed-untouched.test b/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/locked-fixed-untouched.test new file mode 100644 index 000000000..b1bfb5e79 --- /dev/null +++ b/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/locked-fixed-untouched.test @@ -0,0 +1,46 @@ +--TEST-- +Test locked and fixed packages remain untouched. + +--REQUEST-- +{ + "require": { + }, + "locked": [ + { + "name": "package/a", + "version": "1.0.0" + } + ], + "fixed": [ + { + "name": "package/c", + "version": "2.0.0" + } + ] +} + + +--POOL-BEFORE-- +[ + { + "name": "package/a", + "version": "1.0.0" + }, + { + "name": "package/c", + "version": "2.0.0" + } +] + + +--POOL-AFTER-- +[ + { + "name": "package/a", + "version": "1.0.0" + }, + { + "name": "package/c", + "version": "2.0.0" + } +] diff --git a/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/replaces.test b/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/replaces.test new file mode 100644 index 000000000..8e9da1446 --- /dev/null +++ b/tests/Composer/Test/DependencyResolver/Fixtures/pooloptimizer/replaces.test @@ -0,0 +1,59 @@ +--TEST-- +Test replaced packages are correctly removed. + +--REQUEST-- +{ + "require": { + "package/a": "^1.0" + } +} + + +--POOL-BEFORE-- +[ + { + "name": "package/a", + "version": "1.0.0", + "require": { + "package/b": "^1.0" + } + }, + { + "name": "package/b", + "version": "1.0.0", + "replace": { + "package/c": "^1.0" + } + }, + { + "name": "package/b", + "version": "1.0.1", + "replace": { + "package/c": "^1.0" + } + }, + { + "name": "package/c", + "version": "1.0.0" + } +] + + +--POOL-AFTER-- +[ + { + "name": "package/a", + "version": "1.0.0", + "require": { + "package/b": "^1.0" + } + }, + { + "name": "package/b", + "version": "1.0.1", + "replace": { + "package/c": "^1.0" + } + } +] + diff --git a/tests/Composer/Test/DependencyResolver/PoolBuilderTest.php b/tests/Composer/Test/DependencyResolver/PoolBuilderTest.php index 534e6db31..3eb0ebc41 100644 --- a/tests/Composer/Test/DependencyResolver/PoolBuilderTest.php +++ b/tests/Composer/Test/DependencyResolver/PoolBuilderTest.php @@ -12,6 +12,9 @@ namespace Composer\Test\DependencyResolver; +use Composer\DependencyResolver\DefaultPolicy; +use Composer\DependencyResolver\Pool; +use Composer\DependencyResolver\PoolOptimizer; use Composer\IO\NullIO; use Composer\Repository\ArrayRepository; use Composer\Repository\FilterRepository; @@ -31,13 +34,14 @@ class PoolBuilderTest extends TestCase * @dataProvider getIntegrationTests * @param string $file * @param string $message - * @param mixed[] $expect + * @param string[] $expect + * @param string[] $expectOptimized * @param mixed[] $root * @param mixed[] $requestData * @param mixed[] $packageRepos * @param mixed[] $fixed */ - public function testPoolBuilder($file, $message, $expect, $root, $requestData, $packageRepos, $fixed) + public function testPoolBuilder($file, $message, $expect, $expectOptimized, $root, $requestData, $packageRepos, $fixed) { $rootAliases = !empty($root['aliases']) ? $root['aliases'] : array(); $minimumStability = !empty($root['minimum-stability']) ? $root['minimum-stability'] : 'stable'; @@ -56,6 +60,8 @@ class PoolBuilderTest extends TestCase $loader = new ArrayLoader(); $packageIds = array(); $loadPackage = function ($data) use ($loader, &$packageIds) { + /** @var ?int $id */ + $id = null; if (!empty($data['id'])) { $id = $data['id']; unset($data['id']); @@ -115,12 +121,28 @@ class PoolBuilderTest extends TestCase } $pool = $repositorySet->createPool($request, new NullIO()); + + $result = $this->getPackageResultSet($pool, $packageIds); + + $this->assertSame($expect, $result, 'Unoptimized pool does not match expected package set'); + + $optimizer = new PoolOptimizer(new DefaultPolicy()); + $result = $this->getPackageResultSet($optimizer->optimize($request, $pool), $packageIds); + $this->assertSame($expectOptimized, $result, 'Optimized pool does not match expected package set'); + } + + /** + * @param array $packageIds + * @return string[] + */ + private function getPackageResultSet(Pool $pool, $packageIds) + { $result = array(); for ($i = 1, $count = count($pool); $i <= $count; $i++) { $result[] = $pool->packageById($i); } - $result = array_map(function ($package) use ($packageIds) { + return array_map(function (BasePackage $package) use ($packageIds) { if ($id = array_search($package, $packageIds, true)) { return $id; } @@ -143,8 +165,6 @@ class PoolBuilderTest extends TestCase return (string) $package->getName().'-'.$package->getVersion() . $suffix; }, $result); - - $this->assertSame($expect, $result); } /** @@ -173,11 +193,12 @@ class PoolBuilderTest extends TestCase $fixed = JsonFile::parseJson($testData['FIXED']); } $expect = JsonFile::parseJson($testData['EXPECT']); + $expectOptimized = !empty($testData['EXPECT-OPTIMIZED']) ? JsonFile::parseJson($testData['EXPECT-OPTIMIZED']) : $expect; } catch (\Exception $e) { die(sprintf('Test "%s" is not valid: '.$e->getMessage(), str_replace($fixturesDir.'/', '', $file))); } - $tests[basename($file)] = array(str_replace($fixturesDir.'/', '', $file), $message, $expect, $root, $request, $packageRepos, $fixed); + $tests[basename($file)] = array(str_replace($fixturesDir.'/', '', $file), $message, $expect, $expectOptimized, $root, $request, $packageRepos, $fixed); } return $tests; @@ -199,6 +220,7 @@ class PoolBuilderTest extends TestCase 'FIXED' => false, 'PACKAGE-REPOS' => true, 'EXPECT' => true, + 'EXPECT-OPTIMIZED' => false, ); $section = null; diff --git a/tests/Composer/Test/DependencyResolver/PoolOptimizerTest.php b/tests/Composer/Test/DependencyResolver/PoolOptimizerTest.php new file mode 100644 index 000000000..27358e62f --- /dev/null +++ b/tests/Composer/Test/DependencyResolver/PoolOptimizerTest.php @@ -0,0 +1,197 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\DependencyResolver; + +use Composer\DependencyResolver\DefaultPolicy; +use Composer\DependencyResolver\Pool; +use Composer\DependencyResolver\PoolOptimizer; +use Composer\DependencyResolver\Request; +use Composer\Json\JsonFile; +use Composer\Package\AliasPackage; +use Composer\Package\BasePackage; +use Composer\Package\Loader\ArrayLoader; +use Composer\Package\Version\VersionParser; +use Composer\Repository\LockArrayRepository; +use Composer\Test\TestCase; + +class PoolOptimizerTest extends TestCase +{ + /** + * @dataProvider provideIntegrationTests + * @param mixed[] $requestData + * @param BasePackage[] $packagesBefore + * @param BasePackage[] $expectedPackages + * @param string $message + */ + public function testPoolOptimizer(array $requestData, array $packagesBefore, array $expectedPackages, $message) + { + $lockedRepo = new LockArrayRepository(); + + $request = new Request($lockedRepo); + $parser = new VersionParser(); + + if (isset($requestData['locked'])) { + foreach ($requestData['locked'] as $package) { + $request->lockPackage($this->loadPackage($package)); + } + } + if (isset($requestData['fixed'])) { + foreach ($requestData['fixed'] as $package) { + $request->fixPackage($this->loadPackage($package)); + } + } + + foreach ($requestData['require'] as $package => $constraint) { + $request->requireName($package, $parser->parseConstraints($constraint)); + } + + $preferStable = isset($requestData['preferStable']) ? $requestData['preferStable'] : false; + $preferLowest = isset($requestData['preferLowest']) ? $requestData['preferLowest'] : false; + + $pool = new Pool($packagesBefore); + $poolOptimizer = new PoolOptimizer(new DefaultPolicy($preferStable, $preferLowest)); + + $pool = $poolOptimizer->optimize($request, $pool); + + $this->assertSame( + $this->reducePackagesInfoForComparison($expectedPackages), + $this->reducePackagesInfoForComparison($pool->getPackages()), + $message + ); + } + + public function provideIntegrationTests() + { + $fixturesDir = realpath(__DIR__.'/Fixtures/pooloptimizer/'); + $tests = array(); + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($fixturesDir), \RecursiveIteratorIterator::LEAVES_ONLY) as $file) { + if (!preg_match('/\.test$/', $file)) { + continue; + } + + try { + $testData = $this->readTestFile($file, $fixturesDir); + $message = $testData['TEST']; + $requestData = JsonFile::parseJson($testData['REQUEST']); + $packagesBefore = $this->loadPackages(JsonFile::parseJson($testData['POOL-BEFORE'])); + $expectedPackages = $this->loadPackages(JsonFile::parseJson($testData['POOL-AFTER'])); + + } catch (\Exception $e) { + die(sprintf('Test "%s" is not valid: '.$e->getMessage(), str_replace($fixturesDir.'/', '', $file))); + } + + $tests[basename($file)] = array($requestData, $packagesBefore, $expectedPackages, $message); + } + + return $tests; + } + + /** + * @param string $fixturesDir + * @return mixed[] + */ + protected function readTestFile(\SplFileInfo $file, $fixturesDir) + { + $tokens = preg_split('#(?:^|\n*)--([A-Z-]+)--\n#', file_get_contents($file->getRealPath()), -1, PREG_SPLIT_DELIM_CAPTURE); + + /** @var array $sectionInfo */ + $sectionInfo = array( + 'TEST' => true, + 'REQUEST' => true, + 'POOL-BEFORE' => true, + 'POOL-AFTER' => true, + ); + + $section = null; + $data = array(); + foreach ($tokens as $i => $token) { + if (null === $section && empty($token)) { + continue; // skip leading blank + } + + if (null === $section) { + if (!isset($sectionInfo[$token])) { + throw new \RuntimeException(sprintf( + 'The test file "%s" must not contain a section named "%s".', + str_replace($fixturesDir.'/', '', $file), + $token + )); + } + $section = $token; + continue; + } + + $sectionData = $token; + + $data[$section] = $sectionData; + $section = $sectionData = null; + } + + foreach ($sectionInfo as $section => $required) { + if ($required && !isset($data[$section])) { + throw new \RuntimeException(sprintf( + 'The test file "%s" must have a section named "%s".', + str_replace($fixturesDir.'/', '', $file), + $section + )); + } + } + + return $data; + } + + /** + * @param BasePackage[] $packages + * @return string[] + */ + private function reducePackagesInfoForComparison(array $packages) + { + $packagesInfo = array(); + + foreach ($packages as $package) { + $packagesInfo[] = $package->getName() . '@' . $package->getVersion() . ($package instanceof AliasPackage ? ' (alias of '.$package->getAliasOf()->getVersion().')' : ''); + } + + sort($packagesInfo); + + return $packagesInfo; + } + + /** + * @param mixed[][] $packagesData + * @return BasePackage[] + */ + private function loadPackages(array $packagesData) + { + $packages = array(); + + foreach ($packagesData as $packageData) { + $packages[] = $package = $this->loadPackage($packageData); + if ($package instanceof AliasPackage) { + $packages[] = $package->getAliasOf(); + } + } + + return $packages; + } + + /** + * @param mixed[] $packageData + * @return BasePackage + */ + private function loadPackage(array $packageData) + { + $loader = new ArrayLoader(); + return $loader->load($packageData); + } +} diff --git a/tests/Composer/Test/Fixtures/installer/conflict-downgrade-nested.test b/tests/Composer/Test/Fixtures/installer/conflict-downgrade-nested.test new file mode 100644 index 000000000..559f653fe --- /dev/null +++ b/tests/Composer/Test/Fixtures/installer/conflict-downgrade-nested.test @@ -0,0 +1,39 @@ +--TEST-- + +Test that a package which has a conflict does not get installed and has to be downgraded + +--COMPOSER-- +{ + "repositories": [ + { + "type": "package", + "package": [ + { "name": "nesty/nest", "version": "1.0.0", "require": { + "conflicter/pkg": "^1.0", + "victim/pkg": "^1 <1.2" + } }, + { "name": "conflicter/pkg", "version": "1.0.1", "conflict": { "victim/pkg": "1.1.0"} }, + { "name": "victim/pkg", "version": "1.0.0" }, + { "name": "victim/pkg", "version": "1.0.1" }, + { "name": "victim/pkg", "version": "1.0.2" }, + { "name": "victim/pkg", "version": "1.1.0" }, + { "name": "victim/pkg", "version": "1.2.0" } + ] + } + ], + "require": { + "nesty/nest": "*" + } +} + + +--RUN-- +update + +--EXPECT-EXIT-CODE-- +0 + +--EXPECT-- +Installing victim/pkg (1.0.2) +Installing conflicter/pkg (1.0.1) +Installing nesty/nest (1.0.0) diff --git a/tests/Composer/Test/Fixtures/installer/conflict-downgrade.test b/tests/Composer/Test/Fixtures/installer/conflict-downgrade.test new file mode 100644 index 000000000..31741ac8a --- /dev/null +++ b/tests/Composer/Test/Fixtures/installer/conflict-downgrade.test @@ -0,0 +1,35 @@ +--TEST-- + +Test that a package which has a conflict does not get installed and has to be downgraded + +--COMPOSER-- +{ + "repositories": [ + { + "type": "package", + "package": [ + { "name": "conflicter/pkg", "version": "1.0.1", "conflict": { "victim/pkg": "1.1.0"} }, + { "name": "victim/pkg", "version": "1.0.0" }, + { "name": "victim/pkg", "version": "1.0.1" }, + { "name": "victim/pkg", "version": "1.0.2" }, + { "name": "victim/pkg", "version": "1.1.0" }, + { "name": "victim/pkg", "version": "1.2.0" } + ] + } + ], + "require": { + "conflicter/pkg": "^1.0", + "victim/pkg": "^1 <1.2" + } +} + + +--RUN-- +update + +--EXPECT-EXIT-CODE-- +0 + +--EXPECT-- +Installing conflicter/pkg (1.0.1) +Installing victim/pkg (1.0.2) diff --git a/tests/Composer/Test/Fixtures/installer/deduplicate-solver-problems.test b/tests/Composer/Test/Fixtures/installer/deduplicate-solver-problems.test index bbb245914..778abe1f6 100644 --- a/tests/Composer/Test/Fixtures/installer/deduplicate-solver-problems.test +++ b/tests/Composer/Test/Fixtures/installer/deduplicate-solver-problems.test @@ -44,5 +44,14 @@ Your requirements could not be resolved to an installable set of packages. - package/a[2.0.0, ..., 2.6.0] require missing/dep ^1.0 -> found missing/dep[2.0.0] but it does not match the constraint. - Root composer.json requires package/a * -> satisfiable by package/a[2.0.0, ..., 2.6.0]. +--EXPECT-OUTPUT-OPTIMIZED-- +Loading composer repositories with package information +Updating dependencies +Your requirements could not be resolved to an installable set of packages. + + Problem 1 + - Root composer.json requires package/a * -> satisfiable by package/a[2.6.0]. + - package/a 2.6.0 requires missing/dep ^1.0 -> found missing/dep[2.0.0] but it does not match the constraint. + --EXPECT-- diff --git a/tests/Composer/Test/Fixtures/installer/github-issues-7051.test b/tests/Composer/Test/Fixtures/installer/github-issues-7051.test index 7683c7482..51cd96f40 100644 --- a/tests/Composer/Test/Fixtures/installer/github-issues-7051.test +++ b/tests/Composer/Test/Fixtures/installer/github-issues-7051.test @@ -128,6 +128,21 @@ Your requirements could not be resolved to an installable set of packages. - You can only install one version of a package, so only one of these can be installed: symfony/console[v2.8.7, v2.8.8, v3.1.9, ..., v3.4.29]. - illuminate/console v5.2.25 requires symfony/console 3.1.* -> satisfiable by symfony/console[v3.1.9, v3.1.10]. - Conclusion: don't install symfony/console v3.1.10 (conflict analysis result) + +--EXPECT-OUTPUT-OPTIMIZED-- +Loading composer repositories with package information +Updating dependencies +Your requirements could not be resolved to an installable set of packages. + + Problem 1 + - Root composer.json requires illuminate/queue * -> satisfiable by illuminate/queue[v5.2.0]. + - illuminate/queue v5.2.0 requires illuminate/console 5.2.* -> satisfiable by illuminate/console[v5.2.25, v5.2.26]. + - illuminate/console v5.2.25 requires symfony/console 3.1.* -> satisfiable by symfony/console[v3.1.10]. + - illuminate/console v5.2.26 requires symfony/console 2.8.* -> satisfiable by symfony/console[v2.8.8]. + - You can only install one version of a package, so only one of these can be installed: symfony/console[v2.8.8, v3.1.10, v3.4.29]. + - friendsofphp/php-cs-fixer v2.10.5 requires symfony/console ^3.2 || ^4.0 -> satisfiable by symfony/console[v3.4.29]. + - Root composer.json requires friendsofphp/php-cs-fixer * -> satisfiable by friendsofphp/php-cs-fixer[v2.10.5]. + --EXPECT-- --EXPECT-EXIT-CODE-- diff --git a/tests/Composer/Test/Fixtures/installer/provider-conflicts3.test b/tests/Composer/Test/Fixtures/installer/provider-conflicts3.test index 213b1e1e1..25fab5ee3 100644 --- a/tests/Composer/Test/Fixtures/installer/provider-conflicts3.test +++ b/tests/Composer/Test/Fixtures/installer/provider-conflicts3.test @@ -44,5 +44,15 @@ Your requirements could not be resolved to an installable set of packages. - Root composer.json requires regular/pkg 1.* -> satisfiable by regular/pkg[1.0.0, 1.0.1, 1.0.2, 1.0.3]. - Root composer.json requires replacer/pkg 2.* -> satisfiable by replacer/pkg[2.0.0, 2.0.1, 2.0.2, 2.0.3]. +--EXPECT-OUTPUT-OPTIMIZED-- +Loading composer repositories with package information +Updating dependencies +Your requirements could not be resolved to an installable set of packages. + + Problem 1 + - Only one of these can be installed: regular/pkg[1.0.3], replacer/pkg[2.0.0, 2.0.1, 2.0.2, 2.0.3]. replacer/pkg replaces regular/pkg and thus cannot coexist with it. + - Root composer.json requires regular/pkg 1.* -> satisfiable by regular/pkg[1.0.3]. + - Root composer.json requires replacer/pkg 2.* -> satisfiable by replacer/pkg[2.0.0, 2.0.1, 2.0.2, 2.0.3]. + --EXPECT-- diff --git a/tests/Composer/Test/InstallerTest.php b/tests/Composer/Test/InstallerTest.php index 79f03f824..20d157c11 100644 --- a/tests/Composer/Test/InstallerTest.php +++ b/tests/Composer/Test/InstallerTest.php @@ -36,6 +36,7 @@ use Composer\Package\Locker; use Composer\Test\Mock\FactoryMock; use Composer\Test\Mock\InstalledFilesystemRepositoryMock; use Composer\Test\Mock\InstallationManagerMock; +use Composer\Util\Platform; use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Output\StreamOutput; use Symfony\Component\Console\Output\OutputInterface; @@ -56,6 +57,8 @@ class InstallerTest extends TestCase public function tearDown() { + Platform::clearEnv('COMPOSER_POOL_OPTIMIZER'); + chdir($this->prevCwd); if (isset($this->tempComposerHome) && is_dir($this->tempComposerHome)) { $fs = new Filesystem; @@ -228,16 +231,64 @@ class InstallerTest extends TestCase * @param mixed[]|false $expectLock * @param ?mixed[] $expectInstalled * @param ?string $expectOutput + * @param ?string $expectOutputOptimized * @param string $expect * @param int|string $expectResult */ - public function testSlowIntegration($file, $message, $condition, $composerConfig, $lock, $installed, $run, $expectLock, $expectInstalled, $expectOutput, $expect, $expectResult) + public function testSlowIntegration($file, $message, $condition, $composerConfig, $lock, $installed, $run, $expectLock, $expectInstalled, $expectOutput, $expectOutputOptimized, $expect, $expectResult) { - return $this->testIntegration($file, $message, $condition, $composerConfig, $lock, $installed, $run, $expectLock, $expectInstalled, $expectOutput, $expect, $expectResult); + Platform::putEnv('COMPOSER_POOL_OPTIMIZER', '0'); + + $this->doTestIntegration($file, $message, $condition, $composerConfig, $lock, $installed, $run, $expectLock, $expectInstalled, $expectOutput, $expect, $expectResult); } /** * @dataProvider provideIntegrationTests + * @param string $file + * @param string $message + * @param ?string $condition + * @param Config $composerConfig + * @param ?mixed[] $lock + * @param ?mixed[] $installed + * @param string $run + * @param mixed[]|false $expectLock + * @param ?mixed[] $expectInstalled + * @param ?string $expectOutput + * @param ?string $expectOutputOptimized + * @param string $expect + * @param int|string $expectResult + */ + public function testIntegrationWithPoolOptimizer($file, $message, $condition, $composerConfig, $lock, $installed, $run, $expectLock, $expectInstalled, $expectOutput, $expectOutputOptimized, $expect, $expectResult) + { + Platform::putEnv('COMPOSER_POOL_OPTIMIZER', '1'); + + $this->doTestIntegration($file, $message, $condition, $composerConfig, $lock, $installed, $run, $expectLock, $expectInstalled, $expectOutputOptimized ?: $expectOutput, $expect, $expectResult); + } + + /** + * @dataProvider provideIntegrationTests + * @param string $file + * @param string $message + * @param ?string $condition + * @param Config $composerConfig + * @param ?mixed[] $lock + * @param ?mixed[] $installed + * @param string $run + * @param mixed[]|false $expectLock + * @param ?mixed[] $expectInstalled + * @param ?string $expectOutput + * @param ?string $expectOutputOptimized + * @param string $expect + * @param int|string $expectResult + */ + public function testIntegrationWithRawPool($file, $message, $condition, $composerConfig, $lock, $installed, $run, $expectLock, $expectInstalled, $expectOutput, $expectOutputOptimized, $expect, $expectResult) + { + Platform::putEnv('COMPOSER_POOL_OPTIMIZER', '0'); + + $this->doTestIntegration($file, $message, $condition, $composerConfig, $lock, $installed, $run, $expectLock, $expectInstalled, $expectOutput, $expect, $expectResult); + } + + /** * @param string $file * @param string $message * @param ?string $condition @@ -250,8 +301,9 @@ class InstallerTest extends TestCase * @param ?string $expectOutput * @param string $expect * @param int|string $expectResult + * @return void */ - public function testIntegration($file, $message, $condition, $composerConfig, $lock, $installed, $run, $expectLock, $expectInstalled, $expectOutput, $expect, $expectResult) + private function doTestIntegration($file, $message, $condition, $composerConfig, $lock, $installed, $run, $expectLock, $expectInstalled, $expectOutput, $expect, $expectResult) { if ($condition) { eval('$res = '.$condition.';'); @@ -518,6 +570,7 @@ class InstallerTest extends TestCase $expectInstalled = JsonFile::parseJson($testData['EXPECT-INSTALLED']); } $expectOutput = isset($testData['EXPECT-OUTPUT']) ? $testData['EXPECT-OUTPUT'] : null; + $expectOutputOptimized = isset($testData['EXPECT-OUTPUT-OPTIMIZED']) ? $testData['EXPECT-OUTPUT-OPTIMIZED'] : null; $expect = $testData['EXPECT']; if (!empty($testData['EXPECT-EXCEPTION'])) { $expectResult = $testData['EXPECT-EXCEPTION']; @@ -533,7 +586,7 @@ class InstallerTest extends TestCase die(sprintf('Test "%s" is not valid: '.$e->getMessage(), str_replace($fixturesDir.'/', '', $file))); } - $tests[basename($file)] = array(str_replace($fixturesDir.'/', '', $file), $message, $condition, $composer, $lock, $installed, $run, $expectLock, $expectInstalled, $expectOutput, $expect, $expectResult); + $tests[basename($file)] = array(str_replace($fixturesDir.'/', '', $file), $message, $condition, $composer, $lock, $installed, $run, $expectLock, $expectInstalled, $expectOutput, $expectOutputOptimized, $expect, $expectResult); } return $tests; @@ -557,6 +610,7 @@ class InstallerTest extends TestCase 'EXPECT-LOCK' => false, 'EXPECT-INSTALLED' => false, 'EXPECT-OUTPUT' => false, + 'EXPECT-OUTPUT-OPTIMIZED' => false, 'EXPECT-EXIT-CODE' => false, 'EXPECT-EXCEPTION' => false, 'EXPECT' => true, diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 95bbc39d9..a35126cff 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -10,6 +10,8 @@ * file that was distributed with this source code. */ +use Composer\Util\Platform; + error_reporting(E_ALL); if (function_exists('date_default_timezone_set') && function_exists('date_default_timezone_get')) { @@ -19,3 +21,5 @@ if (function_exists('date_default_timezone_set') && function_exists('date_defaul require __DIR__.'/../src/bootstrap.php'; require __DIR__.'/../src/Composer/InstalledVersions.php'; require __DIR__.'/Composer/Test/TestCase.php'; + +Platform::putEnv('COMPOSER_TESTS_ARE_RUNNING', '1'); diff --git a/tests/deprecations-8.1.json b/tests/deprecations-8.1.json index ea9967eaf..f754b566d 100644 --- a/tests/deprecations-8.1.json +++ b/tests/deprecations-8.1.json @@ -75,13 +75,18 @@ "count": 1 }, { - "location": "Composer\\Test\\InstallerTest::testIntegration", + "location": "Composer\\Test\\InstallerTest::testIntegrationWithRawPool", "message": "preg_match(): Passing null to parameter #4 ($flags) of type int is deprecated", - "count": 1640 + "count": 1728 + }, + { + "location": "Composer\\Test\\InstallerTest::testIntegrationWithPoolOptimizer", + "message": "preg_match(): Passing null to parameter #4 ($flags) of type int is deprecated", + "count": 1728 }, { "location": "Composer\\Test\\Package\\Archiver\\ArchivableFilesFinderTest::testManualExcludes", "message": "Return type of Symfony\\Component\\Finder\\Iterator\\CustomFilterIterator::accept() should either be compatible with FilterIterator::accept(): bool, or the #[\\ReturnTypeWillChange] attribute should be used to temporarily suppress the notice", "count": 1 } -] \ No newline at end of file +]