diff --git a/core/core.services.yml b/core/core.services.yml index dfb5274960f3cf026bd5221c6211911e2f3e2d26..047d96b6391e9fcb08390e0c4cc8886a5f008335 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -727,7 +727,7 @@ services: Drupal\Core\Entity\EntityTypeRepositoryInterface: '@entity_type.repository' entity_type.bundle.info: class: Drupal\Core\Entity\EntityTypeBundleInfo - arguments: ['@entity_type.manager', '@language_manager', '@module_handler', '@typed_data_manager', '@cache.discovery'] + arguments: ['@entity_type.manager', '@language_manager', '@module_handler', '@typed_data_manager', '@cache.discovery', '%entity.bundle_classes%'] Drupal\Core\Entity\EntityTypeBundleInfoInterface: '@entity_type.bundle.info' entity.repository: class: Drupal\Core\Entity\EntityRepository diff --git a/core/lib/Drupal/Core/CoreServiceProvider.php b/core/lib/Drupal/Core/CoreServiceProvider.php index 6150c188d182b746bcf26d2e6cf42796e58de236..2b00638d6e469a302e1df7ea6dbfd497ae16652c 100644 --- a/core/lib/Drupal/Core/CoreServiceProvider.php +++ b/core/lib/Drupal/Core/CoreServiceProvider.php @@ -10,6 +10,7 @@ use Drupal\Core\DependencyInjection\Compiler\CorsCompilerPass; use Drupal\Core\DependencyInjection\Compiler\DeprecatedServicePass; use Drupal\Core\DependencyInjection\Compiler\DevelopmentSettingsPass; +use Drupal\Core\Entity\BundleClassCollectorPass; use Drupal\Core\Hook\HookCollectorPass; use Drupal\Core\DependencyInjection\Compiler\LoggerAwarePass; use Drupal\Core\DependencyInjection\Compiler\ModifyServiceDefinitionsPass; @@ -115,6 +116,8 @@ public function register(ContainerBuilder $container) { // Collect moved classes for the backwards compatibility class loader. $container->addCompilerPass(new BackwardsCompatibilityClassLoaderPass()); + $container->addCompilerPass(new BundleClassCollectorPass()); + $container->registerForAutoconfiguration(EventSubscriberInterface::class) ->addTag('event_subscriber'); diff --git a/core/lib/Drupal/Core/Entity/Attribute/Bundle.php b/core/lib/Drupal/Core/Entity/Attribute/Bundle.php new file mode 100644 index 0000000000000000000000000000000000000000..438232f3bc4cade05b9fc6bf77e7f818208d4593 --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Attribute/Bundle.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Entity\Attribute; + +use Drupal\Core\StringTranslation\TranslatableMarkup; + +/** + * Defines an attribute for registering a bundle class. + */ +#[\Attribute(\Attribute::TARGET_CLASS)] +class Bundle { + + /** + * Constructs a Bundle attribute object. + * + * @param string $entityTypeId + * The entity type ID. + * @param string|null $bundle + * The bundle ID, or NULL to use the entity type ID. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $label + * The label of the bundle. + */ + public function __construct( + // @todo see if we can infer the entity type automatically. + public string $entityTypeId, + public ?string $bundle = NULL, + public ?TranslatableMarkup $label = NULL, + ) { + $this->bundle ??= $this->entityTypeId; + } + +} diff --git a/core/lib/Drupal/Core/Entity/BundleClassCollectorPass.php b/core/lib/Drupal/Core/Entity/BundleClassCollectorPass.php new file mode 100644 index 0000000000000000000000000000000000000000..a986384fd4ad4163e6a7cea6faf8466677936dc0 --- /dev/null +++ b/core/lib/Drupal/Core/Entity/BundleClassCollectorPass.php @@ -0,0 +1,78 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Entity; + +use Drupal\Core\Entity\Attribute\Bundle; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * Collects and registers bundle classes. + * + * @todo more documentation. + */ +class BundleClassCollectorPass implements CompilerPassInterface { + + /** + * The bundle classes. + * + * @var array<class-string, array{'entityTypeId': string, 'bundle': string|null, 'label': \Drupal\Core\StringTranslation\TranslatableMarkup|null}> + */ + protected array $bundleClasses = []; + + /** + * {@inheritdoc} + */ + public function process(ContainerBuilder $container): void { + // @todo should we limit this to modules/profiles? + $namespaces = $container->getParameter('container.namespaces'); + foreach ($namespaces as $namespace => $directory) { + if (!file_exists($directory . '/Entity')) { + continue; + } + $this->collectBundleClasses($directory . '/Entity', $namespace, $directory); + } + $container->setParameter('entity.bundle_classes', $this->bundleClasses); + } + + /** + * Collects bundle classes. + * + * @param string $directory + * The directory to search for bundle classes. + * @param string $namespace + * The namespace of the directory. + * @param string $namespace_root + * The directory of the namespace root. + */ + protected function collectBundleClasses(string $directory, string $namespace, string $namespace_root): void { + $iterator = new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS | \FilesystemIterator::FOLLOW_SYMLINKS); + $iterator = new \RecursiveCallbackFilterIterator($iterator, static::filterIterator(...)); + foreach ($iterator as $fileInfo) { + if ($fileInfo->isDir()) { + $this->collectBundleClasses($fileInfo->getPathname(), $namespace, $namespace_root); + continue; + } + $subdir = substr($fileInfo->getPath(), strlen($namespace_root)); + $fqcn = str_replace(DIRECTORY_SEPARATOR, "\\", $namespace . $subdir . '\\' . $fileInfo->getBasename('.php')); + if (!class_exists($fqcn)) { + continue; + } + $reflection = new \ReflectionClass($fqcn); + $attributes = $reflection->getAttributes(Bundle::class); + if (count($attributes) > 0) { + $this->bundleClasses[$fqcn] = $attributes[0]->getArguments(); + } + } + } + + /** + * Filter iterator callback. + */ + protected static function filterIterator(\SplFileInfo $fileInfo, $key, \RecursiveDirectoryIterator $iterator): bool { + return $iterator->isDir() || $fileInfo->getExtension() === 'php'; + } + +} diff --git a/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php b/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php index 93a7bc6e27002b4d5948941d0c1f9b22286945d8..f247df4456f4d7e3e26579b40d4998f4c9459156 100644 --- a/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php +++ b/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php @@ -64,8 +64,10 @@ class EntityTypeBundleInfo implements EntityTypeBundleInfoInterface { * The typed data manager. * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend * The cache backend. + * @param array $bundleClasses + * An array of bundle class info, keyed by fully qualified class name. */ - public function __construct(EntityTypeManagerInterface $entity_type_manager, LanguageManagerInterface $language_manager, ModuleHandlerInterface $module_handler, TypedDataManagerInterface $typed_data_manager, CacheBackendInterface $cache_backend) { + public function __construct(EntityTypeManagerInterface $entity_type_manager, LanguageManagerInterface $language_manager, ModuleHandlerInterface $module_handler, TypedDataManagerInterface $typed_data_manager, CacheBackendInterface $cache_backend, protected array $bundleClasses = []) { $this->entityTypeManager = $entity_type_manager; $this->languageManager = $language_manager; $this->moduleHandler = $module_handler; @@ -107,6 +109,15 @@ public function getAllBundleInfo() { $this->bundleInfo[$type][$type]['label'] = $entity_type->getLabel(); } } + + // Bundle classes. + foreach ($this->bundleClasses as $class => $info) { + $this->bundleInfo[$info['entityTypeId']][$info['bundle']]['class'] = $class; + $this->bundleInfo[$info['entityTypeId']][$info['bundle']]['label'] = $info['label'] + ?? $this->bundleInfo[$info['entityTypeId']][$info['bundle']]['label'] + ?? $info['bundle']; + } + $this->moduleHandler->alter('entity_bundle_info', $this->bundleInfo); $this->cacheSet("entity_bundle_info:$langcode", $this->bundleInfo, Cache::PERMANENT, [ 'entity_types', diff --git a/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/EntityTestBundleClass.php b/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/EntityTestBundleClass.php index 145aa060f90b5607fb8ea27263e0e2c9c224a48c..75b1d76819fe6665f56cde024ec8cf7e8c2d6dd7 100644 --- a/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/EntityTestBundleClass.php +++ b/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/EntityTestBundleClass.php @@ -4,12 +4,17 @@ namespace Drupal\entity_test_bundle_class\Entity; +use Drupal\Core\Entity\Attribute\Bundle; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\entity_test\Entity\EntityTest; /** * The bundle class for the bundle_class bundle of the entity_test entity. */ +#[Bundle( + entityTypeId: 'entity_test', + bundle: 'bundle_class', +)] class EntityTestBundleClass extends EntityTest { /** diff --git a/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/SharedEntityTestBundleClassA.php b/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/SharedEntityTestBundleClassA.php index af5444a98ec9f5bf400791856b9c4cf6508bedb5..5227865a312c97273d430f8ee719e4768c9dda17 100644 --- a/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/SharedEntityTestBundleClassA.php +++ b/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/SharedEntityTestBundleClassA.php @@ -4,10 +4,17 @@ namespace Drupal\entity_test_bundle_class\Entity; +use Drupal\Core\Entity\Attribute\Bundle; +use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\entity_test\Entity\EntityTest; /** * A bundle class that shares the same entity type as entity_test. */ +#[Bundle( + entityTypeId: 'shared_type', + bundle: 'bundle_a', + label: new TranslatableMarkup('Bundle A'), +)] class SharedEntityTestBundleClassA extends EntityTest { } diff --git a/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/SharedEntityTestBundleClassB.php b/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/SharedEntityTestBundleClassB.php index 93cb06690c53ed82eef263445e3ddd267f879278..c4c5abd4e22ac9161568ea5a4da453f2232e92a6 100644 --- a/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/SharedEntityTestBundleClassB.php +++ b/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/SharedEntityTestBundleClassB.php @@ -4,10 +4,17 @@ namespace Drupal\entity_test_bundle_class\Entity; +use Drupal\Core\Entity\Attribute\Bundle; +use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\entity_test\Entity\EntityTest; /** * A bundle class that shares the same entity type as entity_test. */ +#[Bundle( + entityTypeId: 'shared_type', + bundle: 'bundle_b', + label: new TranslatableMarkup('Bundle B'), +)] class SharedEntityTestBundleClassB extends EntityTest { } diff --git a/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/Subdir/EntityTestSubdirBundleClass.php b/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/Subdir/EntityTestSubdirBundleClass.php new file mode 100644 index 0000000000000000000000000000000000000000..5ace395eabf1d35a745446c164e3fbb47ddb3583 --- /dev/null +++ b/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/Subdir/EntityTestSubdirBundleClass.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\entity_test_bundle_class\Entity\Subdir; + +use Drupal\Core\Entity\Attribute\Bundle; +use Drupal\entity_test\Entity\EntityTest; + +/** + * A bundle class that is in a subdirectory. + */ +#[Bundle( + entityTypeId: 'entity_test', + bundle: 'subdir_bundle_class', +)] +class EntityTestSubdirBundleClass extends EntityTest { + +} diff --git a/core/modules/system/tests/modules/entity_test_bundle_class/src/Hook/EntityTestBundleClassHooks.php b/core/modules/system/tests/modules/entity_test_bundle_class/src/Hook/EntityTestBundleClassHooks.php index 5e49bd0298b95ea8cb6ee028a758fbc2fcf4e7c4..57e8eb24303f5c2b133518d3d3792e495a132fd8 100644 --- a/core/modules/system/tests/modules/entity_test_bundle_class/src/Hook/EntityTestBundleClassHooks.php +++ b/core/modules/system/tests/modules/entity_test_bundle_class/src/Hook/EntityTestBundleClassHooks.php @@ -6,8 +6,6 @@ use Drupal\entity_test\Entity\EntityTest; use Drupal\entity_test_bundle_class\Entity\EntityTestVariant; -use Drupal\entity_test_bundle_class\Entity\SharedEntityTestBundleClassB; -use Drupal\entity_test_bundle_class\Entity\SharedEntityTestBundleClassA; use Drupal\entity_test_bundle_class\Entity\EntityTestUserClass; use Drupal\entity_test_bundle_class\Entity\NonInheritingBundleClass; use Drupal\entity_test_bundle_class\Entity\EntityTestAmbiguousBundleClass; @@ -24,9 +22,6 @@ class EntityTestBundleClassHooks { */ #[Hook('entity_bundle_info_alter')] public function entityBundleInfoAlter(&$bundles): void { - if (!empty($bundles['entity_test']['bundle_class'])) { - $bundles['entity_test']['bundle_class']['class'] = EntityTestBundleClass::class; - } if (\Drupal::state()->get('entity_test_bundle_class_enable_ambiguous_entity_types', FALSE)) { $bundles['entity_test']['bundle_class_2']['class'] = EntityTestBundleClass::class; $bundles['entity_test']['entity_test_no_label']['class'] = EntityTestAmbiguousBundleClass::class; @@ -41,15 +36,6 @@ public function entityBundleInfoAlter(&$bundles): void { if (\Drupal::state()->get('entity_test_bundle_class_does_not_exist', FALSE)) { $bundles['entity_test']['bundle_class']['class'] = '\Drupal\Core\NonExistentClass'; } - // Have two bundles share the same base entity class. - $bundles['shared_type']['bundle_a'] = [ - 'label' => 'Bundle A', - 'class' => SharedEntityTestBundleClassA::class, - ]; - $bundles['shared_type']['bundle_b'] = [ - 'label' => 'Bundle B', - 'class' => SharedEntityTestBundleClassB::class, - ]; } /**