diff --git a/core/lib/Drupal/Component/Discovery/StubTrait.php b/core/lib/Drupal/Component/Discovery/StubTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..b727d7a8339a82d18a510940683d080e89dea888 --- /dev/null +++ b/core/lib/Drupal/Component/Discovery/StubTrait.php @@ -0,0 +1,10 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Component\Discovery; + +/** + * Defines an empty trait that can stand in for a missing trait. + */ +trait StubTrait {} diff --git a/core/lib/Drupal/Component/Discovery/TraitSafeClassLoader.php b/core/lib/Drupal/Component/Discovery/TraitSafeClassLoader.php new file mode 100644 index 0000000000000000000000000000000000000000..c137aad07b8252d1ac917969b28fb7dcd2499fb2 --- /dev/null +++ b/core/lib/Drupal/Component/Discovery/TraitSafeClassLoader.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Component\Discovery; + +/** + * Defines a classloader that handles missing traits. + * + * This does not really load classes, but exists to work 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. + */ +class TraitSafeClassLoader { + + /** + * Flag indicating whether there was an attempt to load a missing trait. + */ + protected bool $missingTrait = FALSE; + + /** + * Aliases trait to a stub trait and sets the missing trait flag. + * + * This method is registered as a class loader during attribute discovery and + * runs last. Any call to this method means that $class is missing, and if + * $class is a trait, the flag is set. + * + * @param string $class + * The classname to load. + */ + public function loadClass(string $class): void { + if (str_ends_with($class, 'Trait')) { + $this->missingTrait = TRUE; + class_alias(StubTrait::class, $class, TRUE); + } + } + + /** + * Returns whether there was an attempt to load a missing trait. + * + * @return bool + * TRUE if there was an attempt to load a missing trait, otherwise FALSE. + */ + public function hasMissingTrait(): bool { + return $this->missingTrait; + } + + /** + * Resets the missing trait flag to FALSE. + */ + public function reset(): void { + $this->missingTrait = FALSE; + } + +} diff --git a/core/lib/Drupal/Component/Plugin/Discovery/AttributeClassDiscovery.php b/core/lib/Drupal/Component/Plugin/Discovery/AttributeClassDiscovery.php index cc7cb5f168481cf2ce2d6aa88dd68e0e30189955..ad2b3eb31b13b08d948a9f678f32a4d1de75e681 100644 --- a/core/lib/Drupal/Component/Plugin/Discovery/AttributeClassDiscovery.php +++ b/core/lib/Drupal/Component/Plugin/Discovery/AttributeClassDiscovery.php @@ -2,6 +2,7 @@ namespace Drupal\Component\Plugin\Discovery; +use Drupal\Component\Discovery\TraitSafeClassLoader; use Drupal\Component\Plugin\Attribute\AttributeInterface; use Drupal\Component\Plugin\Attribute\Plugin; use Drupal\Component\FileCache\FileCacheFactory; @@ -19,6 +20,11 @@ class AttributeClassDiscovery implements DiscoveryInterface { */ protected FileCacheInterface $fileCache; + /** + * An array of classes to skip. + */ + protected static array $skipClasses = []; + /** * Constructs a new instance. * @@ -59,6 +65,9 @@ protected function getFileCacheSuffix(string $default_suffix): string { public function getDefinitions() { $definitions = []; + $autoloader = new TraitSafeClassLoader(); + spl_autoload_register([$autoloader, 'loadClass']); + // Search for classes within all PSR-4 namespace locations. foreach ($this->getPluginNamespaces() as $namespace => $dirs) { foreach ($dirs as $dir) { @@ -81,18 +90,6 @@ public function getDefinitions() { $sub_path = $iterator->getSubIterator()->getSubPath(); $sub_path = $sub_path ? str_replace(DIRECTORY_SEPARATOR, '\\', $sub_path) . '\\' : ''; $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 // are not installed. In such a case, a 'class not found' error // may be thrown from reflection. However, this is an unavoidable @@ -101,16 +98,48 @@ public function getDefinitions() { // so that it is scanned each time. This ensures that the plugin // definition will be found if the module it requires is // enabled. + // Additionally, PHP handles missing traits as an unrecoverable + // error. Register a special classloader that prevents a missing + // trait from causing an error, but stores that it was unable to + // find something. Because the classloader will result in the + // class being successfully autoloaded, store an array of classes + // to skip if this method is called again. + if (array_key_exists($class, self::$skipClasses)) { + continue; + } + try { + $class_exists = \class_exists($class, TRUE); + if (!$class_exists || $autoloader->hasMissingTrait()) { + self::$skipClasses[$class] = TRUE; + $autoloader->reset(); + continue; + } + } catch (\Error $e) { + self::$skipClasses[$class] = TRUE; + $autoloader->reset(); if (!preg_match('/(Class|Interface) .* not found$/', $e->getMessage())) { + spl_autoload_unregister([$autoloader, 'loadClass']); throw $e; } + continue; + } + ['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]); } } } } } } + spl_autoload_unregister([$autoloader, 'loadClass']); // 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 diff --git a/core/modules/content_translation/content_translation.services.yml b/core/modules/content_translation/content_translation.services.yml index f2a40968fc07d0e2fdcb34ca75dddacfe7590186..df356de471d674457e695118dd31c64ba5731d83 100644 --- a/core/modules/content_translation/content_translation.services.yml +++ b/core/modules/content_translation/content_translation.services.yml @@ -1,3 +1,10 @@ +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: _defaults: autoconfigure: true diff --git a/core/modules/content_translation/src/Plugin/migrate/source/I18nQueryTrait.php b/core/modules/content_translation/src/Plugin/migrate/source/I18nQueryTrait.php deleted file mode 100644 index f6ea52e84aba71a19914174621dd0dae10d07de6..0000000000000000000000000000000000000000 --- a/core/modules/content_translation/src/Plugin/migrate/source/I18nQueryTrait.php +++ /dev/null @@ -1,96 +0,0 @@ -<?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; - } - -} diff --git a/core/modules/layout_builder/src/Plugin/Block/InlineBlock.php b/core/modules/layout_builder/src/Plugin/Block/InlineBlock.php index cfd853f6e9a6700d9c06ba0733760ebbaae88b02..2c66d43b8200d17326fcaca071b0bf899d2661ce 100644 --- a/core/modules/layout_builder/src/Plugin/Block/InlineBlock.php +++ b/core/modules/layout_builder/src/Plugin/Block/InlineBlock.php @@ -6,6 +6,7 @@ use Drupal\block_content\Access\RefinableDependentAccessTrait; use Drupal\Component\Utility\NestedArray; use Drupal\Core\Access\AccessResult; +use Drupal\Core\Block\Attribute\Block; use Drupal\Core\Block\BlockBase; use Drupal\Core\Entity\Entity\EntityFormDisplay; use Drupal\Core\Entity\EntityDisplayRepositoryInterface; @@ -14,21 +15,22 @@ use Drupal\Core\Form\SubformStateInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\layout_builder\Plugin\Derivative\InlineBlockDeriver; use Symfony\Component\DependencyInjection\ContainerInterface; /** * 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 * 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 { use RefinableDependentAccessTrait; diff --git a/core/tests/Drupal/Tests/Component/Plugin/Attribute/AttributeClassDiscoveryCachedTest.php b/core/tests/Drupal/Tests/Component/Plugin/Attribute/AttributeClassDiscoveryCachedTest.php index b3d11614cce5337f4493f569424e128b949fc17b..b069dca082817d03a84fe29b3bc2dffd83b94bc4 100644 --- a/core/tests/Drupal/Tests/Component/Plugin/Attribute/AttributeClassDiscoveryCachedTest.php +++ b/core/tests/Drupal/Tests/Component/Plugin/Attribute/AttributeClassDiscoveryCachedTest.php @@ -46,8 +46,12 @@ public function testGetDefinitions(): void { $discovery_path = __DIR__ . "/../../../../../fixtures/plugins/Plugin"; // File path that should be discovered within that directory. $file_path = $discovery_path . '/PluginNamespace/AttributeDiscoveryTest1.php'; - // Define a file path within the directory that should not be discovered. - $non_discoverable_file_path = $discovery_path . '/PluginNamespace/AttributeDiscoveryTest2.php'; + // 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([ @@ -69,8 +73,12 @@ public function testGetDefinitions(): void { 'class' => 'com\example\PluginNamespace\AttributeDiscoveryTest1', ], unserialize($file_cache->get($file_path)['content'])); - // The plugin that extends a missing class should not be cached. - $this->assertNull($file_cache->get($non_discoverable_file_path)); + // 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)); + } // Change the file cache entry. // The file cache is keyed by the file path, and we'll add some known diff --git a/core/tests/fixtures/plugins/Plugin/PluginNamespace/AttributeDiscoveryTestMissingInterface.php b/core/tests/fixtures/plugins/Plugin/PluginNamespace/AttributeDiscoveryTestMissingInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..3c9c2d2684d5db2ea621c4f10babc4f097a1de4e --- /dev/null +++ b/core/tests/fixtures/plugins/Plugin/PluginNamespace/AttributeDiscoveryTestMissingInterface.php @@ -0,0 +1,16 @@ +<?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 {} diff --git a/core/tests/fixtures/plugins/Plugin/PluginNamespace/AttributeDiscoveryTestMissingTrait.php b/core/tests/fixtures/plugins/Plugin/PluginNamespace/AttributeDiscoveryTestMissingTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..e9d041c6b852fa48db3d9fee1784a0f362acc56d --- /dev/null +++ b/core/tests/fixtures/plugins/Plugin/PluginNamespace/AttributeDiscoveryTestMissingTrait.php @@ -0,0 +1,19 @@ +<?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; + +}