From 8e5499fdbd9c209adb80d000a4a5e54ffa302c0d Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Thu, 12 May 2022 13:34:46 +0200 Subject: [PATCH] Allow using temporary update constraints on all packages (incl non-root), fixes #10436 --- src/Composer/Command/UpdateCommand.php | 44 ++------- .../DependencyResolver/PoolBuilder.php | 30 ++++-- src/Composer/DependencyResolver/Problem.php | 10 ++ src/Composer/Installer.php | 14 ++- src/Composer/Repository/RepositorySet.php | 20 +++- .../Test/Command/UpdateCommandTest.php | 95 +++++++++++++++++++ tests/Composer/Test/TestCase.php | 11 ++- 7 files changed, 174 insertions(+), 50 deletions(-) create mode 100644 tests/Composer/Test/Command/UpdateCommandTest.php diff --git a/src/Composer/Command/UpdateCommand.php b/src/Composer/Command/UpdateCommand.php index 8bd3a9bfe26f..2bd15c9f550c 100644 --- a/src/Composer/Command/UpdateCommand.php +++ b/src/Composer/Command/UpdateCommand.php @@ -21,6 +21,7 @@ use Composer\Plugin\CommandEvent; use Composer\Plugin\PluginEvents; use Composer\Package\Version\VersionParser; +use Composer\Semver\Constraint\ConstraintInterface; use Composer\Util\HttpDownloader; use Composer\Semver\Constraint\MultiConstraint; use Composer\Package\Link; @@ -144,20 +145,13 @@ protected function execute(InputInterface $input, OutputInterface $output) } } - $rootPackage = $composer->getPackage(); - $rootRequires = $rootPackage->getRequires(); - $rootDevRequires = $rootPackage->getDevRequires(); + $parser = new VersionParser; + $temporaryConstraints = []; foreach ($reqs as $package => $constraint) { - if (isset($rootRequires[$package])) { - $rootRequires[$package] = $this->appendConstraintToLink($rootRequires[$package], $constraint); - } elseif (isset($rootDevRequires[$package])) { - $rootDevRequires[$package] = $this->appendConstraintToLink($rootDevRequires[$package], $constraint); - } else { - throw new \UnexpectedValueException('Only root package requirements can receive temporary constraints and '.$package.' is not one'); - } + $temporaryConstraints[strtolower($package)] = $parser->parseConstraints($constraint); } - $rootPackage->setRequires($rootRequires); - $rootPackage->setDevRequires($rootDevRequires); + + $rootPackage = $composer->getPackage(); $rootPackage->setReferences(RootPackageLoader::extractReferences($reqs, $rootPackage->getReferences())); $rootPackage->setStabilityFlags(RootPackageLoader::extractStabilityFlags($reqs, $rootPackage->getMinimumStability(), $rootPackage->getStabilityFlags())); @@ -166,9 +160,9 @@ protected function execute(InputInterface $input, OutputInterface $output) } if ($input->getOption('root-reqs')) { - $requires = array_keys($rootRequires); + $requires = array_keys($rootPackage->getRequires()); if (!$input->getOption('no-dev')) { - $requires = array_merge($requires, array_keys($rootDevRequires)); + $requires = array_merge($requires, array_keys($rootPackage->getDevRequires())); } if (!empty($packages)) { @@ -232,6 +226,7 @@ protected function execute(InputInterface $input, OutputInterface $output) ->setPlatformRequirementFilter($this->getPlatformRequirementFilter($input)) ->setPreferStable($input->getOption('prefer-stable')) ->setPreferLowest($input->getOption('prefer-lowest')) + ->setTemporaryConstraints($temporaryConstraints) ; if ($input->getOption('no-plugins')) { @@ -307,25 +302,4 @@ private function getPackagesInteractively(IOInterface $io, InputInterface $input throw new \RuntimeException('Installation aborted.'); } - - /** - * @param string $constraint - * @return Link - */ - private function appendConstraintToLink(Link $link, string $constraint): Link - { - $parser = new VersionParser; - $oldPrettyString = $link->getConstraint()->getPrettyString(); - $newConstraint = MultiConstraint::create(array($link->getConstraint(), $parser->parseConstraints($constraint))); - $newConstraint->setPrettyString($oldPrettyString.', '.$constraint); - - return new Link( - $link->getSource(), - $link->getTarget(), - $newConstraint, - /** @phpstan-ignore-next-line */ - $link->getDescription(), - $link->getPrettyConstraint() . ', ' . $constraint - ); - } } diff --git a/src/Composer/DependencyResolver/PoolBuilder.php b/src/Composer/DependencyResolver/PoolBuilder.php index 676ebfefc1d9..54223fba4a8f 100644 --- a/src/Composer/DependencyResolver/PoolBuilder.php +++ b/src/Composer/DependencyResolver/PoolBuilder.php @@ -58,6 +58,10 @@ class PoolBuilder * @phpstan-var array */ private $rootReferences; + /** + * @var array + */ + private $temporaryConstraints; /** * @var ?EventDispatcher */ @@ -142,8 +146,9 @@ class PoolBuilder * @phpstan-param array> $rootAliases * @param string[] $rootReferences an array of package name => source reference * @phpstan-param array $rootReferences + * @param array $temporaryConstraints Runtime temporary constraints that will be used to filter packages */ - public function __construct(array $acceptableStabilities, array $stabilityFlags, array $rootAliases, array $rootReferences, IOInterface $io, EventDispatcher $eventDispatcher = null, PoolOptimizer $poolOptimizer = null) + public function __construct(array $acceptableStabilities, array $stabilityFlags, array $rootAliases, array $rootReferences, IOInterface $io, EventDispatcher $eventDispatcher = null, PoolOptimizer $poolOptimizer = null, array $temporaryConstraints = []) { $this->acceptableStabilities = $acceptableStabilities; $this->stabilityFlags = $stabilityFlags; @@ -152,6 +157,7 @@ public function __construct(array $acceptableStabilities, array $stabilityFlags, $this->eventDispatcher = $eventDispatcher; $this->poolOptimizer = $poolOptimizer; $this->io = $io; + $this->temporaryConstraints = $temporaryConstraints; } /** @@ -234,24 +240,28 @@ public function buildPool(array $repositories, Request $request): Pool $this->loadPackagesMarkedForLoading($request, $repositories); } - foreach ($this->packages as $i => $package) { - // we check all alias related packages at once, so no need to check individual aliases - // isset also checks non-null value - if (!$package instanceof AliasPackage) { - $constraint = new Constraint('==', $package->getVersion()); - $aliasedPackages = array($i => $package); + if (\count($this->temporaryConstraints) > 0) { + foreach ($this->packages as $i => $package) { + // we check all alias related packages at once, so no need to check individual aliases + if (!isset($this->temporaryConstraints[$package->getName()]) || $package instanceof AliasPackage) { + continue; + } + + $constraint = $this->temporaryConstraints[$package->getName()]; + $packageAndAliases = array($i => $package); if (isset($this->aliasMap[spl_object_hash($package)])) { - $aliasedPackages += $this->aliasMap[spl_object_hash($package)]; + $packageAndAliases += $this->aliasMap[spl_object_hash($package)]; } $found = false; - foreach ($aliasedPackages as $packageOrAlias) { + foreach ($packageAndAliases as $packageOrAlias) { if (CompilingMatcher::match($constraint, Constraint::OP_EQ, $packageOrAlias->getVersion())) { $found = true; } } + if (!$found) { - foreach ($aliasedPackages as $index => $packageOrAlias) { + foreach ($packageAndAliases as $index => $packageOrAlias) { unset($this->packages[$index]); } } diff --git a/src/Composer/DependencyResolver/Problem.php b/src/Composer/DependencyResolver/Problem.php index b3bddb1b088c..07cecb836997 100644 --- a/src/Composer/DependencyResolver/Problem.php +++ b/src/Composer/DependencyResolver/Problem.php @@ -294,6 +294,16 @@ public static function getMissingPackageReason(RepositorySet $repositorySet, Req } } + $tempReqs = $repositorySet->getTemporaryConstraints(); + if (isset($tempReqs[$packageName])) { + $filtered = array_filter($packages, function ($p) use ($tempReqs, $packageName): bool { + return $tempReqs[$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, $pool, $constraint).' but '.(self::hasMultipleNames($packages) ? 'these conflict' : 'it conflicts').' with your temporary update constraint ('.$packageName.':'.$tempReqs[$packageName]->getPrettyString().').'); + } + } + if ($lockedPackage) { $fixedConstraint = new Constraint('==', $lockedPackage->getVersion()); $filtered = array_filter($packages, function ($p) use ($fixedConstraint): bool { diff --git a/src/Composer/Installer.php b/src/Composer/Installer.php index dbfb5f03171f..c4cf9785eed6 100644 --- a/src/Composer/Installer.php +++ b/src/Composer/Installer.php @@ -60,6 +60,7 @@ use Composer\Repository\RepositoryManager; use Composer\Repository\LockArrayRepository; use Composer\Script\ScriptEvents; +use Composer\Semver\Constraint\ConstraintInterface; use Composer\Util\Platform; /** @@ -189,6 +190,9 @@ class Installer */ protected $additionalFixedRepository; + /** @var array */ + protected $temporaryConstraints = []; + /** * Constructor * @@ -837,7 +841,7 @@ private function createRepositorySet(bool $forUpdate, PlatformRepository $platfo $stabilityFlags[$this->package->getName()] = BasePackage::$stabilities[VersionParser::parseStability($this->package->getVersion())]; - $repositorySet = new RepositorySet($minimumStability, $stabilityFlags, $rootAliases, $this->package->getReferences(), $rootRequires); + $repositorySet = new RepositorySet($minimumStability, $stabilityFlags, $rootAliases, $this->package->getReferences(), $rootRequires, $this->temporaryConstraints); $repositorySet->addRepository(new RootPackageRepository($this->fixedRootPackage)); $repositorySet->addRepository($platformRepo); if ($this->additionalFixedRepository) { @@ -1065,6 +1069,14 @@ public function setAdditionalFixedRepository(RepositoryInterface $additionalFixe return $this; } + /** + * @param array $constraints + */ + public function setTemporaryConstraints(array $constraints): void + { + $this->temporaryConstraints = $constraints; + } + /** * Whether to run in drymode or not * diff --git a/src/Composer/Repository/RepositorySet.php b/src/Composer/Repository/RepositorySet.php index e3ad49dfe0de..e656d18c5415 100644 --- a/src/Composer/Repository/RepositorySet.php +++ b/src/Composer/Repository/RepositorySet.php @@ -73,6 +73,11 @@ class RepositorySet */ private $rootRequires; + /** + * @var array + */ + private $temporaryConstraints; + /** @var bool */ private $locked = false; /** @var bool */ @@ -92,8 +97,9 @@ class RepositorySet * @phpstan-param array $rootReferences * @param ConstraintInterface[] $rootRequires an array of package name => constraint from the root package * @phpstan-param array $rootRequires + * @param array $temporaryConstraints Runtime temporary constraints that will be used to filter packages */ - public function __construct(string $minimumStability = 'stable', array $stabilityFlags = array(), array $rootAliases = array(), array $rootReferences = array(), array $rootRequires = array()) + public function __construct(string $minimumStability = 'stable', array $stabilityFlags = array(), array $rootAliases = array(), array $rootReferences = array(), array $rootRequires = array(), array $temporaryConstraints = []) { $this->rootAliases = self::getRootAliasesPerPackage($rootAliases); $this->rootReferences = $rootReferences; @@ -111,6 +117,8 @@ public function __construct(string $minimumStability = 'stable', array $stabilit unset($this->rootRequires[$name]); } } + + $this->temporaryConstraints = $temporaryConstraints; } /** @@ -132,6 +140,14 @@ public function getRootRequires(): array return $this->rootRequires; } + /** + * @return array Runtime temporary constraints that will be used to filter packages + */ + public function getTemporaryConstraints(): array + { + return $this->temporaryConstraints; + } + /** * Adds a repository to this repository set * @@ -247,7 +263,7 @@ public function isPackageAcceptable(array $names, string $stability): bool */ public function createPool(Request $request, IOInterface $io, EventDispatcher $eventDispatcher = null, PoolOptimizer $poolOptimizer = null): Pool { - $poolBuilder = new PoolBuilder($this->acceptableStabilities, $this->stabilityFlags, $this->rootAliases, $this->rootReferences, $io, $eventDispatcher, $poolOptimizer); + $poolBuilder = new PoolBuilder($this->acceptableStabilities, $this->stabilityFlags, $this->rootAliases, $this->rootReferences, $io, $eventDispatcher, $poolOptimizer, $this->temporaryConstraints); foreach ($this->repositories as $repo) { if (($repo instanceof InstalledRepositoryInterface || $repo instanceof InstalledRepository) && !$this->allowInstalledRepositories) { diff --git a/tests/Composer/Test/Command/UpdateCommandTest.php b/tests/Composer/Test/Command/UpdateCommandTest.php new file mode 100644 index 000000000000..fdc876b9c83f --- /dev/null +++ b/tests/Composer/Test/Command/UpdateCommandTest.php @@ -0,0 +1,95 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Command; + +use Composer\Test\TestCase; + +class UpdateCommandTest extends TestCase +{ + /** + * @dataProvider provideUpdates + * @param array $composerJson + * @param array $command + */ + public function testUpdate(array $composerJson, array $command, string $expected): void + { + $this->initTempComposer($composerJson); + + $appTester = $this->getApplicationTester(); + $appTester->run(array_merge(['command' => 'update', '--dry-run' => true], $command)); + + $this->assertSame(trim($expected), trim($appTester->getDisplay())); + } + + public function provideUpdates(): \Generator + { + $rootDepAndTransitiveDep = [ + 'repositories' => [ + 'packages' => [ + 'type' => 'package', + 'package' => [ + ['name' => 'root/req', 'version' => '1.0.0', 'require' => ['dep/pkg' => '^1']], + ['name' => 'dep/pkg', 'version' => '1.0.0'], + ['name' => 'dep/pkg', 'version' => '1.0.1'], + ['name' => 'dep/pkg', 'version' => '1.0.2'], + ], + ], + ], + 'require' => [ + 'root/req' => '1.*', + ], + ]; + + yield 'simple update' => [ + $rootDepAndTransitiveDep, + [], + << [ + $rootDepAndTransitiveDep, + ['--with' => ['dep/pkg:1.0.0'], '--no-install' => true], + << [ + $rootDepAndTransitiveDep, + ['--with' => ['dep/pkg:^2']], + << satisfiable by root/req[1.0.0]. + - root/req 1.0.0 requires dep/pkg ^1 -> found dep/pkg[1.0.0, 1.0.1, 1.0.2] but it conflicts with your temporary update constraint (dep/pkg:^2). +OUTPUT + ]; + } +} diff --git a/tests/Composer/Test/TestCase.php b/tests/Composer/Test/TestCase.php index 9ed75c4b1e59..fe7e279bffe6 100644 --- a/tests/Composer/Test/TestCase.php +++ b/tests/Composer/Test/TestCase.php @@ -124,9 +124,16 @@ public function initTempComposer(array $composerJson = [], array $authJson = []) Platform::putEnv('COMPOSER_HOME', $dir.'/composer-home'); Platform::putEnv('COMPOSER_DISABLE_XDEBUG_WARN', '1'); + if ($composerJson === []) { + $composerJson = new \stdClass; + } + if ($authJson === []) { + $authJson = new \stdClass; + } + chdir($dir); - file_put_contents($dir.'/composer.json', JsonFile::encode($composerJson, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_FORCE_OBJECT)); - file_put_contents($dir.'/auth.json', JsonFile::encode($authJson, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_FORCE_OBJECT)); + file_put_contents($dir.'/composer.json', JsonFile::encode($composerJson, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + file_put_contents($dir.'/auth.json', JsonFile::encode($authJson, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); return $dir; }