Issue #2863986 by bircher, pfrenssen, alexpott, claudiu.cristea, Adita, dawehner, gambry, chr.fritsch: Allow updating modules with new service dependencies

merge-requests/1119/head
catch 2019-06-28 13:18:53 +01:00
parent 8f6cc82a4a
commit 8f430db9e6
10 changed files with 238 additions and 0 deletions

View File

@ -5,6 +5,7 @@ namespace Drupal\Core\Update;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\DependencyInjection\ServiceModifierInterface;
use Drupal\Core\DependencyInjection\ServiceProviderInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
@ -52,6 +53,33 @@ class UpdateServiceProvider implements ServiceProviderInterface, ServiceModifier
->clearTag('path_processor_inbound')
->clearTag('path_processor_outbound');
}
// Loop over the defined services and remove any with unmet dependencies.
// The kernel cannot be booted if the container has such services. This
// allows modules to run their update hooks to enable newly added
// dependencies.
do {
$definitions = $container->getDefinitions();
foreach ($definitions as $key => $definition) {
foreach ($definition->getArguments() as $argument) {
if ($argument instanceof Reference) {
if (!$container->has((string) $argument) && $argument->getInvalidBehavior() === ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE) {
// If the container does not have the argument and would throw an
// exception, remove the service.
$container->removeDefinition($key);
}
}
}
}
// Remove any aliases which point to undefined services.
$aliases = $container->getAliases();
foreach ($aliases as $key => $alias) {
if (!$container->has((string) $alias)) {
$container->removeAlias($key);
}
}
// Repeat if services or aliases have been removed.
} while (count($definitions) > count($container->getDefinitions()) || count($aliases) > count($container->getAliases()));
}
}

View File

@ -0,0 +1,8 @@
name: 'New Dependency test'
type: module
description: 'Support module for update testing.'
package: Testing
version: VERSION
core: 8.x
dependencies:
- new_dependency_test_with_service

View File

@ -0,0 +1,15 @@
<?php
/**
* @file
* Install, update and uninstall functions for the new_dependency_test module.
*/
/**
* Enable the new_dependency_test_with_service module.
*/
function new_dependency_test_update_8001() {
// During the update hooks the container is cleaned up to contain only
// services that have their dependencies met. Core services are available.
\Drupal::getContainer()->get('module_installer')->install(['new_dependency_test_with_service']);
}

View File

@ -0,0 +1,12 @@
services:
new_dependency_test.dependent:
class: Drupal\new_dependency_test\DependentService
arguments: ['@new_dependency_test_with_service.service']
new_dependency_test.decorated:
class: Drupal\new_dependency_test\DecoratedDependentService
arguments: ['@new_dependency_test.dependent']
new_dependency_test.decorated_optional:
class: Drupal\new_dependency_test\DecoratedDependentService
arguments: ['@?new_dependency_test.dependent']
new_dependency_test.alias:
alias: new_dependency_test.dependent

View File

@ -0,0 +1,42 @@
<?php
namespace Drupal\new_dependency_test;
/**
* Service that gets the other service of the same module injected.
*
* This service indirectly depends on a not-yet-defined service.
*/
class DecoratedDependentService {
/**
* The injected service.
*
* @var \Drupal\new_dependency_test\DependentService
*/
protected $service;
/**
* DecoratedDependentService constructor.
*
* @param \Drupal\new_dependency_test\DependentService|null $service
* The service of the same module which has the new dependency.
*/
public function __construct(DependentService $service = NULL) {
$this->service = $service;
}
/**
* Get the simple greeting from the service and decorate it.
*
* @return string
* The enhanced greeting.
*/
public function greet() {
if (isset($this->service)) {
return $this->service->greet() . ' World';
}
return 'Sorry, no service.';
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace Drupal\new_dependency_test;
use Drupal\new_dependency_test_with_service\NewService;
/**
* Generic service with a dependency on a service defined in a new module.
*/
class DependentService {
/**
* The injected service.
*
* @var \Drupal\new_dependency_test_with_service\NewService
*/
protected $service;
/**
* DependentService constructor.
*
* @param \Drupal\new_dependency_test_with_service\NewService $service
* The service of the new module.
*/
public function __construct(NewService $service) {
$this->service = $service;
}
/**
* Get the simple greeting from the service.
*
* @return string
* The greeting.
*/
public function greet() {
return $this->service->greet();
}
}

View File

@ -0,0 +1,6 @@
name: 'New Dependency test with service'
type: module
description: 'Support module for update testing.'
package: Testing
version: VERSION
core: 8.x

View File

@ -0,0 +1,3 @@
services:
new_dependency_test_with_service.service:
class: Drupal\new_dependency_test_with_service\NewService

View File

@ -0,0 +1,20 @@
<?php
namespace Drupal\new_dependency_test_with_service;
/**
* Generic service returning a greeting.
*/
class NewService {
/**
* Get a simple greeting.
*
* @return string
* The greeting provided by the new service.
*/
public function greet() {
return 'Hello';
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace Drupal\Tests\system\Functional\Update;
use Drupal\FunctionalTests\Update\UpdatePathTestBase;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
/**
* Modules can introduce new dependencies and enable them in update hooks.
*
* @group system
* @group legacy
*/
class UpdatePathNewDependencyTest extends UpdatePathTestBase {
/**
* {@inheritdoc}
*/
protected function setDatabaseDumpFiles() {
$this->databaseDumpFiles = [
__DIR__ . '/../../../../tests/fixtures/update/drupal-8.6.0.bare.testing.php.gz',
];
}
/**
* Test that a module can add services that depend on new modules.
*/
public function testUpdateNewDependency() {
// The new_dependency_test before the update is just an empty info.yml file.
// The code of the new_dependency_test module is after the update and
// contains the dependency on the new_dependency_test_with_service module.
$extension_config = $this->container->get('config.factory')->getEditable('core.extension');
$extension_config
->set('module.new_dependency_test', 0)
->set('module', module_config_sort($extension_config->get('module')))
->save(TRUE);
drupal_set_installed_schema_version('new_dependency_test', \Drupal::CORE_MINIMUM_SCHEMA_VERSION);
// Rebuild the container and test that the service with the optional unmet
// dependency is still available while the ones that fail are not.
try {
$this->rebuildContainer();
$this->fail('The container has services with unmet dependencies and should have failed to rebuild.');
}
catch (ServiceNotFoundException $exception) {
$this->assertEquals('The service "new_dependency_test.dependent" has a dependency on a non-existent service "new_dependency_test_with_service.service".', $exception->getMessage());
}
// Running the updates enables the dependency.
$this->runUpdates();
$this->assertTrue(array_key_exists('new_dependency_test', $this->container->get('config.factory')->get('core.extension')->get('module')));
$this->assertTrue(array_key_exists('new_dependency_test_with_service', $this->container->get('config.factory')->get('core.extension')->get('module')));
// Tests that the new services are available and working as expected.
$this->assertEquals('Hello', $this->container->get('new_dependency_test_with_service.service')->greet());
$this->assertEquals('Hello', $this->container->get('new_dependency_test.dependent')->greet());
$this->assertEquals('Hello', $this->container->get('new_dependency_test.alias')->greet());
$this->assertEquals('Hello World', $this->container->get('new_dependency_test.decorated')->greet());
$this->assertEquals('Hello World', $this->container->get('new_dependency_test.decorated_optional')->greet());
}
}