Problem/Motivation
Note: This could just as well be marked against the entity system.
Data type info depends on bundle info
\Drupal\Core\Entity\Plugin\DataType\Deriver\EntityDeriver
generates a data type for every entity bundle, so that you can, for example, validate that a given data structure is a valid entity:node:article
and not just any entity:node
. Thus, the data type info depends directly on the entity bundle info.
Bundle info depends on data type info
Bundles for one entity type are usually provided by (configuration) entities of another type. Those other entities, however, are themselves instances of data types, for example entity:node_type
. So by loading those entities during the building of the bundle info it "conceptually" depends on the data type info. "Conceptually" here means that due to a variety of circumstances it happens to be the case that the typed data info is never actually primed during the loading of those bundle entities. Thus, as it stands the bundle info can be built correctly and completely without any recursion.
But if - for whatever reason - any contrib or custom module (or combination thereof) leads to TypedDataManager::getDefinitions()
being called during the loading of a bundle entity this will start to go south. Examples of how this can happen:
- You use content entities as bundles* If you have a field with a property constraint on that entity, simply loading the entity will build the field definitions and that will end up calling
TypedDataManager::getDefaultConstraints()
for that field which callsTypedDataManager::getDefinitions()
. Alternatively if you interact with any fieldFieldTypePluginManager::createFieldItemList()
will triggerEntity::getTypedData()
. - You trigger validation Even though generally this is only done before saving an entity, there really is no reason why it should not be allowed to validate an entity on load. You may not be able to resolve any errors, but you may want to log something or show a warning in the user interface that there is a problem.
- Generally speaking: You trigger
Entity::getTypedData()
This is a bit vague, but the existence of that method is a clear pointer that you should be allowed to interact with the typed data system during the loading of an entity. JSON:API does this inResourceObject::extractContentEntityFields()
to get a list of non-internal fields. That isn't directly called on entity load, but there's no reason why another module shouldn't be allowed to have that exact information during entity load.
* Even though this is not done in core, there is nothing that prevents you from using a content entity as a bundle entity for another entity. If you know that you are never going to have more than a handful of those entities but you want those entities to be managed by the website admins and not be reverted by a config import, that's a great fit. In fact for many sites things like menus or webforms would be better suited as content entities and those could be bundles for other entities (menu links and webform submissions, for example). That's not to say, that we should necessarily have a "bundle content entity" in core, rather to give some rationale for why that's not as far fetched as it may seem at first thought.
The symptom: missing data types
One might think that if this recursive dependency is triggered as described above and neither the bundle info nor the data type info are cached, this leads to infinite recursion. This is not the case, however, because of the specifics of how EntityTypeBundleInfo
works: It first checks whether there is something in its internal static cache before building the bundle info. It then directly (partially) populates that static cache with the result of hook_entity_bundle_info()
implementations and proceeds to amend that cache with the info from the various bundle entities. If during the loading of the bundle entities it happens to recurse into itself via TypedDataManager
it will find an already - partially! - populated static cache and pass that partial info along. TypedDataManager
(or to be precise EntityDeriver
), thus, may not derive bundle data types for entities which actually do have bundles, because EntityTypeBundleInfo
didn't have those bundles yet. The data type info will then be cached without those bundle data types leading to various errors down the road if those data types are (rightly) assumed to be present on the system.
Assuming no bundle info or data type info cache a more concrete code flow for this could be:
EntityTypeBundleInfo::getAllBundleInfo()
gets called by somethingEntityTypeBundleInfo::getAllBundleInfo()
does$this->bundleInfo = $this->moduleHandler->invokeAll('entity_bundle_info')
so that the static cache is no longer emptyEntityTypeBundleInfo::getAllBundleInfo()
builds the bundles for thenode
entity type and calls$this->entityTypeManager->getStorage('node_type')->loadMultiple()
for that purpose. At this point the bundles for thetaxonomy_term
entity type have not been built. (The order here is unspecified so let's just assume this to be the case in this example.)- Some module calls
$nodeType->getTypedData()
inhook_node_type_load()
. (See above for real-world scenarios.) - This calls into the
TypedDataManager::getDefinitions()
viaEntityBase::getTypedDataClass()
andTypedDataManager::hasDefinition()
- The typed data discovery calls
EntityDeriver::getDerivativeDefinitions()
EntityDeriver::getDerivativeDefinitions()
callsEntityTypeBundleInfo::getBundleInfo()
which callsEntityTypeBundleInfo::getAllBundleInfo()
. Since$this->bundleInfo
was set to something non-empty in 2. above, it is returned directly. This is repeated for every entity type untiltaxonomy_term
is reached- Since the bundle info for the
taxonomy_term
entity type is empty, only theentity:taxonomy_term
data type is derived, but notentity:taxonomy_term:tags
TypedDataManager::getDefinitions()
now caches the list of data types withoutentity:taxonomy_term:tags
- The outer call to
EntityTypeBundleInfo::getAllBundleInfo()
now resumes building the bundle info for all entity types including bundles fortaxonomy_term
- The entire (correct) bundle info is cached
At this point, given that the entity type bundle info will (correctly) return the tags
bundle for the taxonomy_term
entity type, it is valid to expect \Drupal::typedDataManager()->getDefinition('entity:taxonomy_term:tags')
to work. That will, however, yield a PluginNotFoundException
.
Steps to reproduce
- Create a module that calls
$nodeType->getTypedData()
inhook_node_type_load()
- Clear the bundle cache
- Fetch the bundle info before the data type info is built**
- Call
\Drupal::typedDataManager()->getDefinition('entity:taxonomy_term:tags')
** If the typed data info is built first, that will call into the entity type bundle info and that will trigger the code flow described above. However, the outer call to TypedDataManager::getDefinitions()
will then receive the correct bundle info from the (outer) call to EntityTypeBundleInfo::getAllBundleInfo()
and it will overwrite the incorrect cache entry set by the inner call to TypedDataManager::getDefinitions()
with the correct data. Thus, in the end everything is correct.