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..857f62df8 100644 --- a/src/Composer/DependencyResolver/Pool.php +++ b/src/Composer/DependencyResolver/Pool.php @@ -36,16 +36,57 @@ class Pool implements \Countable protected $providerCache = array(); /** @var BasePackage[] */ protected $unacceptableFixedOrLockedPackages; + /** @var array> Map of package name => normalized version => pretty version */ + protected $removedVersions = array(); + /** @var array> Map of package object hash => removed normalized versions => removed pretty version */ + protected $removedVersionsByPackage = array(); /** * @param BasePackage[] $packages * @param BasePackage[] $unacceptableFixedOrLockedPackages + * @param array> $removedVersions + * @param array> $removedVersionsByPackage */ - public function __construct(array $packages = array(), array $unacceptableFixedOrLockedPackages = array()) + public function __construct(array $packages = array(), array $unacceptableFixedOrLockedPackages = array(), array $removedVersions = array(), array $removedVersionsByPackage = array()) { $this->versionParser = new VersionParser; $this->setPackages($packages); $this->unacceptableFixedOrLockedPackages = $unacceptableFixedOrLockedPackages; + $this->removedVersions = $removedVersions; + $this->removedVersionsByPackage = $removedVersionsByPackage; + } + + /** + * @param string $name + * @return array + */ + public function getRemovedVersions($name, ConstraintInterface $constraint) + { + if (!isset($this->removedVersions[$name])) { + return array(); + } + + $result = array(); + foreach ($this->removedVersions[$name] as $version => $prettyVersion) { + if ($constraint->matches(new Constraint('==', $version))) { + $result[$version] = $prettyVersion; + } + } + + return $result; + } + + /** + * @param string $objectHash + * @return array + */ + public function getRemovedVersionsByPackage($objectHash) + { + if (!isset($this->removedVersionsByPackage[$objectHash])) { + return array(); + } + + return $this->removedVersionsByPackage[$objectHash]; } /** @@ -221,6 +262,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..c05045a2b --- /dev/null +++ b/src/Composer/DependencyResolver/PoolOptimizer.php @@ -0,0 +1,386 @@ + + * 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\Package\Version\VersionParser; +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(); + + /** + * @var array> + */ + private $removedVersionsByPackage = 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(); + $this->removedVersionsByPackage = 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(); + $removedVersions = array(); + foreach ($pool->getPackages() as $package) { + if (!isset($this->packagesToRemove[$package->id])) { + $packages[] = $package; + } else { + $removedVersions[$package->getName()][$package->getVersion()] = $package->getPrettyVersion(); + } + } + + $optimizedPool = new Pool($packages, $pool->getUnacceptableFixedOrLockedPackages(), $removedVersions, $this->removedVersionsByPackage); + + // Reset package removals + $this->packagesToRemove = array(); + + return $optimizedPool; + } + + /** + * @return Pool + */ + private function optimizeByIdenticalDependencies(Pool $pool) + { + $identicalDefinitionsPerPackage = array(); + $packageIdenticalDefinitionLookup = 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; + } + + $this->markPackageForRemoval($package->id); + + $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; + } + + $groupHash = implode('', $groupHashParts); + $identicalDefinitionsPerPackage[$packageName][$groupHash][$dependencyHash][] = $package; + $packageIdenticalDefinitionLookup[$package->id][$packageName] = array('groupHash' => $groupHash, 'dependencyHash' => $dependencyHash); + } + } + } + + foreach ($identicalDefinitionsPerPackage as $constraintGroups) { + foreach ($constraintGroups as $constraintGroup) { + foreach ($constraintGroup as $packages) { + // Only one package in this constraint group has the same requirements, we're not allowed to remove that package + if (1 === \count($packages)) { + $this->keepPackage($packages[0], $identicalDefinitionsPerPackage, $packageIdenticalDefinitionLookup); + 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) { + $this->keepPackage($pool->literalToPackage($preferredLiteral), $identicalDefinitionsPerPackage, $packageIdenticalDefinitionLookup); + } + } + } + } + + 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; + } + + /** + * @param array>>> $identicalDefinitionsPerPackage + * @param array> $packageIdenticalDefinitionLookup + * @return void + */ + private function keepPackage(BasePackage $package, $identicalDefinitionsPerPackage, $packageIdenticalDefinitionLookup) + { + unset($this->packagesToRemove[$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 + $this->keepPackage($package->getAliasOf(), $identicalDefinitionsPerPackage, $packageIdenticalDefinitionLookup); + } + + // record all the versions of the package group so we can list them later in Problem output + foreach ($package->getNames(false) as $name) { + if (isset($packageIdenticalDefinitionLookup[$package->id][$name])) { + $packageGroupPointers = $packageIdenticalDefinitionLookup[$package->id][$name]; + $packageGroup = $identicalDefinitionsPerPackage[$name][$packageGroupPointers['groupHash']][$packageGroupPointers['dependencyHash']]; + foreach ($packageGroup as $pkg) { + if ($pkg instanceof AliasPackage && $pkg->getPrettyVersion() === VersionParser::DEFAULT_BRANCH_ALIAS) { + $pkg = $pkg->getAliasOf(); + } + $this->removedVersionsByPackage[spl_object_hash($package)][$pkg->getVersion()] = $pkg->getPrettyVersion(); + } + } + } + + if (isset($this->aliasesPerPackage[$package->id])) { + foreach ($this->aliasesPerPackage[$package->id] as $aliasPackage) { + unset($this->packagesToRemove[$aliasPackage->id]); + + // record all the versions of the package group so we can list them later in Problem output + foreach ($aliasPackage->getNames(false) as $name) { + if (isset($packageIdenticalDefinitionLookup[$aliasPackage->id][$name])) { + $packageGroupPointers = $packageIdenticalDefinitionLookup[$aliasPackage->id][$name]; + $packageGroup = $identicalDefinitionsPerPackage[$name][$packageGroupPointers['groupHash']][$packageGroupPointers['dependencyHash']]; + foreach ($packageGroup as $pkg) { + if ($pkg instanceof AliasPackage && $pkg->getPrettyVersion() === VersionParser::DEFAULT_BRANCH_ALIAS) { + $pkg = $pkg->getAliasOf(); + } + $this->removedVersionsByPackage[spl_object_hash($aliasPackage)][$pkg->getVersion()] = $pkg->getPrettyVersion(); + } + } + } + } + } + } +} diff --git a/src/Composer/DependencyResolver/Problem.php b/src/Composer/DependencyResolver/Problem.php index 507e45d17..aec0a33d1 100644 --- a/src/Composer/DependencyResolver/Problem.php +++ b/src/Composer/DependencyResolver/Problem.php @@ -126,6 +126,10 @@ class Problem $template = preg_replace('{^\S+ \S+ }', '%s%s ', $message); $messages[] = $template; $templates[$template][$m[1]][$parser->normalize($m[2])] = $m[2]; + $sourcePackage = $rule->getSourcePackage($pool); + foreach ($pool->getRemovedVersionsByPackage(spl_object_hash($sourcePackage)) as $version => $prettyVersion) { + $templates[$template][$m[1]][$version] = $prettyVersion; + } } elseif ($message !== '') { $messages[] = $message; } @@ -267,7 +271,7 @@ class Problem return $rootReqs[$packageName]->matches(new Constraint('==', $p->getVersion())); }); if (0 === count($filtered)) { - return array("- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose).' but '.(self::hasMultipleNames($packages) ? 'these conflict' : 'it conflicts').' with your root composer.json require ('.$rootReqs[$packageName]->getPrettyString().').'); + return array("- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose, $pool, $constraint).' but '.(self::hasMultipleNames($packages) ? 'these conflict' : 'it conflicts').' with your root composer.json require ('.$rootReqs[$packageName]->getPrettyString().').'); } } @@ -277,7 +281,7 @@ class Problem return $fixedConstraint->matches(new Constraint('==', $p->getVersion())); }); if (0 === count($filtered)) { - return array("- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose).' but the package is fixed to '.$lockedPackage->getPrettyVersion().' (lock file version) by a partial update and that version does not match. Make sure you list it as an argument for the update command.'); + return array("- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose, $pool, $constraint).' but the package is fixed to '.$lockedPackage->getPrettyVersion().' (lock file version) by a partial update and that version does not match. Make sure you list it as an argument for the update command.'); } } @@ -286,27 +290,27 @@ class Problem }); if (!$nonLockedPackages) { - return array("- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose).' in the lock file but not in remote repositories, make sure you avoid updating this package to keep the one from the lock file.'); + return array("- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose, $pool, $constraint).' in the lock file but not in remote repositories, make sure you avoid updating this package to keep the one from the lock file.'); } - return array("- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose).' but these were not loaded, likely because '.(self::hasMultipleNames($packages) ? 'they conflict' : 'it conflicts').' with another require.'); + return array("- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose, $pool, $constraint).' but these were not loaded, likely because '.(self::hasMultipleNames($packages) ? 'they conflict' : 'it conflicts').' with another require.'); } // check if the package is found when bypassing stability checks if ($packages = $repositorySet->findPackages($packageName, $constraint, RepositorySet::ALLOW_UNACCEPTABLE_STABILITIES)) { // we must first verify if a valid package would be found in a lower priority repository if ($allReposPackages = $repositorySet->findPackages($packageName, $constraint, RepositorySet::ALLOW_SHADOWED_REPOSITORIES)) { - return self::computeCheckForLowerPrioRepo($isVerbose, $packageName, $packages, $allReposPackages, 'minimum-stability', $constraint); + return self::computeCheckForLowerPrioRepo($pool, $isVerbose, $packageName, $packages, $allReposPackages, 'minimum-stability', $constraint); } - return array("- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose).' but '.(self::hasMultipleNames($packages) ? 'these do' : 'it does').' not match your minimum-stability.'); + return array("- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose, $pool, $constraint).' but '.(self::hasMultipleNames($packages) ? 'these do' : 'it does').' not match your minimum-stability.'); } // check if the package is found when bypassing the constraint and stability checks if ($packages = $repositorySet->findPackages($packageName, null, RepositorySet::ALLOW_UNACCEPTABLE_STABILITIES)) { // we must first verify if a valid package would be found in a lower priority repository if ($allReposPackages = $repositorySet->findPackages($packageName, $constraint, RepositorySet::ALLOW_SHADOWED_REPOSITORIES)) { - return self::computeCheckForLowerPrioRepo($isVerbose, $packageName, $packages, $allReposPackages, 'constraint', $constraint); + return self::computeCheckForLowerPrioRepo($pool, $isVerbose, $packageName, $packages, $allReposPackages, 'constraint', $constraint); } $suffix = ''; @@ -326,7 +330,7 @@ class Problem $suffix = ' See https://getcomposer.org/dep-on-root for details and assistance.'; } - return array("- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose).' but '.(self::hasMultipleNames($packages) ? 'these do' : 'it does').' not match the constraint.' . $suffix); + return array("- Root composer.json requires $packageName".self::constraintToText($constraint) . ', ', 'found '.self::getPackageList($packages, $isVerbose, $pool, $constraint).' but '.(self::hasMultipleNames($packages) ? 'these do' : 'it does').' not match the constraint.' . $suffix); } if (!preg_match('{^[A-Za-z0-9_./-]+$}', $packageName)) { @@ -356,15 +360,26 @@ class Problem * @internal * @param PackageInterface[] $packages * @param bool $isVerbose + * @param bool $useRemovedVersionGroup * @return string */ - public static function getPackageList(array $packages, $isVerbose) + public static function getPackageList(array $packages, $isVerbose, Pool $pool = null, ConstraintInterface $constraint = null, $useRemovedVersionGroup = false) { $prepared = array(); $hasDefaultBranch = array(); foreach ($packages as $package) { $prepared[$package->getName()]['name'] = $package->getPrettyName(); $prepared[$package->getName()]['versions'][$package->getVersion()] = $package->getPrettyVersion().($package instanceof AliasPackage ? ' (alias of '.$package->getAliasOf()->getPrettyVersion().')' : ''); + if ($pool && $constraint) { + foreach ($pool->getRemovedVersions($package->getName(), $constraint) as $version => $prettyVersion) { + $prepared[$package->getName()]['versions'][$version] = $prettyVersion; + } + } + if ($pool && $useRemovedVersionGroup) { + foreach ($pool->getRemovedVersionsByPackage(spl_object_hash($package)) as $version => $prettyVersion) { + $prepared[$package->getName()]['versions'][$version] = $prettyVersion; + } + } if ($package->isDefaultBranch()) { $hasDefaultBranch[$package->getName()] = true; } @@ -469,7 +484,7 @@ class Problem * @param string $reason * @return array{0: string, 1: string} */ - private static function computeCheckForLowerPrioRepo($isVerbose, $packageName, array $higherRepoPackages, array $allReposPackages, $reason, ConstraintInterface $constraint = null) + private static function computeCheckForLowerPrioRepo(Pool $pool, $isVerbose, $packageName, array $higherRepoPackages, array $allReposPackages, $reason, ConstraintInterface $constraint = null) { $nextRepoPackages = array(); $nextRepo = null; @@ -488,7 +503,7 @@ class Problem if ($topPackage instanceof RootPackageInterface) { return array( "- Root composer.json requires $packageName".self::constraintToText($constraint).', it is ', - 'satisfiable by '.self::getPackageList($nextRepoPackages, $isVerbose).' from '.$nextRepo->getRepoName().' but '.$topPackage->getPrettyName().' is the root package and cannot be modified. See https://getcomposer.org/dep-on-root for details and assistance.', + 'satisfiable by '.self::getPackageList($nextRepoPackages, $isVerbose, $pool, $constraint).' from '.$nextRepo->getRepoName().' but '.$topPackage->getPrettyName().' is the root package and cannot be modified. See https://getcomposer.org/dep-on-root for details and assistance.', ); } } @@ -497,10 +512,10 @@ class Problem $singular = count($higherRepoPackages) === 1; return array("- Root composer.json requires $packageName".self::constraintToText($constraint) . ', it is ', - 'found '.self::getPackageList($nextRepoPackages, $isVerbose).' in the lock file and '.self::getPackageList($higherRepoPackages, $isVerbose).' from '.reset($higherRepoPackages)->getRepository()->getRepoName().' but ' . ($singular ? 'it does' : 'these do') . ' not match your '.$reason.' and ' . ($singular ? 'is' : 'are') . ' therefore not installable. Make sure you either fix the '.$reason.' or avoid updating this package to keep the one from the lock file.', ); + 'found '.self::getPackageList($nextRepoPackages, $isVerbose, $pool, $constraint).' in the lock file and '.self::getPackageList($higherRepoPackages, $isVerbose, $pool, $constraint).' from '.reset($higherRepoPackages)->getRepository()->getRepoName().' but ' . ($singular ? 'it does' : 'these do') . ' not match your '.$reason.' and ' . ($singular ? 'is' : 'are') . ' therefore not installable. Make sure you either fix the '.$reason.' or avoid updating this package to keep the one from the lock file.', ); } - return array("- Root composer.json requires $packageName".self::constraintToText($constraint) . ', it is ', 'satisfiable by '.self::getPackageList($nextRepoPackages, $isVerbose).' from '.$nextRepo->getRepoName().' but '.self::getPackageList($higherRepoPackages, $isVerbose).' from '.reset($higherRepoPackages)->getRepository()->getRepoName().' has higher repository priority. The packages with higher priority do not match your '.$reason.' and are therefore not installable. See https://getcomposer.org/repoprio for details and assistance.'); + return array("- Root composer.json requires $packageName".self::constraintToText($constraint) . ', it is ', 'satisfiable by '.self::getPackageList($nextRepoPackages, $isVerbose, $pool, $constraint).' from '.$nextRepo->getRepoName().' but '.self::getPackageList($higherRepoPackages, $isVerbose, $pool, $constraint).' from '.reset($higherRepoPackages)->getRepository()->getRepoName().' has higher repository priority. The packages with higher priority do not match your '.$reason.' and are therefore not installable. See https://getcomposer.org/repoprio for details and assistance.'); } /** diff --git a/src/Composer/DependencyResolver/Rule.php b/src/Composer/DependencyResolver/Rule.php index bafd57994..8f3cf4abc 100644 --- a/src/Composer/DependencyResolver/Rule.php +++ b/src/Composer/DependencyResolver/Rule.php @@ -228,6 +228,41 @@ abstract class Rule return false; } + /** + * @internal + * @return BasePackage + */ + public function getSourcePackage(Pool $pool) + { + $literals = $this->getLiterals(); + + switch ($this->getReason()) { + case self::RULE_PACKAGE_CONFLICT: + $package1 = $this->deduplicateDefaultBranchAlias($pool->literalToPackage($literals[0])); + $package2 = $this->deduplicateDefaultBranchAlias($pool->literalToPackage($literals[1])); + + $conflictTarget = $package1->getPrettyString(); + if ($reasonData = $this->getReasonData()) { + // swap literals if they are not in the right order with package2 being the conflicter + if ($reasonData->getSource() === $package1->getName()) { + list($package2, $package1) = array($package1, $package2); + } + } + + return $package2; + + case self::RULE_PACKAGE_REQUIRES: + $sourceLiteral = array_shift($literals); + $sourcePackage = $this->deduplicateDefaultBranchAlias($pool->literalToPackage($sourceLiteral)); + + return $sourcePackage; + + default: + throw new \LogicException('Not implemented'); + } + } + + /** * @param bool $isVerbose * @param BasePackage[] $installedMap @@ -258,7 +293,7 @@ abstract class Rule } } - return 'Root composer.json requires '.$packageName.($constraint ? ' '.$constraint->getPrettyString() : '').' -> satisfiable by '.$this->formatPackagesUnique($pool, $packages, $isVerbose).'.'; + return 'Root composer.json requires '.$packageName.($constraint ? ' '.$constraint->getPrettyString() : '').' -> satisfiable by '.$this->formatPackagesUnique($pool, $packages, $isVerbose, $constraint).'.'; case self::RULE_FIXED: $package = $this->deduplicateDefaultBranchAlias($this->reasonData['package']); @@ -320,7 +355,7 @@ abstract class Rule $text = $reasonData->getPrettyString($sourcePackage); if ($requires) { - $text .= ' -> satisfiable by ' . $this->formatPackagesUnique($pool, $requires, $isVerbose) . '.'; + $text .= ' -> satisfiable by ' . $this->formatPackagesUnique($pool, $requires, $isVerbose, $this->reasonData->getConstraint()) . '.'; } else { $targetName = $reasonData->getTarget(); @@ -368,13 +403,13 @@ abstract class Rule } if ($installedPackages && $removablePackages) { - return $this->formatPackagesUnique($pool, $removablePackages, $isVerbose).' cannot be installed as that would require removing '.$this->formatPackagesUnique($pool, $installedPackages, $isVerbose).'. '.$reason; + return $this->formatPackagesUnique($pool, $removablePackages, $isVerbose, null, true).' cannot be installed as that would require removing '.$this->formatPackagesUnique($pool, $installedPackages, $isVerbose, null, true).'. '.$reason; } - return 'Only one of these can be installed: '.$this->formatPackagesUnique($pool, $literals, $isVerbose).'. '.$reason; + return 'Only one of these can be installed: '.$this->formatPackagesUnique($pool, $literals, $isVerbose, null, true).'. '.$reason; } - return 'You can only install one version of a package, so only one of these can be installed: ' . $this->formatPackagesUnique($pool, $literals, $isVerbose) . '.'; + return 'You can only install one version of a package, so only one of these can be installed: ' . $this->formatPackagesUnique($pool, $literals, $isVerbose, null, true) . '.'; case self::RULE_LEARNED: /** @TODO currently still generates way too much output to be helpful, and in some cases can even lead to endless recursion */ // if (isset($learnedPool[$this->reasonData])) { @@ -445,9 +480,10 @@ abstract class Rule /** * @param array $packages An array containing packages or literals * @param bool $isVerbose + * @param bool $useRemovedVersionGroup * @return string */ - protected function formatPackagesUnique(Pool $pool, array $packages, $isVerbose) + protected function formatPackagesUnique(Pool $pool, array $packages, $isVerbose, ConstraintInterface $constraint = null, $useRemovedVersionGroup = false) { foreach ($packages as $index => $package) { if (!\is_object($package)) { @@ -455,7 +491,7 @@ abstract class Rule } } - return Problem::getPackageList($packages, $isVerbose); + return Problem::getPackageList($packages, $isVerbose, $pool, $constraint, $useRemovedVersionGroup); } /** diff --git a/src/Composer/Installer.php b/src/Composer/Installer.php index be1b560cf..db681ed44 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,34 +431,97 @@ class Installer $request->setUpdateAllowList($this->updateAllowList, $this->updateAllowTransitiveDependencies); } - $pool = $repositorySet->createPool($request, $this->io, $this->eventDispatcher); + if (Platform::getEnv('COMPOSER_TESTS_ARE_RUNNING') || '2' === Platform::getEnv('COMPOSER_POOL_OPTIMIZER')) { + $pool = $repositorySet->createPool($request, $this->io, $this->eventDispatcher, $this->createPoolOptimizer($policy)); + } else { + $pool = $repositorySet->createPool($request, $this->io, $this->eventDispatcher/*, $this->createPoolOptimizer($policy)*/); + } $this->io->writeError('Updating dependencies'); // solve dependencies $solver = new Solver($policy, $pool, $this->io); - try { - $lockTransaction = $solver->solve($request, $this->platformRequirementFilter); - $ruleSetSize = $solver->getRuleSetSize(); - $solver = null; - } catch (SolverProblemsException $e) { - $err = 'Your requirements could not be resolved to an installable set of packages.'; - $prettyProblem = $e->getPrettyString($repositorySet, $request, $pool, $this->io->isVerbose()); + if (!Platform::getEnv('COMPOSER_TESTS_ARE_RUNNING') && '1' === Platform::getEnv('COMPOSER_POOL_OPTIMIZER')) { + try { + $this->io->writeError("Updating dependencies with default package pool", true, IOInterface::VERBOSE); + $lockTransaction = $solver->solve($request, $this->platformRequirementFilter); + $ruleSetSize = $solver->getRuleSetSize(); + $this->io->writeError("Analyzed ".count($pool)." packages to resolve dependencies", true, IOInterface::VERBOSE); + $this->io->writeError("Analyzed ".$ruleSetSize." rules to resolve dependencies", true, IOInterface::VERBOSE); + + $this->io->writeError("Updating dependencies with optimized package pool", true, IOInterface::VERBOSE); + $pool2 = $repositorySet->createPool($request, $this->io, $this->eventDispatcher, $this->createPoolOptimizer($policy)); + $solver2 = new Solver($policy, $pool2, $this->io); + $lockTransaction2 = $solver2->solve($request, $this->platformRequirementFilter); + $ruleSetSize2 = $solver2->getRuleSetSize(); + $this->io->writeError("Analyzed ".count($pool2)." packages to resolve dependencies", true, IOInterface::VERBOSE); + $this->io->writeError("Analyzed ".$ruleSetSize2." rules to resolve dependencies", true, IOInterface::VERBOSE); + + $solver = $solver2 = null; + $pool = $pool2 = null; + } catch (SolverProblemsException $e) { + $err = 'Your requirements could not be resolved to an installable set of packages.'; + $prettyProblem = $e->getPrettyString($repositorySet, $request, $pool, $this->io->isVerbose()); - $this->io->writeError(''. $err .'', true, IOInterface::QUIET); - $this->io->writeError($prettyProblem); - if (!$this->devMode) { - $this->io->writeError('Running update with --no-dev does not mean require-dev is ignored, it just means the packages will not be installed. If dev requirements are blocking the update you have to resolve those problems.', true, IOInterface::QUIET); + if (isset($pool2)) { + throw new \LogicException('Optimized solver failed but non-optimized one did not fail, please report this with your composer.json'); + } else { + try { + $pool2 = $repositorySet->createPool($request, $this->io, $this->eventDispatcher, $this->createPoolOptimizer($policy)); + $solver2 = new Solver($policy, $pool2, $this->io); + $lockTransaction2 = $solver2->solve($request, $this->platformRequirementFilter); + throw new \LogicException('Optimized solver worked but non-optimized one failed resolving, please report this with your composer.json'); + } catch (SolverProblemsException $e2) { + } + } + + $this->io->writeError(''. $err .'', true, IOInterface::QUIET); + $this->io->writeError($prettyProblem); + if (!$this->devMode) { + $this->io->writeError('Running update with --no-dev does not mean require-dev is ignored, it just means the packages will not be installed. If dev requirements are blocking the update you have to resolve those problems.', true, IOInterface::QUIET); + } + + $ghe = new GithubActionError($this->io); + $ghe->emit($err."\n".$prettyProblem); + + return max(self::ERROR_GENERIC_FAILURE, $e->getCode()); } - $ghe = new GithubActionError($this->io); - $ghe->emit($err."\n".$prettyProblem); + $txLogOptimized = array_map(function ($op) { + return (string) $op; + }, $lockTransaction2->getOperations()); + $txLogRaw = array_map(function ($op) { + return (string) $op; + }, $lockTransaction->getOperations()); + if ($txLogOptimized !== $txLogRaw) { + throw new \LogicException('Optimized solver resolved differently from non-optimized one, please report this with your composer.json'.PHP_EOL.implode(PHP_EOL,$txLogOptimized).implode(PHP_EOL,$txLogRaw)); + } + $this->io->writeError("Done, test successful", true, IOInterface::VERBOSE); + } else { + try { + $lockTransaction = $solver->solve($request, $this->platformRequirementFilter); + $ruleSetSize = $solver->getRuleSetSize(); + $solver = null; + } catch (SolverProblemsException $e) { + $err = 'Your requirements could not be resolved to an installable set of packages.'; + $prettyProblem = $e->getPrettyString($repositorySet, $request, $pool, $this->io->isVerbose()); - return max(self::ERROR_GENERIC_FAILURE, $e->getCode()); - } + $this->io->writeError(''. $err .'', true, IOInterface::QUIET); + $this->io->writeError($prettyProblem); + if (!$this->devMode) { + $this->io->writeError('Running update with --no-dev does not mean require-dev is ignored, it just means the packages will not be installed. If dev requirements are blocking the update you have to resolve those problems.', true, IOInterface::QUIET); + } - $this->io->writeError("Analyzed ".count($pool)." packages to resolve dependencies", true, IOInterface::VERBOSE); - $this->io->writeError("Analyzed ".$ruleSetSize." rules to resolve dependencies", true, IOInterface::VERBOSE); + $ghe = new GithubActionError($this->io); + $ghe->emit($err."\n".$prettyProblem); + + return max(self::ERROR_GENERIC_FAILURE, $e->getCode()); + } + + $this->io->writeError("Analyzed ".count($pool)." packages to resolve dependencies", true, IOInterface::VERBOSE); + $this->io->writeError("Analyzed ".$ruleSetSize." rules to resolve dependencies", true, IOInterface::VERBOSE); + $pool = null; + } if (!$lockTransaction->getOperations()) { $this->io->writeError('Nothing to modify in lock file'); @@ -999,6 +1063,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..02155ef52 100644 --- a/tests/Composer/Test/Fixtures/installer/deduplicate-solver-problems.test +++ b/tests/Composer/Test/Fixtures/installer/deduplicate-solver-problems.test @@ -11,15 +11,15 @@ Test the error output of solver problems is deduplicated. { "name": "package/a", "version": "2.0.2", "require": { "missing/dep": "^1.0" } }, { "name": "package/a", "version": "2.0.3", "require": { "missing/dep": "^1.0" } }, { "name": "package/a", "version": "2.1.0", "require": { "missing/dep": "^1.0" } }, - { "name": "package/a", "version": "2.2.0", "require": { "missing/dep": "^1.0" } }, - { "name": "package/a", "version": "2.3.1", "require": { "missing/dep": "^1.0" } }, - { "name": "package/a", "version": "2.3.2", "require": { "missing/dep": "^1.0" } }, - { "name": "package/a", "version": "2.3.3", "require": { "missing/dep": "^1.0" } }, - { "name": "package/a", "version": "2.3.4", "require": { "missing/dep": "^1.0" } }, - { "name": "package/a", "version": "2.3.5", "require": { "missing/dep": "^1.0" } }, - { "name": "package/a", "version": "2.4.0", "require": { "missing/dep": "^1.0" } }, - { "name": "package/a", "version": "2.5.0", "require": { "missing/dep": "^1.0" } }, - { "name": "package/a", "version": "2.6.0", "require": { "missing/dep": "^1.0" } }, + { "name": "package/a", "version": "2.2.0", "require": { "missing/dep": "^1.1" } }, + { "name": "package/a", "version": "2.3.1", "require": { "missing/dep": "^1.1" } }, + { "name": "package/a", "version": "2.3.2", "require": { "missing/dep": "^1.1" } }, + { "name": "package/a", "version": "2.3.3", "require": { "missing/dep": "^1.1" } }, + { "name": "package/a", "version": "2.3.4", "require": { "missing/dep": "^1.1" } }, + { "name": "package/a", "version": "2.3.5", "require": { "missing/dep": "^1.1" } }, + { "name": "package/a", "version": "2.4.0", "require": { "missing/dep": "^1.1" } }, + { "name": "package/a", "version": "2.5.0", "require": { "missing/dep": "^1.1" } }, + { "name": "package/a", "version": "2.6.0", "require": { "missing/dep": "^1.1" } }, { "name": "missing/dep", "version": "2.0.0" } ] } @@ -41,7 +41,8 @@ Updating dependencies Your requirements could not be resolved to an installable set of packages. Problem 1 - - 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. + - package/a[2.2.0, ..., 2.6.0] require missing/dep ^1.1 -> found missing/dep[2.0.0] but it does not match the constraint. + - package/a[2.0.0, ..., 2.1.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-- diff --git a/tests/Composer/Test/Fixtures/installer/github-issues-7051.test b/tests/Composer/Test/Fixtures/installer/github-issues-7051.test index 7683c7482..bfa7c9440 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.9, v3.1.10]. + - illuminate/console v5.2.26 requires symfony/console 2.8.* -> satisfiable by symfony/console[v2.8.7, v2.8.8]. + - 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]. + - friendsofphp/php-cs-fixer[v2.10.4, ..., v2.10.5] require symfony/console ^3.2 || ^4.0 -> satisfiable by symfony/console[v3.2.13, ..., v3.4.29]. + - Root composer.json requires friendsofphp/php-cs-fixer * -> satisfiable by friendsofphp/php-cs-fixer[v2.10.4, 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..29f0ebe86 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.0, 1.0.1, 1.0.2, 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.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-- 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 +]