Problem/Motivation
Content types are essentially business objects, but we have no standard ability to modify or subclass them to add business logic, which therefore gets scattered among helper services.
This issue introduces the concept of "bundle classes" which are subclassing the current entity classes such as \Drupal\node\Entity\Node. A bundle class is defined using the class property of the entity type bundle info, so modules can define bundle classes for their own entities using hook_entity_bundle_info() and can alter bundle classes of entities defined in other modules with hook_entity_bundle_info_alter().
For example a custom module could provide a bundle class for a node type like this:
use Drupal\mymodule\Entity\BasicPage;
/**
* Implements hook_entity_bundle_info_alter().
*/
function mymodule_entity_bundle_info_alter(&$bundles) {
$bundles['node']['page']['class'] = BasicPage::class;
}
The bundle classes themselves extend the entity class:
namespace Drupal\mymodule\Entity;
use Drupal\node\Entity\Node;
class BasicPage extends Node implements BasicPageInterface {
// Implement whatever business logic specific to basic pages.
public function getBody() {
return $this->get('body')->value;
}
}
The entity type manager will always return bundle classes if possible, so we can rely on the data types rather than having to call $entity->bundle():
$entity = $this->entityTypeManager->getStorage('node')->load($id);
if ($entity instanceof BasicPageInterface) {
// Some logic which applies only to basic pages.
)
For further explanation of the numerous benefits of this approach, and some real-world examples of how it massively improves the DX, see comment #128.
Remaining tasks
- Reviews / refinements.
- RTBC
- Commit.
User interface changes
None
API changes
- Two new exception classes added:
Drupal\Core\Entity\Exception\AmbiguousBundleClassExceptionDrupal\Core\Entity\Exception\BundleClassInheritanceException
- Added
Drupal\Core\Entity\ BundleEntityStorageInterfacewhich defines agetBundleFromClass()method. - Added
Drupal\Core\Entity\ContentEntityStorageBase::getBundleFromClass()implementation. Drupal\Core\Entity\ContentEntityBasenow implements apublic static create()method.- code>Drupal\Core\Entity\ContentEntityStorageBase now implements a
public static create()method. - Added a getEntityClass() method to
Drupal\Core\Entity\EntityStorageInterface
Data model changes
None
Original summary by larowlan
Content types are essentially business objects, but we have no standard ability to modify or subclass them to add business logic, which therefore gets scattered among helper services.
We can't safely modify the defaults under the current system, because we have lots of places where entity storage determines the entity class using $this->entityClass. If something like Entity Bundle Plugin were to try and sub-class sql content entity storage it would need to override each of these calls.
Example use case
Details generalized from a real past project: I'm building a website for a popular trading card game. Each trading card has its own page, displaying its type (character, trainer, or special), properties such as hit points and attack strength, and (in the case of characters) evolution chain. I want to be able to encode specialized business logic in entity class methods I can call on the theme layer and elsewhere to do things like getting the node ID of the next card in a series or generating an evolution chain. I want this logic centralized in the business object--not scattered across integrating subsystems--as a matter of code quality and DX. To illustrate the difference, if I can extend Node with CharacterTradingCardNode and add an evolutionChain() method right on it, I can embed the business logic right in the class and directly access it anywhere I have that type of node, such as a Twig file. If not, I have to create a separate service for the business logic and inject its output into the context I want to use it in.
Pseudocode with Subclassing (The Proposal)
<!-- node-character_trading_card.html.twig -->
<ul>
{% for evolution in node.evolutionChain %}
<li>{{ evolution.id }}</li>
{% endfor %}
</ul>
Pseudocode without Subclassing (The Present)
# mytheme.theme
function mytheme_preprocess_node(&$variables) {
if ($variables['node'] && $variables['node']->bundle() === 'character_trading_card') {
$evolution_chain_generator = \Drupal::service('mymodule.evolution_chain_generator');
$variables['evolution_chain'] = $evolution_chain_generator->generateChain($variables['node']);
}
}
<!-- node-character_trading_card.html.twig -->
<ul>
{% for evolution in evolution_chain %}
<li>{{ evolution.id }}</li>
{% endfor %}
</ul>
Proposed resolution
Put access to the entity class behind a protected method, passing the bundle when available.
This is generally considered better for refactoring too.
Release notes snippet
Drupal 9.3.0 introduces the ability to define "bundle classes" for all entity types. These are bundle-specific subclasses of the parent Entity class that can encapsulate business logic, expose functionality directly to Twig templates without preprocess layers, and significantly improve the developer experience (DX) for site builders. See the change record for more details.
| Comment | File | Size | Author |
|---|---|---|---|
| #227 | 2570593-227-unofficial-8.9.x.patch | 29.36 KB | siggy |
| #209 | 2570593-209.patch | 57.99 KB | dww |
| #164 | 2570593-164.9_3_x.patch | 41.53 KB | dww |
Issue fork drupal-2570593
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:
- 2570593-bundle-classes
changes, plain diff MR !200
- bundle-classes-9.3.x
changes, plain diff MR !994
Comments
Comment #2
larowlanComment #3
jibran+1 to this.
Comment #4
berdirHm. Kind of yet another variant of #1867228: Make EntityTypeManager provide an entity factory, a factory class would allow this too. But this is definitely a lot smaller.
Comment #5
plachAnd it's also totally compatible with #1867228: Make EntityTypeManager provide an entity factory, so I guess no problem with going this way for now.
Missing @param docs.
Comment #8
larowlanA good use case for this #2720283: add plugins to Contact form types, remove special-casing for 'personal' contact form type
Comment #9
joachim commentedWhile I don't disagree with the change in this patch per se, I don't think that being able to change an entity's class is a complete or a correct solution to issues like #2720283: add plugins to Contact form types, remove special-casing for 'personal' contact form type, or for configurable and swappable functionality for entities in general.
First reason is that with swapping an entity class, your new class has to inherit the entity class, or implement its interface. That seems like a lot of work. Also, if you want the functionality to be selectable in the UI, the swappable entity classes need to be plugins. Having classes which implement entity interfaces and are also plugins seems messy to me.
Secondly, and more importantly, is that entities might want more than one swappable behaviour, and swapping just the class doesn't allow that. For example, in Flag module, we have a Flag config entity. That can take two plugins: one the represents the entity type the Flag is on, and one that provides the type of link the Flag uses. (And we might add a config property that holds a second copy of the Flag link type, so you can have a different link type for flagging and unflagging.)
Lastly, different entity types will have vastly different needs from swappable functionality. For Contact module, there's a lot of special-casing for the 'personal' form that should be moved to a plugin, eg, overriding the Contact form type listing row, denying access to the sitewide form for that config entity, setting the message recipient, and so on. For Flag, the things we want the plugin to be able to change or control are completely different. It's not one size fits all, so it's best to leave each entity class to work in its own way with the plugins it uses.
What might be useful though is seeing if there are any ways in which the DX for creating an entity - plugin combination could be improved.
Comment #13
bart vanhoutte commentedWhen it comes to having per-bundle Entity classes or adding custom behavior to an Entity I think it's better to use composition instead of inheritance. There are going to be scenarios where two modules are going to want to add behavior to the same bundle or Entity Type (User for example).
I think it would be best to create Entity behaviors through the Plugin system and ultimately make something like this possible:
A disadvantage of this is that manual type hints will need to be inserted to get autocompletion, but I think the pros outweigh the cons.
Comment #14
markhalliwellActually, that's a pretty big disadvantage. I heavily rely on an IDE (PHPStorm) which automatically indexes and identifies when syntax is deprecated, methods or parameters are missing, class no longer exists, etc.
Quite simply, having the ability to specify a custom class for any entity bundle/type, which can inherit from the entity class, and have its own methods unique to that bundle is far more beneficial than having to obfuscate this kind of functionality behind yet another plugin.
In fact, this "plugin" would really be a wrapper. A wrapper that would require the entity to be referenced constantly; not to mention all the type hinting that would need to be everywhere.
It's the difference between:
vs. a far cleaner and more human readable DX:
I've worked on a re-roll, will upload it shortly.
Comment #15
markhalliwellComment #16
markhalliwellComment #17
joachim commented> In fact, this "plugin" would really be a wrapper. A wrapper that would require the entity to be referenced constantly; not to mention all the type hinting that would need to be everywhere.
I don't see it as a wrapper at all.
It's a pluggable helper object. Exactly the same as all the entity handlers (storage, list builder, views data, routing, etc). We're fine with the DX for those, aren't we?
Comment #19
markhalliwell$article->getEntity()->save();, specifically->getEntity()is what would make it a "wrapper".No, those are services which consume entities (which are already plugins).
---
This issue is, primarily, to allow said entity plugins to be subclassed from their original parent class.
Allowing for new, specific to the bundle, functionality that could be added and inherited methods to be overridden.
This functionality cannot be done as an external "behaviors" plugin, which is what you are proposing (and could be useful in its own right).
Comment #20
tim.plunkettThis is confusing enough without mixing up terminology.
\Drupal::entityTypeManager()->getDefinition('node')is the Node entity type. That's a plugin.\Drupal\node\Entity\Node::load(7)is a Node entity. That is NOT a plugin.See #22 :)
Comment #21
markhalliwellMeh, semantics. The node entity type plugin is also the class used to create the node entity object itself.
Comment #22
berdirActually, that is wrong :)
\Drupal::entityTypeManager()->getDefinition('node') is the plugin *definition*, aka the annotation class basically. \Drupal\node\Entity\Node *is* the plugin class, it's just that entities use weird and non-standard mechanisms to create plugin instances.
Comment #23
tim.plunkettToo true, Berdir.
Also, "meh semantics" is not really a great reply when you're discussing changing how an entire subsystem works.
Comment #24
markhalliwellI only said that because I didn't want to get into a title war over how some may perceive "entity types" vs "entities" as plugins. The reality is that entities, while they don't use the normal plugin manager mechanism, are plugins as @Berdir confirmed in #22. Also, this isn't really about the "entire subsystem", it's a very small part of that subsystem allowing it to be extended just a little more.
Comment #25
markhalliwellComment #26
markhalliwellHere's an 8.4.x version of #25.
Comment #28
joachim commented>
$article->getEntity()->save();, specifically ->getEntity()is what would make it a "wrapper".> This issue is, primarily, to allow said entity plugins to be subclassed from their original parent class.
I think you're coming at this from the wrong side.
You wouldn't need to do that.
Your entity class's save() method would check for the plugin & call through to that, if your entity type is such that you need to have your behaviour plugin affect the save process.
Essentially, using plugins for entity behaviour is composition, whereas what you're proposing here is inheritance.
Comment #29
markhalliwellNo, I'm not.
This is a legitimate use case: gain the benefits of all the work that has gone into a "node" entity, but want to add helper methods or override existing methods on the entity itself based on a specific bundle.
This means that functionality is technically determined by the "behavior" itself and thus requires the entity to perform these checks in each "supported methods" before proxying to said behavior. Again, not the use case here.
Which is an entirely separate feature/use case (and could very well be used in addition to this), but not what this issue is about.
Correct, which is what this entire issue has been about: don't use
$this->entityClassdirectly, abstract it so it can be overridden when needed or desired.---
Re-assigning to myself to fix tests.
Comment #30
markhalliwellComment #31
markhalliwellSigh, a rouge .orig file made it in the 8.4.x patch.
Comment #32
markhalliwellComment #33
joachim commentedThe issue summary doesn't actually say what the use case is...
Comment #36
traviscarden commentedPer @webchick's request, I've added an example use case from a recent project to the issue summary. It's a little different from the use cases that have been mentioned in the comments, but if I understand correctly it's in the same spirit and implies the same solution.
Comment #38
traviscarden commentedAdding some pseudocode to the example per @webchick's request. The proposal pseudocode reflects the approach we took on my project, for which we used https://github.com/amcgowanca/discoverable_entity_bundle_classes. We were very happy with the outcome. It certainly delivered the benefits argued for in this issue. If we had it to do over again, I think we'd take the same approach. We wouldn't create a separate entity type because the entities should appear in the
admin/contentview, which they wouldn't if they weren't nodes.Comment #39
markhalliwellI'm not entirely sure when @webchick commented on this issue, but the use case is quite simple: leverage Node entities (and all the stuff that's baked into that: UI, revisions, translations, etc.) while providing bundle specific classes for extra custom functionality (as needed).
We've been using this patch for https://quo.tag1consulting.com to completely refactor all of our custom procedural based "alter hooks" and tons of "if/switch" statements (based on ~8 bundle types) to simply abstract the unique code (which is a lot) into their own dedicated classes.
It just offers the ability for our code to be more OO and testable.
Comment #41
chi commentedComment #42
chi commentedComment #43
chi commentedComment #45
chi commentedWorkspace module uses hook_entity_load() to swap revision however that change remains within EntityStorageBase::postLoad() scope because we pass to the hook wrong variable.
Comment #46
chi commentedComment #47
chi commentedI suppose patch in #42 did not fail because in 8.7.x Workspace relies on hook_entity_preload() #928888: Please create a release.
I applied the same fix as in #46.
Comment #49
deviantintegral commentedHere's an update that:
getEntityClassesmethod inEntityStorageBaseto clean up some duplicate code.EntityTypeRepository. I discovered this by refactoring out some methods. I'm not entirely happy with the$same_classpass-by-ref, so let's see if tests pass first. There also needs to be a test added for this case.Comment #51
deviantintegral commentedI misunderstood how the class matching was working (thanks tests!), so this restores only checking for subclasses if an exact match is not found.
Comment #53
mlncn commentedThis is working wonderfully!
Comment #54
mlncn commentedAnd so here's a real life use case for how it is used, with nodes to use a subclass of Node for a given content type (bundle):
In a
.module:In
src/FinditOpportunityNodeStorage.php:In
src/FinditOpportunityNode.phpAnd now anywhere we need to ask "is this opportunity stale" i can use this method, including in Twig templates:
{% if node.isPublished() and node.isOutdated() %}
Warning! This is old!
{% endif %}
Comment #55
jonathanshawThe test fail is genuine (the patch uses deprecated code) so can't be RTBC. But fixing that is a novice task.
Comment #56
d70rr3s commentedFixed test
Comment #57
d70rr3s commentedMy bad I generated the patch using a codebase instead a clean checkout from the repository.
Comment #58
jonathanshawComment #59
jonathanshawThe patch (since #25) adds public methods to ContentEntityStorageBase but not to ContentEntityStorageInterface. At the least this might need a comment or discussion?
Comment #60
markhalliwellAdding a new method to an existing interface is a BC break. Not sure if this is explicitly documented anywhere, but I think it's mostly considered general knowledge by now. Regardless, it is documented that a public method not specified in an interface is considered
@internal. Unclear if it actually needs an@internaltag though (seems a little overkill).We could add a new interface and implement it on
ContentEntityStorageBase. That actually might be better now that I think about it because then code can detect whether an entity storage instance supports sub-classible entities by checking if it's an instance of new said interface.Comment #61
gbirch commentedI have no comments on the details of the implementation, but just want to express my enthusiasm for this proposal. The ability to easily subclass node bundles is wildly helpful in all sorts of scenarios. The ability to implement custom pre and postSave hooks directly in a dedicated entity class is worth the price of admission alone!
Comment #62
eaton commentedJust ducking in to add a vote of approval for the concept being advanced, here. The general approach — using the generic Entity class by default but allowing config to specify a custom subclass for each entity type — is how the Timber framework for Wordpress operates. On smaller projects it's been a godsend.
Cleanly exposing entity-type specific business logic to Twig, rather than building helper libraries that exist in parallel to the entity types, is a big boon and eliminates a lot of very nasty special-casing that often migrates into the preprocess and template layer by necessity. It also keeps said code in a place that's easier to expose in APIs and non-HTML output types.
Comment #63
e0ipsoI wrote an article (https://www.lullabot.com/articles/maintainable-code-drupal-wrapped-entities) recently where I mentioned this issue.
I mention some nuance in the article that may be relevant for this discussion:
The rationale is that adding more API surface to entity classes to, also, deal with business logic goes against of the single responsibility pattern.
In any case this patch will improve the current state of things, so big +1 from me.
Comment #64
gbirch commentedIn response to @e0ipso,
1) That's a great article.
BUT
2) The reality is that one often needs to attach all kinds of business logic to the existing insert / pre-save / post-save / delete processes. At present, we do this with hooks, which is inelegant, confusing, and often hard to debug. Using a wrapper to add new and different functionality to a node is great. But when it comes to altering the logic used in existing methods, a wrapper does not seem to help. The wrapper approach also adds the DX problem mentioned in #14 and elsewhere.
Comment #65
pfrenssenThe issue summary is outdated, it doesn't reflect the current state of the ticket any more. Also tagging for a change record, it would be nice to include a real life example in it.
Comment #66
pfrenssenI've tried this out and this is really exciting, it is a game changer for working with entities, awesome work!
Reviewed the patch:
I don't see a reason that this should be a public method, it is internal logic for
ContentEntityStorageBasewhich is called during the creation of an entity.This can be a static method.
Maybe
Indexes the given array of entities by their class name and ID.?Is there a reason that the
&$entityvalues are returned by reference while iterated over? We are constructing a new array and not modifying the entities. Is there some performance benefit by doing this? My understanding is that in PHP objects are always passed by reference.This could use some dedicated test coverage, to ensure that the exception is thrown correctly in case multiple classes implement the same bundle.
Comment #67
pfrenssenUpdated issue summary.
Comment #68
pfrenssenComment #69
eiriksmWorking on addressing #66 and #60
Comment #70
eiriksmThis is so awesome! I can not wait to start using this!
#60: I now added a new interface (SubClassableEntityStorageInterface) and implemented this on ContentEntityStorageBase.
#66:
1: I do not see that either. Changed to protected.
2: Fixed. Can see that maybe another storage would want to have it non-static though?
3: Good suggestion, changed to that.
4: Agree with you. I don't see why either.
I did not address #5 because I have to run. Leaving at needs work for now. Will pick it up tomorrow if no-one does before me :/
Thoughts/reviews on the changes are still very welcome though
Comment #71
eiriksmOops, forgot the files :D
Comment #72
markhalliwellInstead of
SubClassableEntityStorageInterface(which seems rather abstract), perhapsBundleEntityStorageInterfacewould be better considering that is what the methods it defines is in reference to.edit: I know this might seem like a nit, but... "naming things"--
Comment #73
eiriksmFair point!
I actually came up with the name before I realized what the methods would all reference and never circled back. Just for the whole background :)
Comment #74
eiriksmChanged the class to BundleEntityStorageInterface and added a test for #66.5
Comment #75
johnwebdev commentedWouldn't this approach make it hard to let more than one module subclass a bundle for the same entity type?
Comment #76
berdirI didn't review the patch yet, but agreed. Imho this should be put into the bundle info, which is currently a simple array with an alter hook, then anyone can set a class there.
\Drupal\Core\Entity\EntityTypeBundleInfo::getAllBundleInfo(), then we have alter hooks and so on already available.
Comment #77
pfrenssenGreat idea! Assigning to change this to use
hook_entity_bundle_info().Comment #78
pfrenssenHere is the minimal implementation to make the test pass. Since we now define the bundle classes in the entity type bundle info there is a possibility to have multiple bundles that define their own bundle class, so I have introduced a new
AmbiguousBundleClassExceptionalongside the existingAmbiguousEntityClassException.I am going to see if we now can simplify the changes in the
ContentEntityStorageBase.Comment #79
pfrenssenUpdated issue summary with a new example of how to define a bundle class using
hook_entity_bundle_info_alter().Comment #80
pfrenssenShedding some weight, and documenting the new
classproperty for the entity bundle info hooks + providing an example.Comment #82
pfrenssenSo there is a good reason to check the entity classes and bundle classes separately, since an entity class can subclass another entity class. This is e.g. heavily used in the
entity_testclasses who all inherit each other.Comment #83
pfrenssenThe patches of #78 and #82 are equivalent, I tried to simplify the code in #80 but it is in fact necessary to detect the entity classes and bundle classes in separate loops. The patch in #78 solves it by providing two separate methods, while in #82 the two loops are executed subsequently in the same method, which makes it unneeded to pass a counter around by reference.
Comment #84
claudiu.cristeaThis would be a big improvement for adding business logic to entity bundles. I have some generic remarks, followed by few nits:
node.type.articlein this way:This can be achieved in
EntityTypeBundleInfo::getAllBundleInfo(). To be discussed.EntityTypeRepository::getEntityTypeFromClass()accordingly. But I think we should enforce a bundle class to extend the entity. And I think we should test this and throw an exception early, inEntityTypeBundleInfo::getAllBundleInfo().Few inline remarks:
There's a quote ending this line.
Questioning whether
EntityBaseshould "know" about the existence ofContentEntityStorageBase. I think this is moreContentEntityBase's business. Wouldn’t it be better to move the logic there?s/string/string|null
s/@ee/@see
It's a boolean expression, let's use booleans instead of integers.
s/public/protected
I'm noticing here an assumption: the bundle class *always* extends the entity class. But do we test this somewhere? See my generic comment.
Setting to Needs work for change record creation, clarifying the two main points and fixing the nits.
I'm also wondering if it's not too late for
8.9.x, this looks more like a9.1.x.Comment #85
berdir> I want to be able to define the (config entity) bundle node.type.article in this way:
That's IMHO a nice DX improvement, but it's just that, an improvement on top of the bundle info system, so it has to be optional anyway and could be a follow-up. Trying to push things into the config entity structure too early/first has resulted in some issues getting stuck, like #2765065: Allow plurals on bundle labels.
Agreed on enforcing that bundle classes need to be subclasses, otherwise BC would be impossible to support when we add new methods and so on to entity types. Nice counter-example to the "everything must be final and then all our BC problems will be resolved" argument :)
Comment #86
claudiu.cristea@Berdir
I agree that it should be optional,
::getAllBundleInfo()should test the existence of this property. However, now it's a good time to do it, because this change has a very small potential of BC break, as some existing custom bundle config entities might have defined already aclasskey for other purposes.Comment #87
johnwebdev commentedI'd also prefer to see that as a follow up to not widen the scope of this issue to much, I like that idea, but we'd still need to manage dependencies (i.e. that config would need to depend on the module providing the class?), potential BC as mentioned in #86.
Comment #88
claudiu.cristea@johnwebdev, well right now, if you define a class for bundle w/o config entity, in
hook_entity_bundle_info(), a third party module is able to swap the class, inhook_entity_bundle_info_alter(). But a bundle defined via config entity is not a 1st class citizen because its class can only be defined inhook_entity_bundle_info_alter()and, as an effect, it cannot be swapped anymore by a 3rd party.Comment #89
claudiu.cristeaFixing in this ticket or as a follow-up would require to handle the BC problem in this way:
8.9.xwhere we deprecate usage of aclasskey in bundle config entities.9.0.xthat throws an exception if aclasskey exists in bundle config entities9.1.x.Comment #90
pfrenssenAssigning, going to address the remarks. I also like the idea of being able to define the class in entity type config, but since this is still under debate I won't yet implement this. I think that the B/C break of the `class` key being already used in entity type config is quite unlikely to be a problem in the wild BTW. I also think that it would be very little work to implement it, but I'm fine if this is handled in a followup also.
Comment #91
pfrenssenAddressed all remarks from #84 except point 6 because it results in:
.
The protected modules patch (#2822382: Make every $modules property protected on classes extending BrowserTestBase and KernelTestBase) is only merged in 9.x.x.
Comment #92
pfrenssenThe patch was using "subclass" and "bundle class" interchangeably. I settled on "bundle class" since it is more descriptive than "subclass".
Comment #93
pfrenssenFixing failures.
Comment #95
claudiu.cristeaNice work, I see all the points got addressed. I have only a question and, depending on the answer, it might require a change:
I wonder if the
$storage instanceof ContentEntityStorageBasecheck really makes sense. Is there a storage of an entity, extending ContentEntityBase, that is not extending ContentEntityStorageBase?Apart from that this still needs work for the following reasons:
9.1.x. It needs reroll.I also think that this is a Feature request, rather than a Task.
Comment #96
sokru commentedJust a reroll from #93.
Comment #97
claudiu.cristeaComment #98
pfrenssenTests are failing, it seems this "Build successful" message is misleading.
Comment #99
jonathanshawThanks for all the work on this feature!
I noticed today that it makes entity deserialization more painful, in a way that highlights a certain kind of subtle BC problem here.
In my contrib use case when an entity is deleted I'm serializing it and putting it into a queue to perform some remote API cleanup later. I can't enqueue the class name (as the class might not still be installed at the time of queue execution) so I have to enqueue the bundle as well as the entity type and infer the class name at the time of queue execution. Therefore I need to additionally inject the entity bundle service and conditionally check it:
I wonder if a public method
getEntityClass($entityTypeId, $bundle)on the EntityTypeManager would be a good idea now it has got harder to identify the right class for an entity?Comment #100
pfrenssenYes I think this is a good idea, surely more use cases will pop up where it will be necessary to be able to call
getEntityClass().Comment #101
jeroentTests are failing because of this error:
Comment #103
jeroentComment #104
jeroentBack to needs work for #99.
Comment #105
jeroentWe already added a
getEntityClassmethod onEntitystorageBase. But this method is protected. I updated the access modifier so now we can call it like this:$this->entityTypeManager->getStorage('node')->getEntityClass('page')which fixes #99.Comment #106
claudiu.cristeaIf I'm reading correctly the code, the
predeleteanddeletehooks are called for each module as many times as different entity classes we have. The same is true for:I guess we only have to iterate
$entity_class::preDelete($this, $items);and$entity_class::postDelete($this, $items);. I didn't check, but this may be true also for other hooks (load, etc.)Comment #107
jonathanshawAFACIS this $keyed_entities logic should be removed here and handled in getEntityClasses() instead.
$entity_classes is produced by getEntityClasses() which actually returns an array, keyed by entity class, of subarrays of entities belonging to that class. $entitiesByClass and getEntitiesByClass() would be clearer names maybe.
So currently in the patch hook predelete and hook delete are called once per entity, which seems right.
$entity_class::preDelete() and $entity_class::postDelete() are called once per entity class, with only the entities from that class, which seems right.
doDelete() and resetCache() are called once per entity class, with only the entities from that class; this doesn't seem wrong, though it would be possible to change the code so they are called only once with all the entities. I'm not aware of an advantage to doing that.
Comment #110
pfrenssenAddressed #2570593-107: Allow entities to be subclassed using "bundle classes". As far as I can see all remarks have been addressed now.
I would also like to mention that we are using this patch in production in a large project at the European Commission for 6 months with great success.
Comment #111
pfrenssenComment #112
claudiu.cristeaWe use bundle classes for months in our project and it's charming that we can add business logic to bundles, as they are business objects. Everything looks good here, great work.
However, there are still 2 points preventing me to RTBC this:
Setting as NW to fix the above.
Comment #113
claudiu.cristeaCreated:
Comment #114
dwkitchen commentedI have found this causes an issue with Webprofiler https://gitlab.com/drupalspoons/devel/-/issues/361
Comment #115
berdirI think that is expected but adding a method to entity storage is allowed. I'm not sure if the serialzation issues have been addressed though, see #99
Comment #116
catchLooks like the ::getEntityClass() method was added (made public) in #105 to address #99 so that might handle serialization.
It's OK if this requires an update in devel module. I still need to do a proper review of this issue, just nothing these bits for later...
Comment #117
moshe weitzman commentedI pledge to update Devel once this issue lands. I've used DEBC on several projects and am a huge fan of bundle classes ... Would be great to get reviews from framework and release managers,
Comment #118
catchOn #39 I also worked on quo, which was built very early in Drupal 8 (might even have been pre-8.0.0), and iirc the main reason it used node bundles rather than custom entity types was because at that time, group module support for different entity types (especially access), as well as core features like revisions wasn't there yet. Since 8.0.0 a lot of those issues with custom entity types have been fixed, so it could probably use entity types quite happily and nodes for one thing or not at all.
IMO this makes the use-case for this patch a lot narrower, since some of it is now handled by custom entity types. So the question is what are the use-cases where you really, really want to use bundles, but also need the bundle classes.
With configuration entity types there is no way you can just go and make a new entity type, so that seems like a stronger use case for this than content entities - but do we have contrib examples for that? At the moment all the examples in the issue summary are for content entities. Would be good to document existing examples where this would be used in the issue summary.
Comment #119
jonathanshawOne answer might be: when you want to extend/modify the behaviors of an entity type provided by core or contrib. In these cases creating your own new entity types doesn't help.
For example, the order entity type from commerce_order or the group entity type from group. These entity types are at the heart of a complex ecosystem of code and I cansimply drop-in my own entity types as a replacement and trust it all to work. But I could easily want to have a dozen different flavours of orders or groups, with overlapping sets of logic and fields. Bundle classes really help here.
Comment #120
catchAhh I actually have a contrib/client use-case for this - where there are multiple bundles for groups and quite different logic between them.
@berdir in irc also mentioned using getters on bundle classes directly in Twig templates instead of preprocess, which was a direction we discussed moving in when Twig was first added to core (but has not really progressed in core that much yet).
Comment #121
catchUntagging for release manager review, the potentially disruptive change has been deferred to #3191620: Config entity bundles are able to declare their class in config.
Comment #122
alexpottDiscussed this issue with @catch and @berdir. Berdir pointed out:
Points 1 and 2 are compelling for me and @catch. I also think 3 is important if you are weighing up using a node type vs a custom entity type on a current site build.
Are we concerned about existing generic entity storage code using \Drupal\Core\Entity\EntityStorageBase::$entityClass and now doing the wrong thing? I think to commit this we need a good review of the bc implications and whether they'll break contrib and custom - especially any generic entity modules.
Comment #123
jonathanshawAffected modules according to grep.xnddx.ru for
$this->entityClass:Generic modules
bundle_override (41 installs, tries to do what this issue does so no surprise it clashes)
developer_suite (8 installs, doing all kinds of bleeding edge weirdness)
sparql_entity_storage (no installs)
Modules for external/plugin-based entities
Any attempt to set a bundle class on these modules would be to fight against their whole architecture - they already have a system of their own non-standard systems for entity flavours.
external_entities (215 installs)
apigee_edge (820 installs)
quiz (350 installs)
course (55 installs)
civicrm_entity (83 installs)
Seems like a pretty low BC impact in contrib.
Comment #124
ghost of drupal pastSince I was pinged in Slack, I will comment (yesterday I found this issue already linked from the Lullabot article but I wouldn't have commented otherwise): https://www.drupal.org/project/agcobcau generates an entity class per bundle , stores it using phpstorage and this class overrides the load/loadMultiple/create methods. The class also has a @property in class doxygen for every field for better IDE integration. You can write things like
$page = NodePage::loadwhich will be properly type hinted to be aNodePageobject and$page->field_foowill also be typehinted correctly. Again, this was written with IDE integration in mind. It has no releases and it's a gigantic hack anyways so it does not really affect this issue at all.Comment #125
dieterholvoet commentedAt our company we have been using our own module to be able to use bundle classes, wmmodel. It works using a core patch, which can be found in the repository. Bundle classes are defined using annotations.
load,loadMultiple&createmethods are overridden using a trait.Another module, wmscaffold, provides the ability to generate these bundle classes using a Drush command, including different kinds of getters for fields of different types.
These modules aren't published on Drupal.org though.
Comment #126
bradjones1Amen to:
Exciting stuff! Thank you to all who are working this actively.
Comment #127
eiriksmJust wanted to add another use case we are using it for:
Deprecating and moving fields.
You can keep calling ->getMyField () after renaming field_my_field to my_other_field
Comment #128
pfrenssenWe have been using this in a pilot project that has a large number of bundles, and it is in our view a game changer for working with complex code. We cannot wait to roll this out across all our Drupal projects. Use cases for contrib and core are limited, but for actual websites this is amazing.
Benefits
Object oriented
The main benefit is that it allows to define bundles and interact with entities in a full object oriented way. A complex project might have dozens of bundles that share business logic with other bundles. Things like content that is promoted to the front page, is subject to workflow, has a tagline, ...
Encapsulation of business logic
The bundle classes allow to add interfaces to bundles that identify which specific business logic they perform, and abstract away all the gnarly interaction with the Field API that normally is needed. We found that it greatly simplifies the way we write code.
For example we have declared a standard node type called "custom page" as a bundle class. In our project custom pages are group content (using Organic Groups), they have a publication date (from the Publication Date module) and they might have a logo (a normal image field). Our interface reflects the business logic that is encapsulated in this node type (full code:
CustomPageInterface.php).The bundle class itself can be mostly composed of traits, but has a few methods to declare things like field names that are unique to this bundle (full code:
CustomPage.php):The traits are shareable across all bundles that share the same business logic. For example we have 6 bundles that have an optional logo, and they all use the
LogoTrait(code:LogoTrait.php):Discoverability of business logic
It is at all times fully clear what kind of entities you are dealing with. Writing things like alter hooks becomes a breeze. Let's say we need to control access, but only on bundles that are organic groups and have a workflow field:
DX
Being able to use the IDE autocomplete to discover methods on practically any entity is also a very nice bonus.
NodeInterfacedoesn't say which fields are available on this custom page node you're working with, but CustomPageInterface shows me everything it is capable of.Example with bundle classes (yay!! :)))
Working with an entity that declares its responsibilities in this way is very convenient. For example if we are implementing a controller that should show information about an entity, and in case the bundle supports workflow then we should show the workflow state, this looks like this:
The whole complexity of interacting with the Field API is nicely encapsulated in the bundle classes.
Without bundle classes (boo! :((( )
In contrast, this is the code we were using before:
The increase in DX is staggering. Of course you can create numerous "helper" services and classes to encapsulate this kind of logic, but bundle classes allow to use an entity centric approach.
Where to use bundle classes?
It can be applied to any kind of business logic you are dealing with on a day to day basis. No matter how large or small. Does the bundle have a logo? Slap a
LogoInterfaceon it. Do we track the number of visitors in Matomo?VisitCountAwareInterface. A classic node type for an online event?EventInterface.Tip
We have a trait to robustly interact with the Field API (ref
JoinupBundleClassFieldAccessTrait), with the code taking care of all the edge cases of the Field API. This makes it quick and easy to develop bundle classes.Comment #129
moshe weitzman commentedJust want to thank @pfrenssen for such a terrific comment. I've experienced similar major benefits but I could not have described them so well.
Looks like we need to expand test coverage a bit based on recent MR comments.
Comment #130
berdirReviewed the MR.
Comment #132
moshe weitzman commentedI addressed all the feedback that I found actionable. I'll try to implement anything else thats needed if folks can elaborate a bit more. Hoping we can get this one in.
Comment #133
ghost of drupal past+ // The bundle value is a field item array, keyed by the field's main
+ // property name.
+ $bundle = reset($bundle_value);
Please don't introduce more of #3178794: FieldItemBase::setValue() relies on code order thanks.
Comment #134
moshe weitzman commentedAFAICT, That’s pre-existing code that’s moved now into a conditional https://git.drupalcode.org/project/drupal/-/merge_requests/200/diffs#not....
Comment #135
moshe weitzman commentedI made that $entities_by_class change and rebased the MR.
Comment #136
moshe weitzman commentedIf anyone is available to provide a final review, I'd appreciate it.
Comment #137
claudiu.cristea@moshe weitzman, Still needs work because of PHPCS
Comment #138
jeroentComment #139
larowlanHiding all files now this uses a MR
Comment #140
larowlanI've been using this on a new site, and got to say the developer ergonomics is awesome.
Left a review in gitlab
Comment #141
dimitriskr commentedHello, I'm posting here because it's not related to the functionality, but for the patch from the MR. When applying it, I get some duplicate files and folders inside web/core/ folder (check screenshot attached for an example). (web/core/b/ or web/core/core/)
Does this happen to anybody else?
Comment #142
bradjones1@dimitriskr you're running into a patch level mismatch, probably using cweagans/composer-patches; see https://github.com/cweagans/composer-patches/tree/1.x#allowing-to-force-... and https://github.com/cweagans/composer-patches/issues/60#issuecomment-3940...
Comment #143
dimitriskr commentedAh I see, thank you very much! The file structure is now as expected
Comment #144
james hawthorn-byng commentedIt sounds like this issue may resolve a lot of annoyances I have with using bundles.
I would love to check this out but I cant seem to find the patch file in the MR, or do I have to check the branch out and then create a patch file?
Comment #145
dpi@James Byng there is a Download as -> Plain diff menu adjacent to Check out branch button.
Comment #146
dpiAdded some comments.
Comment #148
bradjones1Rebased against 9.3.x on a different branch - I'm still not quite sure the etiquette of rebasing and force-pushing onto someone else's branch (not sure the remote would accept it but I wasn't about to try... the docs are silent on that?) It also looks like I couldn't change the target branch on the existing MR, anyway, which would add a lot of noise to the diff.
I'm interested in using this on a current project and so will take a stab at a commit addressing dpi's comments.
If I should just do this on the exisitng MR, someone pipe up as to what's appropriate?
Comment #149
bradjones1Good thing I didn't force-push, that rebase is no bueno. Going to do a squash and then not have to rebase 18 commits.
Comment #150
bradjones1https://git.drupalcode.org/project/drupal/-/commit/ebcecbe435a7ca1cc516c... and related (removing alpha modules form 9.2.x) seem to muck with a rebase against 9.3.x; I squashed the commits from the main MR here and cherry-picked them, which makes this more atomic.
Comment #153
dwwPer Slack exchange in #contribute with @larowlan, we just fixed up MR 200 for this issue.
2570593-bundle-classesbranch relative to the HEAD of 9.3.x.So I think we're full-steam ahead at 200 again. ;) I'll see if I can resolve anything else pending tomorrow...
Thanks!
-Derek
Comment #154
dwwStatus update:
Without being able to resolve threads, it's pretty hard to keep track of what's going on in the MR. The noise in here isn't exactly easy to follow, either. At the current moment, most of the review points have been addressed and could be resolved. The open questions are:
Then there's new test coverage to review for these:
Tentatively putting this back to NR, although it probably is still NW for something. ;)
@catch Asked for a summary update in #118, but the CR seems to have pretty clear examples of where/why you might want to do this. I guess we could harvest more of #128 into the summary, but that seems like a lot of noise. Added a link for now.
More importantly, I just changed the "None" lie in the API changes to try to document everything public this patch is now touching. ;) Everything seems pretty kosher, except that I believe this is a BC break:
Drupal\Core\Entity\EntityStorageInterfaceAlthough since we add an implementation in EntityStorageBase, I think we're still allowed under this rule:
https://www.drupal.org/about/core/policies/core-change-policies/drupal-8...
Also added a 1st draft of a release note snippet. This surely seems like it'll want a release note. Probably a "highlight", too. But I'll leave that to release managers / product managers to decide. ;)
Exciting! This is going to be really great to officially support in core...
Thanks,
-Derek
Comment #155
dwwAlthough the work is all happening in MR 200, here are some patches based on the current state of the art that apply to 9.3.x and 9.2.x for folks (like myself) who need them for composer.
Comment #156
simeSimple video to maybe sell the benefits. https://youtu.be/DtA7YZ1sFKc
Comment #157
dwwI believe everything in the MR is now addressed. Here's a test-only showing all the latest test coverage, with only
commit 0b9a49968reverted. This shows that the new tests catch the bug I spotted by eye.Here's also a new pair of full patches for composer folks, and an interdiff relative to #155 to see what all has changed.
I believe this is now viable for RTBC if anyone else is willing to do a thorough review.
Thanks!
-Derek
Comment #158
larowlanGoing to ping other FMs to say I've removed this tag
Comment #160
dwwYay, just as expected / advertised:
https://www.drupal.org/pift-ci-job/2165792
Comment #163
dwwWhoops. ;) @larowlan and I stand corrected. The "fix" for
postLoad()actively breaksDrupal\user\Entity\Role::postLoad():Once we're invoking
postLoad()with$items, not$entities, that sort is only sorting the subarray, but the original$entitiesarray itself is untouched. 😢 That's why https://www.drupal.org/pift-ci-job/2165788 is failing in Drupal\Tests\user\Functional\Views\HandlerFieldRoleTest and related things that are testing for role weights / ordering.I pushed
commit 71a3a3b66fto the MR about it. This undoes the "fix", adds comments about why, and updates the test coverage about method invocations and counts. Here it is in patch form.Thanks, tests and testbot! Once again, you're saving the day...
Comment #164
dwwAfter further discussion with @Berdir and @larowlan in Slack just now, we agreed to a slightly weird compromise. If
EntityStorageBase::postLoad()only has a single class to deal with, we invoke that class's postLoad() with the original$entitiesarray, to allow things like\Drupal\user\Entity\Role::postLoad()to reorder the roles based on weight, etc. However, if there are multiple bundle classes in the picture, each one only sees the$itemssubarray of entities that match the class's bundle. Restored the test coverage that to effectively what we had at #157. But with the special-case in play, all the role related tests are still passing locally now.https://git.drupalcode.org/project/drupal/-/merge_requests/200/diffs?com... for the gory details.
p.s. Attached the Slack transcript of our discussion for history / posterity / context.
Comment #165
berdirWill try to review this soon.
One thought I had in to another issue would be to use this in forum.module for its special vocabulary and node type. That could be a big step to transform that module from a forgotten and ignored thing to a decent example and a real-word demonstration of this feature. Might also allow us to fix #2010132: Canonical taxonomy term link for forum vocabulary is broken although I'm still unsure about that feature, should probably use aliases instead. Follow-up issue already, this is already complicated enough. An early proof of concept might help to convince anyone who still doubts that this is useful though :)
Comment #166
larowlanGreat idea, forum is the ideal use case
Comment #167
claudiu.cristeaCan we, please, also deprecate the potential use of the
classkey in the bundle config entities as per #84, first bullet point? It should be done now, so that we can implement that in Drupal 10Comment #168
simonbaesesee #171
Comment #169
bradjones1I'm not sure what you mean by deprecate in #167 - it appears this is a feature you want to implement based on the work here? There is an issue for this: #3191620: Config entity bundles are able to declare their class in config
I will admit that I haven't followed all the recent changes here, but I am struggling to grok exactly what the specific issue is, here, that you say needs work by deprecation.
Comment #170
berdirYeah, I don't think that we need to deprecate a possible usage of that key now so that we could use it later. Even if someone would implement that right now for their config entity, assuming they use it or the same use case, it would not actually break anything. FWIW, I'm not quite convinced we need this to be on the config entity. That's definitely not something we want to expose in a UI anyway, at least not without a safe, restricted discovery and selection mechanism. If we go there we could just as well define entity bundle classes as their own plugin system and define their for which entity type and bundles they are, similar to formatters/widget. But that too can be explored in a follow-up issue.
Comment #171
simonbaeseI am currently using the patch for Drupal 9.2.x and ran into a minor issue. In my module, I extend
Nodewith the bundle classProject. Additionally, I want to configure the bundle in/config/install/node.type.project.yml. This seems reasonable to me, since my module should not only provide the logic, but also the configuration for the new content type.With this fairly simple setup the module installation throws the warning
Debugging the issue showed that
hook_entity_bundle_info_alter()is called during the installation before the configuration is finished. Consequently, with the current suggestion on how to implement the hook, the bundle class gets overwritten before the configuration of the entity type exists.This poses no issue because configuration will be provided at some point. Still, one might make the alteration of the bundle class more robust. In my use case the following was proficient
Comment #172
claudiu.cristea@Berdir,
Please see #88 why, I think, bundle config entities should get a chance to define the class before is being altered.
Comment #173
bradjones1I'm not sure the situation described by @simonboy in #171 is a bug in so far as there are a number of similar areas where modules must be aware of the order of operations in how their default configuration is imported (think places where you check for
$syncing = true) ... right?Comment #174
dwwa) Looks like #171 was a re-post and cross-post, and clobbered the previous status update.
b) I agree with @Berdir that we don't need to preemptively deprecate the usage of a
classkey now. I'm not clear that #88 is cause to further delay this issue. Seems possible to sort that out in a follow-up. This already risks never landing if it's too complicated for review and too much scope creep.Therefore, restoring to 'needs review'.
Meanwhile, I opened the follow-up for #165: #3233398: Add a bundle subclass for forum.module's business logic
Comment #175
simonbaese@dww You are right. I did not intend to change the status. I think I started writing my comment before the previous comment was published.
@bradjones1 I agree that this not a bug that is caused by this patch. The sequencing of installation and configuration is not entirely clear at the moment (see for example discussions around pre- and postinstall hooks). I would suggest to change the instructions on how to alter the bundle info (see e.g. #171). It is not comforting having warnings thrown that are not easily understood when using this feature in a 'natural' way.
Comment #176
berdir@simonboy: Do _not_ call getBundleInfo() in your alter hook, that will cause a recursion. Drupal is currently building the bundle info. You already have the bundle info, just wrap it in a isset($bundles['node']['project') and you're fine. And yes, it makes sense that the code example in the change record uses that syntax, to avoid problems like that.
Comment #177
simonbaese@Berdir Thanks for the advice.
Comment #178
aaronmchaleAlso agree with @Berdir (#170) and @dww (#174).
We're definitely going to need a follow-up issue (or two?) to look at what mechanisms should exist (other than in a hook).
For example, I also like the idea of config entities being able to declare their associated bundle class in configuration, but I don't think that should be exposed in the UI (you would only really want that for installing a bundle from config, a field in the UI just wouldn't make sense). I also like the approach of Bundle Plugins, that the Entity API contrib module provides, where you can define plugins as bundles instead of config entities.
However, this issue is already long, and I second the concern that if we dive down these rabbit holes here, we'll never get this done. Let's get this in, it's a fantastic addition, and use follow-up issues to properly explore and discuss each mechanism that should be available for taking advantage of this.
Comment #179
jibranThe patch looks good to me. Nice work everyone. I only have one question, What if as a contrib/core maintainer I don't want my entity to support bundle classes? Can I disable that for an entity?
Comment #180
berdirYes and no? You could make your entity class final, you could replace the storage and disable the logic that checks for that? But someone can re-replace the storage class or copy the entity class. Almost everything can be overridden, with a patch as a last-resort, GPL simply doesn't grant us the power to deny anyone from doing something they want ;)
Of course if you use this, then you need to live with the fact that there are BC risks. If you override any method that then changes in an update then you are in trouble.
Comment #181
dwwIt seems like the last remaining thing before this is RTBC is sorting out how
postSave()should work. Is #164 sufficient? We had a big Slack thread about it, and I'm posting the highlights here so they're accessible to folks not on Slack, and so they're saved for posterity.TL;DR: is #164 Good Enough(tm), or do we need to do something like this with
doPostLoad()?EntityStorageBase::postLoad()would be:Then
EntityStorageBase::doPostLoad()is basically just:But
ContentEntityStorageBase::doPostLoad()would become:But we'd probably want the same hack to count the # of bundle classes and invoke
postLoad()on the main$entitiesarray if there's only 1 class. So I'm not really sure what all this refactoring would buy us...Comment #182
catchIf this is the case, i think we should go ahead with what we have.
Comment #183
kingdutchI've updated the CR to have the
issetcheck suggested in #171 and confirmed as useful in #176.The CR may still need an update for branch/version since 9.2.0 is already released.
In Open Social we currently have this functionality implemented using a custom solution. We were not aware of this issue. Our solution quite closely matches with what has been come up with in this issue. Very happy to see if I can provide this the final review towards an RTBC.
The workaround in that case would be to push the sorting into a
hook_entity_loadwhich still receives all the entities at once.This same section of code as 1. also receives
$itemsby reference so that the input toEntityStorageBase::postLoadis mutated and available to the caller. We also loop over the results ofgetEntitiesByClassby reference. However, I believe, thegetEntitiesByClassitself breaks the reference between the input toEntityStorageBase::postLoadand the$entity_class::postLoadcall. This means that any sorting changes that a bundle specificpostLoadfunction would not propagate as would happen for apostLoadcall on a single class.I see that this was discussed in the MR: https://git.drupalcode.org/project/drupal/-/merge_requests/200#note_42131 but it may be good to document the limitation for bundle classes.
ContentEntityBasewhich doesn't propagate it and checks for a FALSEY value (whichNULLstill is). However, this could break entities -- parenthesis for logical grouping -- that (do not extendContentEntityBaseOR overwrite the constructor) AND check against FALSE strictly [i.e.$bundle === FALSE] rather than using!$bundle. I think this is an edge case that can be ignored.This change was introduced in #2570593-25: Allow entities to be subclassed using "bundle classes". I wonder if it should only do this when the bundle was not explicitly specified. For example existing code (even though it may be weird) that introduces bundle classes for nodes and has invocations such as
$article_node::create(['type' => 'blog']);will now change behaviour and no longer return the blog bundle but instead start creating articles.Beyond these three remarks everything else pointed me to "Reviewed & Tested by the community". However, leaving this at "Needs review" until someone else confirms whether the above points are indeed not an issue or should be addressed.
Comment #184
pfrenssenReplying to #2570593-183: Allow entities to be subclassed using "bundle classes":
Role::loadMultiple(),RoleStorage::loadByProperties()orRoleStorage::loadMultiple()that informs a developer that the role entities will be sorted by weight. The reason this was introduced is to be backwards compatible with theuser_roles()function in Drupal 7 that was returning them in this order. Even back then this order was not documented. It is just something that is there "because it always has been", and possibly something that we should deprecate.On the other hand, for the intended use cases of
EntityInterface::postLoad(), which is to enrich the loaded entities with additional data, we definitely should let each bundle class have a go at it, and they should only be passed entities of their bundle.I think the current implementation strikes a good balance between backwards compatibility and allowing this to be used like intended. If any existing code is abusing this method to impose an order on entities, then it is most likely to be done by config entity types (like
Role) which are inherently bundleless. As soon as multiple bundles are in play, sorting entities at load time regardless of bundle doesn't really make much sense any more.Article::create(['type' => 'blog'])and then get an entity of typeArticlethen they 100% deserve to get what they ask for :DComment #185
dwwThanks for the reviews and comments.
Re: #183:
Re: #184:
I rebased the MR, and pushed commit c729434f0 to try to improve the docs. Is that sufficient to address point 1?
Can we consider #3230792: Decide what to do in ContentEntityStorageBase::create() if preCreate() tries to change the bundle equivalent to the question of point 3?
Given that @catch has signed off on #164 as cleaner than the alternative in #181, anything else before this is RTBC?
Thanks!
-Derek
Comment #186
dwwSorry, I rebased to a slightly stale copy of 9.3.x, and the MR was saying the merge was blocked. Re-rebased so it's merge-able.
Comment #187
pfrenssenYes, this is very clear now and the point is addressed, thanks!
I think this looks great now. I would RTBC if I could :)
Comment #188
claudiu.cristeaEverything is addressed. Thank you!
Comment #189
kingdutchCool! Happy with the replies! The addition in the comments is a good one to indicate that it's a backwards compatibility layer and not a promised feature for bundle-class-less entities.
Happy with marking this as RTBC as done in #188!
Comment #190
alexpottDo we need to do work to make custom / contrib aware of this change. I'm concerned that we're not enough to help classes like SparqlEntityStorage - see http://grep.xnddx.ru/node/31888366 - know that they have to change. It's access the entityClass property directly.
There are also storages that are setting it - http://grep.xnddx.ru/search?text=-%3EentityClass+%3D&filename= and create objects using it http://grep.xnddx.ru/search?text=-%3EentityClass%28&filename=
Should we not consider doing something like https://3v4l.org/UdVmG - we'd need to implement a setter and a magic __set() but at least we could then help classes use getEntityClass() consistently and deprecate direct access to the variable.
Comment #191
pfrenssenThe Sparql Entity Storage module maintainers are my colleagues. They are aware of this, I have proposed a patch some time ago: https://github.com/ec-europa/sparql_entity_storage/pull/11
I think emitting a deprecation warning on accessing is a good idea though. There might also be private custom code out there that is using it.
There is also at least one competing implementation around: the Developer Suite module has implemented bundle classes on its own. They have been alerted about this issue in #2930060: Utilize new entity subclassing functionality.
Comment #192
ghost of drupal pastA couple minor notes potentially could be addressed in followup:
thrown when multiple subclasses correspond to the called class.I believe this wanted to bethrown when multiple entity types correspond to the provided classa) not "subclasses" but "entity types" b) not "called class" but "provided class" because we do not call a class, that's not even possible, you can call an object if it has a __invoke magic method but classes can't be called.get_called_class();tostatic::class? With PHP 8 introducing thestaticreturn type, I prefer static::class (introduced in 5.5) but that's again very minor.Comment #193
claudiu.cristeaSetting to NW as per #190 and #192.
Comment #194
pfrenssenRegarding point 4 of #2570593-192: Allow entities to be subclassed using "bundle classes", I would prefer that we keep the requirement that bundle classes should extend the main entity class, and not allow to use the base class for bundles. It makes more sense, and it will be easier to distinguish between bundles.
The idea is to make it possible to do a class centric approach to bundles. If certain bundles can have the entity class instead of their own dedicated class then we can no longer depend on interfaces but we always will need to fall back to inspecting
$entity->bundle()to be certain we have an entity of the correct bundle.It is possible of course that certain bundles don't need any specific methods of their own, and then they don't need a dedicated interface or class. But in that case just don't define a bundle class for them, and they will default to the main class anyway.
Comment #195
dwwThanks for all the reviews.
Starting from the easy stuff, first. 😉
Re: @Charlie ChX Negyesi in #192:
static::classis good. Better, in fact. 😉 Fixed in commit 6ad1ea7b6.Now for the harder stuff... re: @alexpott in #190: This is why you're such a fabulous framework maintainer. 😀 You seem to always think of such things, grep the impact on contrib/custom, etc. Thanks for keeping us honest!
I started down the road you're describing (see commit 5e949ffc0f), but I'm running into various complications and I'm not sure I'm on the right path.
A. From my understand of the PHP docs,
__get()is only called if you're trying to access what PHP calls "inaccessible properties". I believe https://3v4l.org/UdVmG only works becauseprivate $entityClass = 'test';isprivate. If it wasprotected, a subclass would be able to directly access it,__get()wouldn't be called, and the deprecation wouldn't be thrown. See https://3v4l.org/KXYVm for proof. Are you proposing we make this propertyprivate? Isn't that a much more impactful and potentially breaking change?B. I'm not sure the best way trigger an error when calling code is trying to set this directly. Are you proposing we also add a
setEntityClass()method toEntityStorageInterface? That seems like a yucky thing to have in the public API. Or would it be a protected method directly onEntityStorageBase? If all this effort is to protect child classes from setting this property directly, we again need to make itprivateor none of the magic would even kick in. Is that the idea?C. I'm not sure how to best test all these things. Should we add a new entity type to core/modules/system/tests/modules/entity_test, something like "entity_test_deprecated_storage", that extends entity_test, but uses the storage handler annotation to point to some new class (
\Drupal\entity_test\Entity\EntityTestDeprecatedStorageHandler?) that extendsContentEntityStorageBasebut tries to do naughty things? Then add a new Kernel test incore/tests/Drupal/KernelTests/Core/Entitythat tries to create some entities of entity_test_deprecated_storage and make sure we see the right deprecation message? I'm worried that properly testing these deprecations is going to spiral this issue somewhat out of control, and time is short. I don't want to start adding dozens (or maybe hundreds) of lines of whacky test code, unless that's what you're proposing we need before this is commit-able. 😉So, before I keep plowing forward, I'll push what I've got so far, post this comment, and hope to get clarity on what you're really proposing in #190.
Thanks!
-Derek
p.s. 9.3.x branch has been busy! 😉 Rebased again while I was at it.
Comment #196
berdirYes, to make __get() work, you need to remove the property. That's what we've done for example with the entityManager property removals and BC layers for it. I don't think we need BC for setting it, that was never expected to work IMHO.
For testing it, a unit test with a subclassed storage class that accesses that property in a new public method might be sufficient?
Comment #197
berdirSee \Drupal\Tests\Core\Entity\Sql\SqlContentEntityStorageTest, which already mocks all the stuff that SqlContentEntityStorage needs although just subclassing the parent class directly should be a lot simpler.
Comment #198
dwwSince it was very incomplete and potentially full of problems, I reverted the
__get()from 5e949ffc0f and pushed that as 4e50b99d so that the MR is in a "clean state" while we sort out if/what to do about #190...We're Slacking about it right now. @Kingdutch is about to post a comment with a hopefully better answer than the mess I started. 😉
Comment #199
kingdutchYou're giving me too much credit, and yourself too little ;p
Looking at the commits added by @dww those look good. Momentarily ignoring @alexpott's feedback in #190 I'd be happy to RTBC the PR in its current state.
Regarding the feedback about making maintainers aware. @dww's understanding of how
__getworks is correct. So 5e949ffc0f on its own does not do what we want.I think changing the visibility to
privatemay create more issues than it solves. The wayprivateand__getworks is not entirely straightforward (even though it makes sense once you actually know what's going on).I've created a little sandbox that has a private property in the base class, which implements
__getand extends this in another class that can override it's property.The invocation and related output is as follows.
So the result of making the property
privateis that without warning changing theentityClassvalue will no longer work as expected. You will see the expected value in your own class and its overridden methods. However, the base class which is where all the new magic happens (and which falls back to the rawentityClassvalue) will never be able to see the modified value anymore, which changes its behaviour. Additionally, if a class overrides theprivateproperty before access it will never call__geteither and will still not be made aware of the deprecation.Implementing
__setas well would solve this in that it can propagate the value into the base class (so that the entire hierarchy has a shared value again). This ensures the inheriting class no longer has its own copy of the property and thus calls__geton the base class again.This makes implementing
__getwithout__seta no-go for me. Implementing both is an option, but I don't think it's much better than doing nothing.My personal view is that documentation should be sufficient with the following reasoning: Existing code will not break until either the module maintainers introduce bundle classes, which means they’re aware of the change, or until an external developer introduces bundle classes for the code modified by the module they’re using, which also means they know what they're doing.
Comment #200
dwwOkay, extensive Slack chat about all this, and I think we have a plan. 😉 Attaching the transcript here with permission for posterity and for anyone who wants to follow along who's not on Slack.
TL;DR: I'm going to:
protected $entityClassentirely.__get()and the deprecation warning.private $baseEntityClass__set()watching 'entityClass'. If hit, trigger a deprecation and set$baseEntityClassEntityStorageBase::getEntityClass()implementation to see if$baseEntityClassis set, and if so, use it. Else, use$this->entityType->getClass()(the current behavior).More or less. I've got a few meetings, but hopefully I'll have progress to share here in a couple of hours...
Edit: Clarified point 5 to mention which version of
getEntityClass()would care about$baseEntityClass.Comment #201
dwwThis still needs the tests of the new deprecation stuff, but I think the implementation itself is now (mostly) complete. Tagging for tests, but moving back to NR if anyone wants to review what I've done to make sure this is on the right track...
Also, the deprecation message for
__get()is pretty obvious, but what should we say in__set()if you're trying to touch entityClass?There is no hook_entity_type_info() + alter. What's the recommended alternative for folks trying to override this? "Set the right class in the entity type annotation, or if you've got bundles, use bundle classes instead." ? That seems unhelpful. 😉 Is this a limitation in this approach?
Thanks!
-Derek
Comment #202
berdirYou could also override getEntityClass(), but you might not have the same context available there. Although you should be able to set your own property wherever you changed entityClass and then return that in getEntityClass(). For cases like quiz that apparently return a different class based on the returned values. I wasn't sure if that was the bundle or something else.
Comment #203
dwwThose last 2 commits should fix all the failing tests.
The first fixes the bug where I used
trigger_error()not@trigger_error(), which is what that "Unsilenced deprecation" stuff is about.I also removed "Use @todo instead." from the deprecation warning. The CR now explains all this.
Are we cool with the required test changes from commit 8f96ba0b? That commit modifies the expectations on how often
getClass()is called:$this->entityType->getClass() in the constructor, so many tests no longer need to mock a return for it at all.I don't think we can avoid this, since if we continue to compute this in the constructor, setting
$this->entityClass(and therefore triggering the__set()deprecation) wouldn't change anything since the class would hold onto the old value.I suppose in
EntityStorageBase::getEntityClass(), if$this->baseEntityClassis stillNULLand we call$this->entityType->getClass(), we could save the value intobaseEntityClassso that we don't callgetClass()multiple times in the same request. Is that worth the complication?For the test changes, should we use
exactly(2)(current code in the MR) oratleastOnce()?Thanks everyone, almost there! 🎉🤞🙏🤞😉
-Derek
Comment #204
dwwUpdating the API changes section of the summary with latest API changes.
Also updated the CR to explain how postLoad() now works and add anchors for each of the sections.
Finally, saving credit for some of the folks who have majorly impacted the code in here via Slack threads...
Comment #205
dwwp.s. Although the MR thinks there are 2 unresolved threads, they should both be resolved now. I just don't have perms in Gitlab to do so (requires @pfrenssen (owner of the MR) or a core committer).
Comment #206
kingdutchMoving to Needs Work for a nitpick and a question. Other than that this is RTBC for me :D
Comment #207
dwwThanks for the review, @Kingdutch!
Back to NR.
Comment #208
kingdutchI don't see a button to resolve my own threads, but the changes by dww address both my points :D
Looks good to me!
Comment #209
dwwWe had quite a Slack thread about changes to the code examples in the CR, the benefits of duck typing vs. checking
instanceofand other OO paradigm debates. 😉 I just spent a while trying to improve things and front-load more of the stuff folks reading the CR might care about:Highlights include the Possibilities section near the very top of the page. I think that's more relevant for folks than a debate on duck typing. ;)
Also split the main "Examples" section into Interfaces and traits and Using an abstract base class.
Moved the heart of the debate about duck typing to a separate section on How code can react to loaded entities. I think I did everyone justice in there. Further edits welcome.
Then I added an API changes section.
This might now be one of the most complete CRs ever written. 😉 But as always, further improvements welcome.
Finally, remaining tasks here is down to just "commit". 🎉🤞🙏
Thanks,
-Derek
p.s. Re-rebased. 9.3.x continues to be busy!
p.p.s. Uploading a new patch here for composer-patches fun. Turns out this one applies cleanly to 9.3.x, 9.2.x and even 9.2.7 official, so I'm only uploading 1 copy.
Comment #210
larowlanAdding issues credits. Credited @sime and @e0ipso for their advocacy work (youtube video, blog post) as that's important too.
Comment #212
larowlanMerged the MR and published the change record.
Thanks everyone for their efforts here, I think this is one of the biggest features of Drupal 9.3, even if its not user-facing - Developers are users too!
Comment #213
larowlanAlso, has to be close to the most comprehensive change notice ever, kudos to all the folks involved
Comment #214
dww🎉🎉🎉🎉❤️ Thanks!!!
Going out on a limb to tag this as a 9.3.0 release highlight. The release managers will ultimately decide, but the above perspective seems to resonate with a lot of folks. This is a really big, important change for Drupal. Even if it's not a new button to press in the UI, it's going to be a big deal (perhaps game changing) for a lot of developers.
Thanks. 😊 Glad we all landed on a CR we can all be happy with / proud of.
Yay everyone! 🎉
Thanks again,
-Derek
Comment #215
chi commentedThese two statements contradict each other as there is no way in PHP to extend multiple classes.
Comment #216
larowlan@Chi so long as the parent extends from Node, it works. I'll try to make the CR more explicit in that regard
Comment #217
larowlan@Chi - how does https://www.drupal.org/node/3191609/revisions/view/12445470/12445611 sound?
Comment #218
chi commented@larwlan It makes sense. Thank you.
Comment #219
pfrenssenI'm super happy to see it included in Drupal 9.3, this is going to make life so much easier for many developers working on Drupal projects. We've been using the patch already for a year while it was still evolving, and being able to let PHPStorm autocomplete all the methods makes working with entities a breeze. It makes it feel like working with a "real" OO framework.
Thanks everyone, amazing job!
Comment #220
aaronmchaleThis is a huge feature, definitely one of the highlights of 9.3, great way to start the week 🎉🎉🎉
Anyone up to the task of taking the CR and writing a documentation page for it in the Entity API docs, this definitely needs a page of its won! 😀
Comment #221
timodwhit commentedThank you all so much! This was fun to track and see the progress on! Well done and very exciting for the DX!
Comment #222
larowlan#3246150: Bundle class changes mean the entity class is now determined by the active entity-type definition as a follow up we found today
Comment #223
dwwThanks for opening that follow-up, and sorry we missed it! I'll dive into that tomorrow, or perhaps later today if the stars are aligned at $day_job.
Meanwhile, not that it really matters, but I'm going to bump this to major.
https://www.drupal.org/docs/develop/issues/fields-and-other-parts-of-an-...
Given the 134 followers, the buzz around this feature among all sorts of folks, and the (large) scope of work it took to get this in, I think this definitely qualifies on all fronts.
Comment #224
joachim commentedThis would improve DX for defining these on custom entity types, as it would remove the need to implement a hook: #3112239: Allow entity classes to define the entity's bundles in a static method.
Comment #226
dieterholvoet commentedI created a follow up issue with an idea of how
EntityInterface::loadMultipleandEntityInterface::loadshould work when called on a bundle class, input is always welcome: https://www.drupal.org/project/drupal/issues/3252421Comment #227
siggy commentedUnofficial patch for 8.9.20 when also applying the patch : https://www.drupal.org/node/2765065
Comment #228
alexpottWe missed \Drupal\Core\Entity\FieldableEntityInterface::bundleFieldDefinitions() when we implemented this. In HEAD this using the entity class and not a bundle specific class (if one is available). Which makes does not make a lot of sense. This is fixed in #3266287: Make buildBundleFieldDefinitions use Bundle Classes for building bundleFieldDefinitions