Skip to content
Snippets Groups Projects
Verified Commit e2c511e8 authored by Lee Rowlands's avatar Lee Rowlands
Browse files

Issue #3502913 by catch, godotislate, quietone, nicxvan, larowlan, oily, dww,...

Issue #3502913 by catch, godotislate, quietone, nicxvan, larowlan, oily, dww, berdir, alexpott: Add a fallback classloader that can handle missing traits for attribute discovery
parent 7da6cdbf
Branches
Tags
4 merge requests!5423Draft: Resolve #3329907 "Test2",!3478Issue #3337882: Deleted menus are not removed from content type config,!579Issue #2230909: Simple decimals fail to pass validation,!213Issue #2906496: Give Media a menu item under Content
Pipeline #483324 passed with warnings
Pipeline: drupal

#483337

    Showing
    with 298 additions and 120 deletions
    <?php
    declare(strict_types=1);
    namespace Drupal\Component\Discovery;
    /**
    * Defines a classloader that detects missing classes.
    *
    * This does not load classes. It allows calling code to explicitly check
    * whether a class that was requested failed to be discovered by other class
    * loaders.
    *
    * It also works around a PHP limitation when it attempts to load a class that
    * relies on a trait that does not exist. This is a common situation with Drupal
    * plugins, which may be intended to be dormant unless certain other modules are
    * installed.
    *
    * @see https://github.com/php/php-src/issues/17959
    * @internal
    */
    final class MissingClassDetectionClassLoader {
    /**
    * An array of detected missing traits.
    */
    protected array $missingTraits = [];
    /**
    * Flag indicating whether there was an attempt to load a missing class.
    */
    protected bool $missingClass = FALSE;
    /**
    * Records missing classes and aliases missing traits.
    *
    * This method is registered as a class loader during attribute discovery and
    * runs last. Any call to this method means that the requested class is
    * missing. If that class is a trait, it is aliased to a stub trait to avoid
    * an uncaught PHP fatal error.
    *
    * @param string $class
    * The class name to load.
    */
    public function loadClass(string $class): void {
    $this->missingClass = TRUE;
    if (str_ends_with($class, 'Trait')) {
    $this->missingTraits[] = $class;
    class_alias(StubTrait::class, $class);
    }
    }
    /**
    * Returns whether there was an attempt to load a missing class.
    *
    * @return bool
    * TRUE if there was an attempt to load a missing class, otherwise FALSE.
    */
    public function hasMissingClass(): bool {
    return $this->missingClass;
    }
    /**
    * Returns all recorded missing traits since the last reset.
    *
    * @return string[]
    * An array of traits recorded as missing.
    */
    public function getMissingTraits(): array {
    return $this->missingTraits;
    }
    /**
    * Resets class variables.
    */
    public function reset(): void {
    $this->missingClass = FALSE;
    $this->missingTraits = [];
    }
    }
    <?php
    declare(strict_types=1);
    namespace Drupal\Component\Discovery;
    /**
    * Defines an empty trait that can stand in for a missing trait.
    *
    * @internal
    */
    trait StubTrait {}
    ...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
    namespace Drupal\Component\Plugin\Discovery; namespace Drupal\Component\Plugin\Discovery;
    use Drupal\Component\Discovery\MissingClassDetectionClassLoader;
    use Drupal\Component\Plugin\Attribute\AttributeInterface; use Drupal\Component\Plugin\Attribute\AttributeInterface;
    use Drupal\Component\Plugin\Attribute\Plugin; use Drupal\Component\Plugin\Attribute\Plugin;
    use Drupal\Component\FileCache\FileCacheFactory; use Drupal\Component\FileCache\FileCacheFactory;
    ...@@ -19,6 +20,14 @@ class AttributeClassDiscovery implements DiscoveryInterface { ...@@ -19,6 +20,14 @@ class AttributeClassDiscovery implements DiscoveryInterface {
    */ */
    protected FileCacheInterface $fileCache; protected FileCacheInterface $fileCache;
    /**
    * An array of classes to skip.
    *
    * This must be static because once a class has been autoloaded by PHP, it
    * cannot be unregistered again.
    */
    protected static array $skipClasses = [];
    /** /**
    * Constructs a new instance. * Constructs a new instance.
    * *
    ...@@ -59,6 +68,9 @@ protected function getFileCacheSuffix(string $default_suffix): string { ...@@ -59,6 +68,9 @@ protected function getFileCacheSuffix(string $default_suffix): string {
    public function getDefinitions() { public function getDefinitions() {
    $definitions = []; $definitions = [];
    $autoloader = new MissingClassDetectionClassLoader();
    spl_autoload_register([$autoloader, 'loadClass']);
    // Search for classes within all PSR-4 namespace locations. // Search for classes within all PSR-4 namespace locations.
    foreach ($this->getPluginNamespaces() as $namespace => $dirs) { foreach ($this->getPluginNamespaces() as $namespace => $dirs) {
    foreach ($dirs as $dir) { foreach ($dirs as $dir) {
    ...@@ -81,18 +93,6 @@ public function getDefinitions() { ...@@ -81,18 +93,6 @@ public function getDefinitions() {
    $sub_path = $iterator->getSubIterator()->getSubPath(); $sub_path = $iterator->getSubIterator()->getSubPath();
    $sub_path = $sub_path ? str_replace(DIRECTORY_SEPARATOR, '\\', $sub_path) . '\\' : ''; $sub_path = $sub_path ? str_replace(DIRECTORY_SEPARATOR, '\\', $sub_path) . '\\' : '';
    $class = $namespace . '\\' . $sub_path . $fileinfo->getBasename('.php'); $class = $namespace . '\\' . $sub_path . $fileinfo->getBasename('.php');
    try {
    ['id' => $id, 'content' => $content] = $this->parseClass($class, $fileinfo);
    if ($id) {
    $definitions[$id] = $content;
    // Explicitly serialize this to create a new object instance.
    $this->fileCache->set($fileinfo->getPathName(), ['id' => $id, 'content' => serialize($content)]);
    }
    else {
    // Store a NULL object, so that the file is not parsed again.
    $this->fileCache->set($fileinfo->getPathName(), [NULL]);
    }
    }
    // Plugins may rely on Attribute classes defined by modules that // Plugins may rely on Attribute classes defined by modules that
    // are not installed. In such a case, a 'class not found' error // are not installed. In such a case, a 'class not found' error
    // may be thrown from reflection. However, this is an unavoidable // may be thrown from reflection. However, this is an unavoidable
    ...@@ -101,16 +101,86 @@ public function getDefinitions() { ...@@ -101,16 +101,86 @@ public function getDefinitions() {
    // so that it is scanned each time. This ensures that the plugin // so that it is scanned each time. This ensures that the plugin
    // definition will be found if the module it requires is // definition will be found if the module it requires is
    // enabled. // enabled.
    // PHP handles missing traits as an unrecoverable error.
    // Register a special classloader that prevents a missing
    // trait from causing an error. When it encounters a missing
    // trait it stores that it was unable to find the trait.
    // Because the classloader will result in the class being
    // autoloaded we store an array of classes to skip if this
    // method is called again.
    // If discovery runs twice in a single request, first without
    // the module that defines the missing trait, and second after it
    // has been installed, we want the plugin to be discovered in the
    // second case. Therefore, if a module has been added to skipped
    // classes, check if the trait's namespace is available.
    // If it is available, allow discovery.
    // @todo a fix for this has been committed to PHP. Once that is
    // available, attempt to make the class loader registration
    // conditional on PHP version, then remove the logic entirely once
    // Drupal requires PHP 8.5.
    // @see https://github.com/php/php-src/issues/17959
    // @see https://github.com/php/php-src/commit/8731c95b35f6838bacd12a07c50886e020aad5a6
    if (array_key_exists($class, self::$skipClasses)) {
    $missing_classes = self::$skipClasses[$class];
    foreach ($missing_classes as $missing_class) {
    $missing_class_namespace = implode('\\', array_slice(explode('\\', $missing_class), 0, 2));
    // If we arrive here a second time, and the namespace is still
    // unavailable, ensure discovery is skipped. Without this
    // explicit check for already checked classes, an invalid
    // class would be discovered, because once we've detected a
    // a missing trait and aliased the stub instead, this can't
    // happen again, so the class appears valid. However, if the
    // namespace has become available in the meantime, assume that
    // the class actually should be discovered since this probably
    // means the optional module it depends on has been enabled.
    if (!isset($this->getPluginNamespaces()[$missing_class_namespace])) {
    $autoloader->reset();
    continue 2;
    }
    }
    }
    try {
    $class_exists = class_exists($class, TRUE);
    if (!$class_exists || \count($autoloader->getMissingTraits()) > 0) {
    // @todo remove this workaround once PHP treats missing traits
    // as catchable fatal errors.
    if (\count($autoloader->getMissingTraits()) > 0) {
    self::$skipClasses[$class] = $autoloader->getMissingTraits();
    }
    $autoloader->reset();
    continue;
    }
    }
    catch (\Error $e) { catch (\Error $e) {
    if (!preg_match('/(Class|Interface) .* not found$/', $e->getMessage())) { if (!$autoloader->hasMissingClass()) {
    // @todo Add test coverage for unexpected Error exceptions in
    // https://www.drupal.org/project/drupal/issues/3520811.
    $autoloader->reset();
    spl_autoload_unregister([$autoloader, 'loadClass']);
    throw $e; throw $e;
    } }
    $autoloader->reset();
    continue;
    }
    ['id' => $id, 'content' => $content] = $this->parseClass($class, $fileinfo);
    if ($id) {
    $definitions[$id] = $content;
    // Explicitly serialize this to create a new object instance.
    if (!isset(self::$skipClasses[$class])) {
    $this->fileCache->set($fileinfo->getPathName(), ['id' => $id, 'content' => serialize($content)]);
    }
    }
    else {
    // Store a NULL object, so that the file is not parsed again.
    $this->fileCache->set($fileinfo->getPathName(), [NULL]);
    } }
    } }
    } }
    } }
    } }
    } }
    spl_autoload_unregister([$autoloader, 'loadClass']);
    // Plugin discovery is a memory expensive process due to reflection and the // Plugin discovery is a memory expensive process due to reflection and the
    // number of files involved. Collect cycles at the end of discovery to be as // number of files involved. Collect cycles at the end of discovery to be as
    ......
    parameters:
    content_translation.moved_classes:
    'Drupal\content_translation\Plugin\migrate\source\I18nQueryTrait':
    class: 'Drupal\migrate_drupal\Plugin\migrate\source\I18nQueryTrait'
    deprecation_version: drupal:11.2.0
    removed_version: drupal:12.0.0
    change_record: https://www.drupal.org/node/3439256
    services: services:
    _defaults: _defaults:
    autoconfigure: true autoconfigure: true
    ......
    <?php
    namespace Drupal\content_translation\Plugin\migrate\source;
    @trigger_error('The ' . __NAMESPACE__ . '\I18nQueryTrait is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use \Drupal\migrate_drupal\Plugin\migrate\source\I18nQueryTrait instead. See https://www.drupal.org/node/3439256', E_USER_DEPRECATED);
    use Drupal\migrate\Plugin\MigrateIdMapInterface;
    use Drupal\migrate\MigrateException;
    use Drupal\migrate\Row;
    // cspell:ignore objectid
    /**
    * Gets an i18n translation from the source database.
    *
    * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use
    * \Drupal\migrate_drupal\Plugin\migrate\source\I18nQueryTrait instead.
    *
    * @see https://www.drupal.org/node/3439256
    */
    trait I18nQueryTrait {
    /**
    * The i18n string table name.
    *
    * @var string
    */
    protected $i18nStringTable;
    /**
    * Gets the translation for the property not already in the row.
    *
    * For some i18n migrations there are two translation values, such as a
    * translated title and a translated description, that need to be retrieved.
    * Since these values are stored in separate rows of the i18nStringTable
    * table we get them individually, one in the source plugin query() and the
    * other in prepareRow(). The names of the properties varies, for example,
    * in BoxTranslation they are 'body' and 'title' whereas in
    * MenuLinkTranslation they are 'title' and 'description'. This will save both
    * translations to the row.
    *
    * @param \Drupal\migrate\Row $row
    * The current migration row which must include both a 'language' property
    * and an 'objectid' property. The 'objectid' is the value for the
    * 'objectid' field in the i18n_string table.
    * @param string $property_not_in_row
    * The name of the property to get the translation for.
    * @param string $object_id_name
    * The value of the objectid in the i18n table.
    * @param \Drupal\migrate\Plugin\MigrateIdMapInterface $id_map
    * The ID map.
    *
    * @return bool
    * FALSE if the property has already been migrated.
    *
    * @throws \Drupal\migrate\MigrateException
    */
    protected function getPropertyNotInRowTranslation(Row $row, $property_not_in_row, $object_id_name, MigrateIdMapInterface $id_map) {
    $language = $row->getSourceProperty('language');
    if (!$language) {
    throw new MigrateException('No language found.');
    }
    $object_id = $row->getSourceProperty($object_id_name);
    if (!$object_id) {
    throw new MigrateException('No objectid found.');
    }
    // If this row has been migrated it is a duplicate so skip it.
    if ($id_map->lookupDestinationIds([$object_id_name => $object_id, 'language' => $language])) {
    return FALSE;
    }
    // Save the translation for the property already in the row.
    $property_in_row = $row->getSourceProperty('property');
    $row->setSourceProperty($property_in_row . '_translated', $row->getSourceProperty('translation'));
    // Get the translation, if one exists, for the property not already in the
    // row.
    $query = $this->select($this->i18nStringTable, 'i18n')
    ->fields('i18n', ['lid'])
    ->condition('i18n.property', $property_not_in_row)
    ->condition('i18n.objectid', $object_id);
    $query->leftJoin('locales_target', 'lt', '[i18n].[lid] = [lt].[lid]');
    $query->condition('lt.language', $language);
    $query->addField('lt', 'translation');
    $results = $query->execute()->fetchAssoc();
    if (!$results) {
    $row->setSourceProperty($property_not_in_row . '_translated', NULL);
    }
    else {
    $row->setSourceProperty($property_not_in_row . '_translated', $results['translation']);
    }
    return TRUE;
    }
    }
    ...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
    use Drupal\block_content\Access\RefinableDependentAccessTrait; use Drupal\block_content\Access\RefinableDependentAccessTrait;
    use Drupal\Component\Utility\NestedArray; use Drupal\Component\Utility\NestedArray;
    use Drupal\Core\Access\AccessResult; use Drupal\Core\Access\AccessResult;
    use Drupal\Core\Block\Attribute\Block;
    use Drupal\Core\Block\BlockBase; use Drupal\Core\Block\BlockBase;
    use Drupal\Core\Entity\Entity\EntityFormDisplay; use Drupal\Core\Entity\Entity\EntityFormDisplay;
    use Drupal\Core\Entity\EntityDisplayRepositoryInterface; use Drupal\Core\Entity\EntityDisplayRepositoryInterface;
    ...@@ -14,21 +15,22 @@ ...@@ -14,21 +15,22 @@
    use Drupal\Core\Form\SubformStateInterface; use Drupal\Core\Form\SubformStateInterface;
    use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
    use Drupal\Core\Session\AccountInterface; use Drupal\Core\Session\AccountInterface;
    use Drupal\Core\StringTranslation\TranslatableMarkup;
    use Drupal\layout_builder\Plugin\Derivative\InlineBlockDeriver;
    use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\ContainerInterface;
    /** /**
    * Defines an inline block plugin type. * Defines an inline block plugin type.
    * *
    * @Block(
    * id = "inline_block",
    * admin_label = @Translation("Inline block"),
    * category = @Translation("Inline blocks"),
    * deriver = "Drupal\layout_builder\Plugin\Derivative\InlineBlockDeriver",
    * )
    *
    * @internal * @internal
    * Plugin classes are internal. * Plugin classes are internal.
    */ */
    #[Block(
    id: 'inline_block',
    admin_label: new TranslatableMarkup('Inline block'),
    category: new TranslatableMarkup('Inline blocks'),
    deriver: InlineBlockDeriver::class,
    )]
    class InlineBlock extends BlockBase implements ContainerFactoryPluginInterface, RefinableDependentAccessInterface { class InlineBlock extends BlockBase implements ContainerFactoryPluginInterface, RefinableDependentAccessInterface {
    use RefinableDependentAccessTrait; use RefinableDependentAccessTrait;
    ......
    ...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
    /** /**
    * @coversDefaultClass \Drupal\Component\Plugin\Discovery\AttributeClassDiscovery * @coversDefaultClass \Drupal\Component\Plugin\Discovery\AttributeClassDiscovery
    * @covers \Drupal\Component\Discovery\MissingClassDetectionClassLoader
    * @group Attribute * @group Attribute
    * @runTestsInSeparateProcesses * @runTestsInSeparateProcesses
    */ */
    ...@@ -46,8 +47,12 @@ public function testGetDefinitions(): void { ...@@ -46,8 +47,12 @@ public function testGetDefinitions(): void {
    $discovery_path = __DIR__ . "/../../../../../fixtures/plugins/Plugin"; $discovery_path = __DIR__ . "/../../../../../fixtures/plugins/Plugin";
    // File path that should be discovered within that directory. // File path that should be discovered within that directory.
    $file_path = $discovery_path . '/PluginNamespace/AttributeDiscoveryTest1.php'; $file_path = $discovery_path . '/PluginNamespace/AttributeDiscoveryTest1.php';
    // Define a file path within the directory that should not be discovered. // Define file paths within the directory that should not be discovered.
    $non_discoverable_file_path = $discovery_path . '/PluginNamespace/AttributeDiscoveryTest2.php'; $non_discoverable_file_paths = [
    $discovery_path . '/PluginNamespace/AttributeDiscoveryTest2.php',
    $discovery_path . '/PluginNamespace/AttributeDiscoveryTestMissingInterface.php',
    $discovery_path . '/PluginNamespace/AttributeDiscoveryTestMissingTrait.php',
    ];
    $discovery = new AttributeClassDiscovery(['com\example' => [$discovery_path]]); $discovery = new AttributeClassDiscovery(['com\example' => [$discovery_path]]);
    $this->assertEquals([ $this->assertEquals([
    ...@@ -69,8 +74,12 @@ public function testGetDefinitions(): void { ...@@ -69,8 +74,12 @@ public function testGetDefinitions(): void {
    'class' => 'com\example\PluginNamespace\AttributeDiscoveryTest1', 'class' => 'com\example\PluginNamespace\AttributeDiscoveryTest1',
    ], unserialize($file_cache->get($file_path)['content'])); ], unserialize($file_cache->get($file_path)['content']));
    // The plugin that extends a missing class should not be cached. // The plugins that extend a missing class, implement a missing interface,
    // and use a missing trait should not be cached.
    foreach ($non_discoverable_file_paths as $non_discoverable_file_path) {
    $this->assertTrue(file_exists($non_discoverable_file_path));
    $this->assertNull($file_cache->get($non_discoverable_file_path)); $this->assertNull($file_cache->get($non_discoverable_file_path));
    }
    // Change the file cache entry. // Change the file cache entry.
    // The file cache is keyed by the file path, and we'll add some known // The file cache is keyed by the file path, and we'll add some known
    ...@@ -88,4 +97,62 @@ public function testGetDefinitions(): void { ...@@ -88,4 +97,62 @@ public function testGetDefinitions(): void {
    ], $discovery->getDefinitions()); ], $discovery->getDefinitions());
    } }
    /**
    * Tests discovery with missing traits.
    *
    * @covers ::getDefinitions
    */
    public function testGetDefinitionsMissingTrait(): void {
    // Path to the classes which we'll discover and parse annotation.
    $discovery_path = __DIR__ . "/../../../../../fixtures/plugins/Plugin";
    // Define file paths within the directory that should not be discovered.
    $non_discoverable_file_paths = [
    $discovery_path . '/PluginNamespace/AttributeDiscoveryTest2.php',
    $discovery_path . '/PluginNamespace/AttributeDiscoveryTestMissingInterface.php',
    $discovery_path . '/PluginNamespace/AttributeDiscoveryTestMissingTrait.php',
    ];
    $discovery = new AttributeClassDiscovery(['com\example' => [$discovery_path]]);
    $this->assertEquals([
    'discovery_test_1' => [
    'id' => 'discovery_test_1',
    'class' => 'com\example\PluginNamespace\AttributeDiscoveryTest1',
    ],
    ], $discovery->getDefinitions());
    // Gain access to the file cache.
    $ref_file_cache = new \ReflectionProperty($discovery, 'fileCache');
    $ref_file_cache->setAccessible(TRUE);
    /** @var \Drupal\Component\FileCache\FileCacheInterface $file_cache */
    $file_cache = $ref_file_cache->getValue($discovery);
    // The plugins that extend a missing class, implement a missing interface,
    // and use a missing trait should not be cached.
    foreach ($non_discoverable_file_paths as $non_discoverable_file_path) {
    $this->assertTrue(file_exists($non_discoverable_file_path));
    $this->assertNull($file_cache->get($non_discoverable_file_path));
    }
    $discovery = new AttributeClassDiscovery(['com\example' => [$discovery_path], 'Drupal\a_module_that_does_not_exist' => [$discovery_path]]);
    $this->assertEquals([
    'discovery_test_1' => [
    'id' => 'discovery_test_1',
    'class' => 'com\example\PluginNamespace\AttributeDiscoveryTest1',
    ],
    'discovery_test_missing_trait' => [
    'id' => 'discovery_test_missing_trait',
    'class' => 'com\example\PluginNamespace\AttributeDiscoveryTestMissingTrait',
    'title' => 'Discovery test plugin missing trait',
    ],
    ], $discovery->getDefinitions());
    // The plugins that extend a missing class, implement a missing interface,
    // and use a missing trait should not be cached. This is the case even for
    // the plugin that was just discovered.
    foreach ($non_discoverable_file_paths as $non_discoverable_file_path) {
    $this->assertTrue(file_exists($non_discoverable_file_path));
    $this->assertNull($file_cache->get($non_discoverable_file_path));
    }
    }
    } }
    <?php
    declare(strict_types=1);
    namespace com\example\PluginNamespace;
    use Drupal\a_module_that_does_not_exist\Plugin\CustomInterface;
    /**
    * Provides a custom test plugin that implements a missing interface.
    */
    #[CustomPlugin(
    id: "discovery_test_missing_interface",
    title: "Discovery test plugin missing interface"
    )]
    class AttributeDiscoveryTestMissingInterface implements CustomInterface {}
    <?php
    declare(strict_types=1);
    namespace com\example\PluginNamespace;
    use Drupal\a_module_that_does_not_exist\Plugin\CustomTrait;
    /**
    * Provides a custom test plugin that uses a missing trait.
    */
    #[CustomPlugin(
    id: "discovery_test_missing_trait",
    title: "Discovery test plugin missing trait"
    )]
    class AttributeDiscoveryTestMissingTrait {
    use CustomTrait;
    }
    0% Loading or .
    You are about to add 0 people to the discussion. Proceed with caution.
    Please register or to comment