diff --git a/.gitignore b/.gitignore index 3a9875b..6de133d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /vendor/ composer.lock +.phpunit.result.cache +/test diff --git a/README.md b/README.md index 882353b..182be1a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Optimize Composer for Drupal 8.5+ projects +Optimize Composer for Drupal projects ==== [![Build Status](https://travis-ci.org/zaporylie/composer-drupal-optimizations.svg?branch=master)](https://travis-ci.org/zaporylie/composer-drupal-optimizations) ![Packagist](https://img.shields.io/packagist/v/zaporylie/composer-drupal-optimizations.svg) @@ -18,9 +18,7 @@ No configuration required 🎊 # Optimizations -- Reduce memory usage and CPU usage by removing legacy symfony tags (see also https://github.com/symfony/flex/pull/378) - -(only one at the moment) +- Reduce memory usage and CPU usage by removing legacy symfony tags # Benchmark @@ -29,15 +27,41 @@ Following numbers are for clean https://github.com/drupal-composer/drupal-projec Before: ``` -Memory usage: 304.16MB (peak: 876.79MB), time: 17.13s +Memory usage: 323.19MB (peak: 1121.09MB), time: 13.68s ``` After: ``` -Memory usage: 218.72MB (peak: 250.44MB), time: 4.83s +Memory usage: 238.66MB (peak: 297.17MB), time: 4.84s +``` + +> php 7.2, macOS High Sierra, i7, 16GB RAM + +# Configuration + +If no configuration is provided this package will provide sensible defaults based on the content of project's composer.json +file. Default configuration should cover 99% of the cases. However, in case you want to manually specify the tags +that should be filtered out you are welcome to use the `extra` section: + +```json +{ + "extra": { + "composer-drupal-optimizations": { + "require": { + "symfony/symfony": ">3.4" + } + } + } +} ``` +***Recommendation note:*** +Use defaults (skip config above) if possible - this package will be maintained throughout the Drupal's lifecycle in order +to optimize legacy constraints in parallel with Drupal's requirements. + +All you have to do is to make sure your drupal core constraint is set to `drupal/core: ^8.5` or above. + # Credits - Symfony community - idea and development; Special thanks to @nicolas-grekas diff --git a/src/Cache.php b/src/Cache.php index c410aa9..815b432 100644 --- a/src/Cache.php +++ b/src/Cache.php @@ -3,6 +3,8 @@ namespace zaporylie\ComposerDrupalOptimizations; use Composer\Cache as BaseCache; +use Composer\Semver\Constraint\Constraint; +use Composer\Semver\VersionParser; /** * Class Cache @@ -10,17 +12,27 @@ */ class Cache extends BaseCache { - protected static $lowestTags = [ - 'symfony/symfony' => 'v3.4.0', - ]; + /** + * @var array + */ + protected $packages = []; + + /** + * @var \Composer\Semver\VersionParser + */ + protected $versionParser; + + /** + * {@inheritdoc} + */ public function read($file) { $content = $this->readFile($file); if (!\is_array($data = json_decode($content, true))) { return $content; } - foreach (array_keys(static::$lowestTags) as $key) { + foreach (array_keys($this->packages) as $key) { list($provider, ) = explode('/', $key, 2); if (0 === strpos($file, "provider-$provider\$")) { $data = $this->removeLegacyTags($data); @@ -35,21 +47,81 @@ protected function readFile($file) return parent::read($file); } + /** + * Removes legacy tags from $data. + * + * @param array $data + * @return array + */ public function removeLegacyTags(array $data) { - foreach (self::$lowestTags as $package => $lowestVersion) { - if (!isset($data['packages'][$package][$lowestVersion])) { + // Skip if the list of packages is empty. + if (!$this->packages || empty($data['packages'])) { + return $data; + } + + // Skip if none of the packages was found. + if (!array_diff_key($data['packages'], $this->packages)) { + return $data; + } + + foreach ($this->packages as $packageName => $packageVersionConstraint) { + if (!isset($data['packages'][$packageName])) { continue; } - foreach ($data['packages'] as $package => $versions) { - foreach ($versions as $version => $composerJson) { - if (version_compare($version, $lowestVersion, '<')) { - unset($data['packages'][$package][$version]); - } + $packages = []; + $specificPackage = $data['packages'][$packageName]; + foreach ($specificPackage as $version => $composerJson) { + if ('dev-master' === $version) { + $normalizedVersion = $this->versionParser->normalize($composerJson['extra']['branch-alias']['dev-master']); + } else { + $normalizedVersion = $composerJson['version_normalized']; + } + $packageConstraint = $this->versionParser->parseConstraints($packageVersionConstraint); + $versionConstraint = new Constraint('==', $normalizedVersion); + if ($packageConstraint->matches($versionConstraint)) { + $packages += isset($composerJson['replace']) ? $composerJson['replace'] : []; + } else { + unset($specificPackage[$version]); + } + } + + // Ignore requirements: their intersection with versions of the package gives empty result. + if (!$specificPackage) { + continue; + } + $data['packages'][$packageName] = $specificPackage; + + unset($specificPackage['dev-master']); + foreach ($data['packages'] as $name => $versions) { + if (!isset($packages[$name]) || null === $devMasterAlias = (isset($versions['dev-master']['extra']['branch-alias']['dev-master']) ? $versions['dev-master']['extra']['branch-alias']['dev-master'] : null)) { + continue; + } + $devMaster = $versions['dev-master']; + $versions = array_intersect_key($versions, $specificPackage); + $packageConstraint = $this->versionParser->parseConstraints($packageVersionConstraint); + $versionConstraint = new Constraint('==', $this->versionParser->normalize($devMasterAlias)); + if ($packageConstraint->matches($versionConstraint)) { + $versions['dev-master'] = $devMaster; + } + if ($versions) { + $data['packages'][$name] = $versions; } } - break; } return $data; + + } + + /** + * @param array $packages + * + * @return $this + */ + public function setRequiredVersionConstraints(array $packages) { + $this->versionParser = new VersionParser(); + $this->packages = $packages; + return $this; } + } diff --git a/src/Plugin.php b/src/Plugin.php index b7537be..7069aac 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -8,24 +8,64 @@ use Composer\Plugin\PluginInterface; use Composer\Repository\RepositoryFactory; use Composer\Repository\RepositoryManager; +use Composer\Semver\Constraint\Constraint; +use Composer\Semver\Constraint\ConstraintInterface; class Plugin implements PluginInterface { + public function activate(Composer $composer, IOInterface $io) { + // Set default version constraints based on the composer requirements. + $extra = $composer->getPackage()->getExtra(); + $packages = $composer->getPackage()->getRequires(); + if (!isset($extra['composer-drupal-optimizations']['require']) && isset($packages['drupal/core'])) { + $coreConstraint = $packages['drupal/core']->getConstraint(); + $extra['composer-drupal-optimizations']['require'] = static::getDefaultRequire($coreConstraint); + if (!empty($extra['composer-drupal-optimizations']['require']) && $io->isVerbose()) { + $io->write('Required tags were not explicitly set so the zaporylie/composer-drupal-optimizations set default based on project\'s composer.json content.'); + } + } + if (!empty($extra['composer-drupal-optimizations']['require']) && $io->isVerbose()) { + foreach ($extra['composer-drupal-optimizations']['require'] as $package => $version) { + $io->write(sprintf('extra.commerce-drupal-optimizations.require.%s: \'%s\'', $package, $version)); + } + } + $rfs = Factory::createRemoteFilesystem($io, $composer->getConfig()); $manager = RepositoryFactory::manager($io, $composer->getConfig(), $composer->getEventDispatcher(), $rfs); - $setRepositories = \Closure::bind(function (RepositoryManager $manager) { + $setRepositories = \Closure::bind(function (RepositoryManager $manager) use ($extra) { $manager->repositoryClasses = $this->repositoryClasses; $manager->setRepositoryClass('composer', TruncatedComposerRepository::class); $manager->repositories = $this->repositories; $i = 0; foreach (RepositoryFactory::defaultRepos(null, $this->config, $manager) as $repo) { $manager->repositories[$i++] = $repo; + if ($repo instanceof TruncatedComposerRepository && !empty($extra['composer-drupal-optimizations']['require'])) { + $repo->setRequiredVersionConstraints($extra['composer-drupal-optimizations']['require']); + } } $manager->setLocalRepository($this->getLocalRepository()); }, $composer->getRepositoryManager(), RepositoryManager::class); $setRepositories($manager); $composer->setRepositoryManager($manager); } + + /** + * Negotiates default require constraint and package for given drupal/core. + * + * @param \Composer\Semver\Constraint\ConstraintInterface + * + * @return array + */ + static public function getDefaultRequire(ConstraintInterface $coreConstraint) + { + if ((new Constraint('>=', '8.5.0'))->matches($coreConstraint) + && !(new Constraint('<', '8.5.0'))->matches($coreConstraint)) { + return [ + 'symfony/symfony' => '>3.4', + ]; + } + return []; + } } diff --git a/src/TruncatedComposerRepository.php b/src/TruncatedComposerRepository.php index 0b99bab..23f8ecf 100644 --- a/src/TruncatedComposerRepository.php +++ b/src/TruncatedComposerRepository.php @@ -18,6 +18,9 @@ public function __construct(array $repoConfig, IOInterface $io, Config $config, protected function fetchFile($filename, $cacheKey = null, $sha256 = null, $storeLastModifiedTime = false) { $data = parent::fetchFile($filename, $cacheKey, $sha256, $storeLastModifiedTime); - return $this->cache->removeLegacyTags($data); + return \is_array($data) ? $this->cache->removeLegacyTags($data) : $data; + } + public function setRequiredVersionConstraints(array $packages) { + $this->cache->setRequiredVersionConstraints($packages); } } diff --git a/tests/CacheTest.php b/tests/CacheTest.php index 94e8063..2eb471f 100644 --- a/tests/CacheTest.php +++ b/tests/CacheTest.php @@ -17,11 +17,12 @@ class CacheTest extends TestCase * @param $expected * * @dataProvider provideReadTest + * @covers \zaporylie\ComposerDrupalOptimizations\Cache::read */ public function testRead($provided, $expected) { $cache = new class(new NullIO(), 'test') extends Cache { - protected static $lowestTags = [ + protected $packages = [ 'vendor/package' => 'version', ]; protected function readFile($file) @@ -52,4 +53,109 @@ function provideReadTest() yield 'matching-incorrect-provider' => ['{"provider":"vendor"}', '{"provider":"vendor"}']; yield 'matching' => ['provider-vendor${"provider":"vendor"}', '{"provider":"vendor","status":"ok"}']; } + + /** + * @dataProvider provideRemoveLegacyTags + * @covers \zaporylie\ComposerDrupalOptimizations\Cache::removeLegacyTags + */ + public function testRemoveLegacyTags(array $expected, array $packages, array $versionConstraints) + { + /** @var Cache $cache */ + $cache = (new \ReflectionClass(Cache::class))->newInstanceWithoutConstructor(); + $cache->setRequiredVersionConstraints($versionConstraints); + $this->assertSame(['packages' => $expected], $cache->removeLegacyTags(['packages' => $packages])); + } + + /** + * Test data. + */ + public function provideRemoveLegacyTags() + { + yield 'no-symfony/symfony' => [[123], [123], ['symfony/symfony' => '~1']]; + $branchAlias = function ($versionAlias) { + return [ + 'extra' => [ + 'branch-alias' => [ + 'dev-master' => $versionAlias.'-dev', + ], + ], + ]; + }; + $packages = [ + 'foo/unrelated' => [ + '1.0.0' => [], + ], + 'symfony/symfony' => [ + '3.3.0' => [ + 'version_normalized' => '3.3.0.0', + 'replace' => ['symfony/foo' => 'self.version'], + ], + '3.4.0' => [ + 'version_normalized' => '3.4.0.0', + 'replace' => ['symfony/foo' => 'self.version'], + ], + 'dev-master' => $branchAlias('3.5') + [ + 'replace' => ['symfony/foo' => 'self.version'], + ], + ], + 'symfony/foo' => [ + '3.3.0' => ['version_normalized' => '3.3.0.0'], + '3.4.0' => ['version_normalized' => '3.4.0.0'], + 'dev-master' => $branchAlias('3.5'), + ], + ]; + yield 'empty-intersection-ignores' => [$packages, $packages, ['symfony/symfony' => '~2.0']]; + yield 'empty-intersection-ignores' => [$packages, $packages, ['symfony/symfony' => '~4.0']]; + $expected = $packages; + unset($expected['symfony/symfony']['3.3.0']); + unset($expected['symfony/foo']['3.3.0']); + yield 'non-empty-intersection-filters' => [$expected, $packages, ['symfony/symfony' => '~3.4']]; + unset($expected['symfony/symfony']['3.4.0']); + unset($expected['symfony/foo']['3.4.0']); + yield 'master-only' => [$expected, $packages, ['symfony/symfony' => '~3.5']]; + $packages = [ + 'symfony/symfony' => [ + '2.8.0' => [ + 'version_normalized' => '2.8.0.0', + 'replace' => [ + 'symfony/legacy' => 'self.version', + 'symfony/foo' => 'self.version', + ], + ], + ], + 'symfony/legacy' => [ + '2.8.0' => ['version_normalized' => '2.8.0.0'], + 'dev-master' => $branchAlias('2.8'), + ], + ]; + yield 'legacy-are-not-filtered' => [$packages, $packages, ['symfony/symfony' => '~3.0']]; + $packages = [ + 'symfony/symfony' => [ + '2.8.0' => [ + 'version_normalized' => '2.8.0.0', + 'replace' => [ + 'symfony/foo' => 'self.version', + 'symfony/new' => 'self.version', + ], + ], + 'dev-master' => $branchAlias('3.0') + [ + 'replace' => [ + 'symfony/foo' => 'self.version', + 'symfony/new' => 'self.version', + ], + ], + ], + 'symfony/foo' => [ + '2.8.0' => ['version_normalized' => '2.8.0.0'], + 'dev-master' => $branchAlias('3.0'), + ], + 'symfony/new' => [ + 'dev-master' => $branchAlias('3.0'), + ], + ]; + $expected = $packages; + unset($expected['symfony/symfony']['dev-master']); + unset($expected['symfony/foo']['dev-master']); + yield 'master-is-filtered-only-when-in-range' => [$expected, $packages, ['symfony/symfony' => '~2.8']]; + } } diff --git a/tests/DefaultRequireTest.php b/tests/DefaultRequireTest.php new file mode 100644 index 0000000..e3368c4 --- /dev/null +++ b/tests/DefaultRequireTest.php @@ -0,0 +1,40 @@ +parseConstraints($provided))); + } + + /** + * Test data. + */ + function provideTestData() + { + yield 'exact-below' => ['8.2.0', []]; + yield 'exact-above' => ['8.6.0', ['symfony/symfony' => '>3.4']]; + yield 'exact-min' => ['8.5.0', ['symfony/symfony' => '>3.4']]; + yield 'range-below' => ['~8.4.0', []]; + yield 'range-overlapping' => ['>8.4.0 <8.6.0', []]; + yield 'range-below-above' => ['~8.2.0|~8.6.0', []]; + yield 'range-above' => ['~8.6.0', ['symfony/symfony' => '>3.4']]; + yield 'range-min' => ['^8.5', ['symfony/symfony' => '>3.4']]; + } + +}