diff --git a/core/includes/install.inc b/core/includes/install.inc index 7adb15a..eb85afb 100644 --- a/core/includes/install.inc +++ b/core/includes/install.inc @@ -5,12 +5,13 @@ * API functions for installing modules and themes. */ -use Composer\Semver\Semver; use Drupal\Component\Utility\Unicode; use Symfony\Component\HttpFoundation\RedirectResponse; use Drupal\Component\Utility\Crypt; use Drupal\Component\Utility\OpCodeCache; use Drupal\Component\Utility\UrlHelper; +use Drupal\Core\Composer\ExtensionComposerDependencies; +use Drupal\Core\Extension\Extension; use Drupal\Core\Extension\ExtensionDiscovery; use Drupal\Core\Site\Settings; @@ -986,8 +987,15 @@ function drupal_requirements_severity(&$requirements) { function drupal_check_module($module) { module_load_install($module); // Check requirements - $requirements = \Drupal::moduleHandler()->invoke($module, 'requirements', array('install')) ?: []; - $requirements += drupal_check_composer_requirements($module); + $requirements = \Drupal::moduleHandler()->invoke($module, 'requirements', array('install')); + + // Check Composer dependencies. + $dependencies = new ExtensionComposerDependencies(); + // We can't use the module handler here because it only deals with enabled + // modules. + $extension = new Extension(DRUPAL_ROOT, 'module', drupal_get_filename('module', $module)); + $requirements[] = $dependencies->extensionComposerRequirements(DRUPAL_ROOT, $extension); + if (is_array($requirements) && drupal_requirements_severity($requirements) == REQUIREMENT_ERROR) { // Print any error messages foreach ($requirements as $requirement) { @@ -1005,88 +1013,6 @@ function drupal_check_module($module) { } /** - * Check composer requirements. - * - * @param string $module - * Module name. - * @return array - */ -function drupal_check_composer_requirements($module) { - $module_dir = drupal_get_path('module', $module); - - // Check if the modules that are going to be installed have a composer.json - // file, if they have one, make sure that the configured dependencies are - // installed. If failures are detected, make sure the dependent modules - // aren't installed either. - if (file_exists($module_dir . '/composer.json')) { - - $semver = new Semver(); - - $module_composer_json = json_decode(file_get_contents($module_dir . '/composer.json')); - $module_package_requirements = $module_composer_json->require; - - // Load the actual autoloader being used and determine its filename using - // reflection. We can determine the vendor directory based on that filename. - $autoloader = require \Drupal::root() . '/autoload.php'; - $reflector = new ReflectionClass($autoloader); - $vendor_dir = dirname(dirname($reflector->getFileName())); - - // The json in composer/installed.json includes all the installed packages - // and is more reliable than checking composer.lock, vendors actually have - // to be installed for this file to even be available. - if (file_exists($vendor_dir . '/composer/installed.json')) { - $installed_packages = json_decode(file_get_contents($vendor_dir.'/composer/installed.json')); - } - else { - $installed_packages = []; - $requirements['composer_dependencies'] = [ - 'description' => t('Not all the modules have their Composer dependencies installed yet. Please find more information on this handbook page.', - [ - '@handbook' => 'https://www.drupal.org/node/2494073' - ]), - 'severity' => REQUIREMENT_ERROR, - 'title' => $module, - ]; - } - - $check_installed_packages = []; - $required_packages = []; - - foreach ($module_package_requirements as $name => $version) { - foreach ($installed_packages as $package) { - if ($package->name === $name && $semver->satisfies($package->version_normalized, $version)) { - $check_installed_packages[] = $name; - } - } - $required_packages[] = $name; - } - - if ($required_packages !== $check_installed_packages) { - $requirements['composer_dependencies'] = [ - 'description' => t('Not all the modules have their Composer dependencies installed yet. Please find more information on this handbook page.', - [ - '@handbook' => 'https://www.drupal.org/node/2494073' - ]), - 'severity' => REQUIREMENT_ERROR, - 'title' => $module, - ]; - } - else { - $requirements['composer_dependencies'] = [ - 'description' => t('All Composer dependencies have been installed.'), - 'severity' => REQUIREMENT_OK, - 'value' => t('Never run'), - 'title' => $module, - ]; - } - - return $requirements; - } - - return []; -} - -/** * Retrieves information about an installation profile from its .info.yml file. * * The information stored in a profile .info.yml file is similar to that stored diff --git a/core/lib/Drupal/Core/Composer/ExtensionComposerDependencies.php b/core/lib/Drupal/Core/Composer/ExtensionComposerDependencies.php new file mode 100644 index 0000000..b11f3ae --- /dev/null +++ b/core/lib/Drupal/Core/Composer/ExtensionComposerDependencies.php @@ -0,0 +1,156 @@ + '1.7.0'. + */ + protected $composerInstalled = []; + + /** + * Determine whether Composer-based dependencies are still required. + * + * @param string $drupal_root + * Path to the root of the Drupal installation. + * @param \Drupal\Core\Extension\Extension $extension + * Extension to check. + * + * @return array + * Requirements array, suitable for hook_requirements(). + */ + public function extensionComposerRequirements($drupal_root, Extension $extension) { + $requirements = []; + if (!$this->dependenciesAreMet($drupal_root, $extension)) { + $requirements['composer_dependencies'] = [ + 'description' => t('Not all the modules have their Composer dependencies installed yet. Please find more information on this handbook page.', [ + '@handbook' => 'https://www.drupal.org/node/2494073' + ]), + 'severity' => REQUIREMENT_ERROR, + 'title' => $extension->getName(), + ]; + } + else { + $requirements['composer_dependencies'] = [ + 'description' => t('All Composer dependencies have been installed.'), + 'severity' => REQUIREMENT_OK, + 'value' => t('Never run'), + 'title' => $extension->getName(), + ]; + } + return $requirements; + } + + /** + * Determine whether the Composer-based dependencies of an extension are met. + * + * @param string $drupal_root + * Path to the root of the Drupal installation. + * @param \Drupal\Core\Extension\Extension $extension + * Extension to check. + * + * @return bool + * TRUE if this extenion's dependencies are met, FALSE otherwise. + */ + public function dependenciesAreMet($drupal_root, Extension $extension) { + // Does the extension have a composer.json file? + $extension_composer_json = $drupal_root . '/' . $extension->getPath() . '/composer.json'; + if (is_readable($extension_composer_json)) { + // Populate $composerInstalled. + if (empty($this->composerInstalled)) { + $installed_json = $drupal_root . '/vendor/composer/installed.json'; + if (file_exists($installed_json)) { + $json = file_get_contents($installed_json, FALSE); + $this->composerInstalled = $this->parseInstalledPackages($json); + } + } + // @todo: Determine how to tell if the root package is a dev installation + // or not. + return $this->extensionDependenciesAreMet( + json_decode(file_get_contents($extension_composer_json)), $this->composerInstalled, TRUE + ); + } + return TRUE; + } + + /** + * Performs the dependency check. + * + * @param object $composer_dependencies + * Extension dependencies, parsed from the composer.json file. + * @param array $installed_packages + * List of installed packages, with package name as key and normalized + * version as value. + * @param bool $include_dev + * Whether or not to include requires-dev. + * + * @return bool + * TRUE if the dependencies are met, FALSE otherwise. + */ + protected function extensionDependenciesAreMet(\stdClass $composer_dependencies, array $installed_packages, $include_dev = FALSE) { + // Gather all the requirements. + $require = []; + if (!empty($composer_dependencies->require)) { + $require = (array) $composer_dependencies->require; + } + if ($include_dev) { + // Dev requirements shouldn't be the same as non-dev ones, so we naively + // merge them together. + if (!empty($composer_dependencies->{'require-dev'})) { + $require = array_merge($require, (array) $composer_dependencies->{'require-dev'}); + } + } + if (!empty($require)) { + // Check each required package against the list of installed packages. + $semver = new Semver(); + foreach ($require as $package_name => $constraint) { + if (empty($installed_packages[$package_name])) { + return FALSE; + } + if (!$semver->satisfies($installed_packages[$package_name], $constraint)) { + return FALSE; + } + } + } + return TRUE; + } + + /** + * Parse an installed.json file into the useful parts. + * + * @param string $installed_json + * JSON contents of installed.json file. + * + * @return array + * Array with package names as the keys and normalized version as the + * values. + */ + protected function parseInstalledPackages($installed_json) { + $result = []; + foreach (json_decode($installed_json) as $package) { + if (isset($package->name) && isset($package->version_normalized)) { + $result[$package->name] = $package->version_normalized; + } + } + return $result; + } + +} diff --git a/core/lib/Drupal/Core/Extension/ExtensionComposerRequirementsException.php b/core/lib/Drupal/Core/Extension/ExtensionComposerRequirementsException.php index 7256bbf..66eb4f0 100644 --- a/core/lib/Drupal/Core/Extension/ExtensionComposerRequirementsException.php +++ b/core/lib/Drupal/Core/Extension/ExtensionComposerRequirementsException.php @@ -8,6 +8,6 @@ namespace Drupal\Core\Extension; /** - * Exception thrown when the extension's composer requirements are not installed. + * Exception thrown when an extension's Composer requirements are not installed. */ -class ExtensionComposerRequirementsException extends \Exception { } +class ExtensionComposerRequirementsException extends \Exception {} diff --git a/core/lib/Drupal/Core/Extension/ModuleInstaller.php b/core/lib/Drupal/Core/Extension/ModuleInstaller.php index 37fdde6..e4c108d 100644 --- a/core/lib/Drupal/Core/Extension/ModuleInstaller.php +++ b/core/lib/Drupal/Core/Extension/ModuleInstaller.php @@ -10,9 +10,12 @@ use Drupal\Component\Serialization\Yaml; use Drupal\Core\Cache\Cache; use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Composer\ExtensionComposerDependencies; use Drupal\Core\DrupalKernelInterface; use Drupal\Core\Entity\EntityStorageException; use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\Core\Extension\Extension; +use Drupal\Core\Extension\ExtensionComposerRequirementsException; /** * Default implementation of the module installer. @@ -133,6 +136,9 @@ public function install(array $module_list, $enable_dependencies = TRUE) { $source_storage = $config_installer->getSourceStorage(); } $modules_installed = array(); + // If we reuse the Composer dependency object we'll benefit from it's cache + // of installed packages. + $composer_dependencies = new ExtensionComposerDependencies(); foreach ($module_list as $module) { $enabled = $extension_config->get("module.$module") !== NULL; if (!$enabled) { @@ -141,8 +147,9 @@ public function install(array $module_list, $enable_dependencies = TRUE) { throw new ExtensionNameLengthException("Module name '$module' is over the maximum allowed length of " . DRUPAL_EXTENSION_NAME_MAX_LENGTH . ' characters'); } - $composer_requirements = drupal_check_composer_requirements($module); - if (isset($composer_requirements['composer_dependencies']) && $composer_requirements['composer_dependencies']['severity'] == REQUIREMENT_ERROR) { + // Throw an exception if there are unmet Composer-based dependencies. + $extension = new Extension(DRUPAL_ROOT, 'module', drupal_get_filename('module', $module)); + if (!$composer_dependencies->dependenciesAreMet(DRUPAL_ROOT, $extension)) { throw new ExtensionComposerRequirementsException("Module '$module' composer requirements are not installed yet."); } diff --git a/core/tests/Drupal/Tests/Core/Composer/ExtensionComposerDependenciesTest.php b/core/tests/Drupal/Tests/Core/Composer/ExtensionComposerDependenciesTest.php new file mode 100644 index 0000000..f5e4ca2 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Composer/ExtensionComposerDependenciesTest.php @@ -0,0 +1,124 @@ + '1.0.0'], '[{"name": "vendor/package","version_normalized": "1.0.0"}]'], + ]; + } + + /** + * @covers ::parseInstalledPackages + * + * @dataProvider provideParseInstalled + */ + public function testParseInstalledPackages($expected, $installed) { + $dependencies = new ExtensionComposerDependencies(); + + $ref_method = new \ReflectionMethod($dependencies, 'parseInstalledPackages'); + $ref_method->setAccessible(TRUE); + + $this->assertArrayEquals($expected, $ref_method->invoke($dependencies, $installed)); + } + + /** + * @return array + * - Boolean showing whether the dependencies are met by the installed + * packages. + * - Object of dependencies parsed from JSON. + * - Array of installed dependencies, with package name as key and version + * as value. + */ + public function provideExtensionDependencies() { + return [ + [TRUE, json_decode('{}'), []], + [TRUE, json_decode('{"require": {"vendor/package":"1.0.0"}}'), ['vendor/package' => '1.0.0']], + [FALSE, json_decode('{"require": {"vendor/package":"~1.0"}}'), ['vendor/package' => '0.0.9']], + [FALSE, json_decode('{"require-dev": {"vendor/package":"~1.0"}}'), ['vendor/package' => '0.0.9']], + ]; + } + + /** + * @covers ::extensionDependenciesAreMet + * + * @dataProvider provideExtensionDependencies + */ + public function testExtensionDependenciesAreMet($expected, $extension_dependencies, $installed) { + $dependencies = new ExtensionComposerDependencies(); + + $ref_method = new \ReflectionMethod($dependencies, 'extensionDependenciesAreMet'); + $ref_method->setAccessible(TRUE); + + $this->assertEquals($expected, $ref_method->invoke($dependencies, $extension_dependencies, $installed, TRUE)); + } + + /** + * @return array + * - TRUE if dependencies are expected to be met, FALSE otherwise. + * - Contents of module's composer.json file, or NULL if there is no file. + * - Contents of project's installed.json file, or NULL if there is no file. + */ + public function provideDependenciesAreMet() { + return [ + [TRUE, NULL, NULL], + [TRUE, NULL, '[]'], + [TRUE, '{}', '[]'], + [TRUE, '{"require": {"vendor/package":"1.0.0"}}', '[{"name":"vendor/package","version_normalized":"1.0.0"}]'], + [FALSE, '{"require": {"vendor/not-installed":"1.0.0"}}', '[{"name":"vendor/package","version_normalized":"1.0.0"}]'], + [FALSE, '{"require": {"vendor/package":"~1.1"}}', '[{"name":"vendor/package","version_normalized":"1.0.0"}]'], + [FALSE, '{"require-dev": {"vendor/package":"~1.1"}}', '[{"name":"vendor/package","version_normalized":"1.0.0"}]'], + ]; + } + + /** + * @covers ::dependenciesAreMet + * + * @dataProvider provideDependenciesAreMet + */ + public function testDependenciesAreMet($expected, $composer_json, $installed_json) { + $structure = [ + 'modules' => ['some_module' => ['some_module.info.yml' => '']], + 'vendor' => ['composer' => []], + ]; + if ($installed_json !== NULL) { + $structure['vendor']['composer']['installed.json'] = $installed_json; + } + if ($composer_json !== NULL) { + $structure['modules']['some_module']['composer.json'] = $composer_json; + } + vfsStream::setup('root', NULL, $structure); + + $extension = new Extension(vfsStream::url('root'), 'module', 'modules/some_module/some_module.info.yml'); + $dependencies = new ExtensionComposerDependencies(); + + $this->assertEquals($expected, $dependencies->dependenciesAreMet(vfsStream::url('root'), $extension)); + } + +}