Problem/Motivation
Currently to declare a bundle class you have to implement hook_entity_bundle_info_alter() and set the class for each bundle. If you have many classes scattered across many modules you need to implement this many times. This can also lead to many use statements in your .module files to import all the classes just to reference the class name.
It would be a nicer experience to simply place an attribute on the bundle class.
#[Bundle(
entityType: 'node',
bundle: 'article',
label: new TranslatableMarkup('Article'),
)]
class Article extends Node { }
Steps to reproduce
N/A
Proposed resolution
The Bundle Class Attribute module provides a proof of concept using plugins.
Such a simple feature could exist in core.
3 different proposed approaches for discovery
MR 5178: Treat bundle classes as plugins with its own plugin managerMR 1545: Introduce a compiler pass to discover bundle classesMR 14763: Have EntityTypeManager discover bundle classes and add to entity definitions in ::findDefinitions- MR 14764: Leverage existing entity plugin discovery and treat bundle classes as derivatives
Remaining tasks
- Determine if this is something we want in core
- Determine if there is a better approach
- Implement the core solution
User interface changes
N/A
API changes
Bundle classes can be declared via attributes.
Data model changes
N/A
Release notes snippet
TBD
Issue fork drupal-3301682
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
efpapado commentedAgree 100%, this would be very useful.
Comment #3
dpi+1
Just like regular entity classes, we'd need to commit to a namespace (subdir).
Currently with entity bundles, and with this, I'd like to be able to have profile/modules which contain multiple entity types in an organised fashion. E.g src/Entity/Node/Article.php, src/Entity/Term/Tag.php
So supporting
Entity/should be fine, so long as we natively support recursion.Comment #5
danielvezaBig +1. Reduces boilerplate scattered between different files.
Comment #6
mstrelan commented#3 might not be necessary if we adopted something like https://github.com/olvlvl/composer-attribute-collector.
Comment #8
avpadernoComment #9
mstrelan commentedComment #11
mstrelan commentedNeeds work for @todo comment in system_entity_bundle_info_alter. Likely need to refactor \Drupal\Core\Entity\EntityTypeBundleInfo.
Comment #12
mstrelan commentedComment #13
smustgrave commentedLeft a comment on the MR.
But new service seems like something that could use a CR, examples always welcome.
Comment #14
mstrelan commentedComment #15
geek-merlinAlso, after having groked hook attribute discovery in a compoler pass, this should also run in a compiler pass (not as plugins).
Comment #16
dwwNice! Love it. Not sure how I missed this issue, but glad to see it mentioned in Slack just now.
Comment #17
mstrelan commented@geek-merlin any chance you'd be able to put up an MR with that approach, even if it's only a WIP / POC?
Comment #18
geek-merlinI doubt that i'll have time soon-ish, but how i'd approach it:
- In ServiceProvider, register a CompilerPass
- In CompilerPass, iterate classes in the blessed directory, check attributes, then set the collected classes as a constructor parameter of BundleClassAltererHooks service
- In BundleClassAltererHooks, alter the bundles
Comment #19
larowlanComment #20
larowlanComment #21
berdirWith Hooks, we now have an implementation in core that doesn't use plugin discovery to find stuff, we could something like that here as well? Could then also be inlined into the EntityTypeBundleInfo, that would also make it clear that you're not meant to use this directly as an API.
The discovery should probably make sure that it's not possible to register multiple classes for the same bundle. Can also happen with the alter hook, where it's much harder to detect, but at least here we could?
What about subclasses? Lets say a contrib module defines a node type and adds a bundle class for it. In a custom project you want to subclass and change that. I guess you still have the option to use the alter hook then and set it there.
Comment #22
dpiIts possible to accept this, I've looked into and addressed it for BCA. This model works for particular upstream-downstream codebases.
Maybe you'd be interested in reviewing #3459534: Determine which bundle class should take effect when multiple classes extend the same bundle and associated PR.
Comment #24
mstrelan commentedAdded a new branch (MR !11545) that adds a container pass for collecting bundle classes and registers them in EntityTypeBundleInfo. It's very rough, so I'm looking for advice on how to improve it, e.g. with caching and just general improvements, as well as pointing out everything I did wrong.
Comment #25
godotislateAdded comments to the MR with a few thoughts.
Comment #26
larowlanComment #29
godotislateI decided to explore a slightly different approach, because I think it might be better for the entity type manager to pick up the bundle class info in its definitions than to have that information processed at container compile time. (It also saves from increasing the container size.) I borrowed a lot from work @mstrelan did in the other MRs, and did the new work in MR 14763.
Originally my thought was to use a discovery decorator and implement
getDiscovery()inEntityTypeManager, with a decorator class that would wrap the default discovery and then add bundle class information into each entity type definition. Unfortunately that doesn't really work, because inEntityTypeManager::findDefinitions(), it's possible to add entity type definitions after discovery inentity_type_buildhooks. So I changed the discovery decorator to anAttributeBundleClassDiscoveryclass that does discovery by attributes and sets the bundle class information on each entity type definition after theentity_type_buildhooks are invoked, but before theentity_type_alterhooks run.I considered whether everything in
AttributeBundleClassDiscoveryshouldn't just live directly in EntityTypeManager, or ifAttributeBundleClassDiscoveryshould be a separate service, but I don't have strong feelings which way to go with it.I also changed the discovery directory for bundle classes from
/Entityto/Bundle, because I think there are performance issues in play for entity type discovery and bundle class discovery when they live in the same directory. This may be minimal if there are not a large number of bundle classes anyway, so I don't feel that strongly about it.Anyway, once the entity type defintions have bundle class information stored, then extracting them in
EntityTypeBundleInfo::getAllBundleInfo()is pretty straightforward.Comment #30
godotislateComment #31
godotislateI guess one other thing to consider, regardless of discovery approach, is if two modules have #[Bundle] classes for the same entity bundle. Which should win? Should module weight factor into it?
Comment #32
mstrelan commentedThanks for working on this! I haven't looked at the MR, but in regards to #31, it's the same issue if you define these via hook_entity_bundle_info_alter ... The last one wins. So module weight would be one way, or we could have a priority like event subscribers.
Comment #34
godotislate@ghost of drupal past proposed a fourth idea: Use the existing entity type plugin discovery and treat bundle classes as derivatives. This approach is in MR 14764.
Basic idea is:
'{entity type id}:{bundle id}'. This is facilitated by the Bundle attributegetDefinitions()getBundleDefinitions()onEntityTypeManagerInterfaceis introduced to get only the derivative definitionsI think this approach has an obvious advantage in that it uses an already existing iterative attribute discovery and does not introduce a new one.
Comment #36
godotislateI think the derivatives approach in https://git.drupalcode.org/project/drupal/-/merge_requests/14764 is the best idea, and much better than my idea in https://git.drupalcode.org/project/drupal/-/merge_requests/14763, so I'm taking the latter out of the running. Back down to three.
Comment #37
mstrelan commentedTo be honest I'm not really bothered about the implementation, as long as the attribute works. In theory the implementation could be marked
@internaland swapped out without breaking anything. Happy to run with the derivatives approach. The one thing I'd like to see is test coverage for two modules defining the same bundle class, or even two classes in the same module. That has a valid use case and has been a feature request for BCA in contrib. Even if we don't provide a way to set the weight/priority now, at least it should be documented. Setting NW for this.Comment #38
godotislateSince this approach leverages plugin discovery, it's basically last one discovered wins, where last is determined the order of
container.namespaces. We can document this.Thought I thought of a weird way to implement priority. A integer
priorityargument with default value 0 can be added to the attribute. This gets appended to the derivative plugin ID, e.g.node:blog:100. For every matching class largest priority wins, and otherwise for those with the same priority, it's last one discovered wins. The would be determined in thegetBundleDefinitions()method onEntityTypeManager.Comment #39
ghost of drupal pastNah, it's not worth introducing a whole new syntax for that, it's not different to any other plugin as you mention. Like, what happens if two modules define the same entity type or two formatters etc. May I ask why is this seen as a big problem? Did it come up already among users of Bundle Class Attributes?
Comment #40
mstrelan commentedThe use case I've seen is an install profile (e.g. for a university) provides a bundle class for common bundles, but some sites extend the bundle with additional fields using config_split and have their own bundle classes that provide additional methods. We need to use the alter hook to ensure the site-specific class has priority.
Comment #41
ghost of drupal pastCould the site specific site bundle be in a module -- perhaps even a standalone module just for this? -- with a weight of 1001? The profile has a fixed weight of 1000 is set in InstallerKernel and ModuleExtensionList and install.core.inc all. Someone needs to file an issue to extract it into a constant and document it on module_set_weight at least. https://www.drupal.org/docs/develop/managing-a-drupalorg-theme-module-or... documents the weight too.
Comment #42
mstrelan commentedYeah module weight is one option
Comment #43
catchNote that #2968232: Deprecate module weights is trying to deprecate module weights, but also:
1. Profiles shouldn't be implementing runtime code any more, we're trying to deprecate that too (slowly), otherwise they can never be uninstalled.
2. For that specific use case, using an alter hook seems reasonable.
Comment #44
godotislateI wasn't sure whether
container.namespaceswere sorted respecting module weight (looks like it is after checking), but per #43, that is moot.I'll document that the way to specifically choose one bundle class over another for the same entity bundle is using the alter hook.
Comment #45
mstrelan commentedI neglected to mention the base classes are in modules provided by the profile, so no issue there. I agree the alter hook seems reasonable. Once the documentation is added as per #44 then it can go back to NR.
Comment #46
godotislateOK, updated the MR:
{entity type ID}:{bundle}to prevent collisions with actual entity type definition IDs, but otherwise they are not used. This will make it easier to change if there's ever a different use case for entity type plugin derivativesAdded a CR: https://www.drupal.org/node/3574891
Separately, I ran into a bug in the
EntityType/ContentEntityType/ConfigEntityTypeattribute classes, where theuri_callbackproperty type isstring|null. Since the property is supposed to have a callable value, this typing prevents setting array callables of [class name, method]. This is a bit more complicated because PHP does not allow the use ofcallableas the type of class properties, so I'm not sure what the fix would be, other type changing the type tostring|array|null. Any others? I don't know if \Closure should be allowed, because I think there might be times when entity types are serialized? In any case, this seems to be a little used feature and hence why no test coverage has picked it up.Comment #47
catchFor uri_callback see #2667040: Deprecate uri_callback in routes for entities, not sure if we can skip adding support for it here entirely while we also remove it over there?
Comment #48
godotislateRe: #47
I'm fine with skipping it, since the feature seems so little used, and the deprecation issue seems really close.
That said, if we skip it and for some reason it doesn't get deprecated, and there's a future need to add it to the attribute, then there's an issue of contrib perhaps not being able to support multiple versions, since passing arguments with named parameters that don't exist results in fatal errors. See https://3v4l.org/p2IWT.
While it seems
uri_callbackwill be ever be needed, it does apply generally about how to add constructor arguments to attributes, since they're often instantiated with named parameters. One idea I've had is to add a variadic argument, like this:So then named parameters can be used with different versions that do and don't have the argument defined. It's not a perfect solution, because the undefined parameters will need to be passed last.
Comment #49
ghost of drupal pastinstead of variadic pass in $others = [] and then you can do $others['uri_callback'] should push come to shove ; variadic afaik can't be named but this way it can be
also document that this argument must be passed as a named argument so that future additional named arguments can be added if needed before it
Comment #50
godotislateIt can, but maybe we mean different things, so I'll provide an example.
Say in core 12.1, there's a new attribute
Featurethat looks like this:And contrib modules start using it like this:
Then in core 12.2, let's say
$secondis deprecated and a new argument$thirdis added, so like this:The contrib module can add the
thirdparameter and have one version compatible with 12.1 and 12.2.Then in core 13.0, where
secondis removed:Contrib can have a single version compatible with
12.1, 12.2, and 13.012.2 and 13.0 like this:And actually,thirdcould be placed beforesecondas early as it was known thatsecondwas deprecated andthirdadded.Edited: Can't have compatibility with both 12.1 and 13.0, because
thirdwould need to be last for 12.1, andsecondlast for 13.0.Edited x2: Since
secondwas deprecated in 12.2, perhaps it could just be dropped in the last version.Comment #51
godotislateRemoved support for
uri_callbackin the MR.With some more testing, ran into a couple issues with this approach:
::get(), anEntityTypeIdLengthExceptiongets thrown if the ID is too longentity_type_buildandentity_type_alterhooks. This is probably not a big dealComment #52
godotislateMade some changes to address everything in #51. I think this is ready.
Comment #53
godotislateAll MR feedback should have been addressed now.
Comment #56
mstrelan commentedClosed the earlier MRs. Updated the CR to remove references to uri_callback and fix some grammar and typos.
Comment #57
godotislateThanks @mstrelan for the clean up on the CR. Do you think this is good for RTBC, or does it another review?
Comment #58
acbramley commentedI would mark this as RTBC, but have one question - do we need test coverage for overriding an attribute based bundle class using hook_entity_bundle_info_alter?
Other than that everything is looking good. I also reformatted the CR a bit, removing headings and fixing some formatting issues.
Comment #59
mstrelan commentedI'm happy with this in regards to the MR. I don't feel I should RTBC this as I worked on this initially. I also haven't manually tested this at all, but I trust the test coverage here. Would be good to test on a real project though.
It would be also good to manually test on a project that is using BCA contrib module already. Since that uses
hook_entity_bundle_info_alterI would think classes using the attribute from BCA would win. The only steps to migrate from BCA to core would be to replace the namespace of the attribute, and if using named parameters, replaceentityTypewithentity_type_id. We could consider matching that parameter name to make it easier, but not a big deal if we don't. Rector should be able to handle this.Comment #60
godotislateAdded alter hook test per #58 and parameter rename per #59.
Comment #61
acbramley commentedAwesome, thanks @godotislate! Also learned that we can have hooks in test classes from that :)
Comment #62
godotislateYeah, in kernel tests since #3502432: Make hook testing with kernel tests very simple!
Comment #63
berdirI haven't read all the comments, but I understand you can define bundle classes through this from scratch without anything else?
I think that's problematic, at least for entity types that use bundle_entity_type. node bundles should all be node_type config entities, the type is defined as an entity reference field to with the node_type target type. Having a type that does not match that will result in validation issues and likely breaks code that expects to be able to do node->type->entity and things like that.
I understand that you could do the same with hook_entity_bundle_info too, but this explicitly advertises the ability to do so and I think we should consider to disallow that.
The change record also has a slight typo, bundle classes don't implement EntityTypeInterface (that's the definition object) but EntityInterface.
Comment #64
godotislateMade the change from
EntityTypeInterfacetoEntityInterfacein the CR.As for defining bundles in code: I think @ghost of drupal past mentioned that he had bundles created completely in code on a client project, and that's where he got that from. Though I guess we can edit the documentation to remove mentions that this is possible.
Comment #65
berdirYes, I think its' neat if it's the only type of bundles you have. But mixing them from different sources sounds like a bad idea. We could add a check and specifically verify that it's not an entity type with bundle_entity_type?
Comment #66
godotislateFair enough, that doesn't sound too difficult.
Comment #67
ghost of drupal pastThanks, that sounds like a great idea, as far as I can see it's just:
Comment #68
godotislateRe #67:
Change there looks good, but a question came up while I was testing. I hit the exception right as I deleted the bundle entity, which is expected, but then that means that the bundle class needs to be removed before the bundle entity can be deleted.
Should we throw an exception or just do nothing, i.e.
Comment #69
berdirI was wondering about that yes, both removing but also the opposite, when you deploy a new node type as part of a config import or so.
I think silently ignoring those is fine considering those edge cases.
Comment #70
godotislateMR and CR updated per #65 - 69. Back to NR.
I also restored a missing commit that addressed previous MR comments.
Comment #71
berdirThanks for addressing that. I fixed a typo in the CR (it's new_bundle, not new_class in the example) and tried to clarify that paragraph a bit.
Comment #72
godotislateSomeday I will write or edit a CR without typos or mistakes, but that day has not yet come.
Comment #74
catchHave opened this a few times and neither committed it nor found anything wrong with it either, gave it another end to end look now and I still can't see anything to complain about. Nice to get rid of even more boilerplate and the code change is pretty compact.
Committed/pushed to main and 11.x, thanks! Tagging for 11.4 release highlights.
Comment #79
berdirThis is causing problems for us in linkit, see #3596529: LinkitHelper::getEntityFromUri('https://'... triggers an exception in 11.4, not sure if core should throw that exception. maybe it should respect $exception_on_invalid?