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

  1. Reviews / refinements.
  2. RTBC
  3. Commit.

User interface changes

None

API changes

  1. Two new exception classes added:
    • Drupal\Core\Entity\Exception\AmbiguousBundleClassException
    • Drupal\Core\Entity\Exception\BundleClassInheritanceException
  2. Added Drupal\Core\Entity\ BundleEntityStorageInterface which defines a getBundleFromClass() method.
  3. Added Drupal\Core\Entity\ContentEntityStorageBase::getBundleFromClass() implementation.
  4. Drupal\Core\Entity\ContentEntityBase now implements a public static create() method.
  5. code>Drupal\Core\Entity\ContentEntityStorageBase now implements a public static create() method.
  6. 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.

CommentFileSizeAuthor
#227 2570593-227-unofficial-8.9.x.patch29.36 KBsiggy
#209 2570593-209.patch57.99 KBdww
#200 2570593-200.slack_.txt13.16 KBdww
#181 2570593-181.slack-re-164.txt5.06 KBdww
#164 2570593-164.slack_.txt5.69 KBdww
#164 2570593.163_164.interdiff.txt3.45 KBdww
#164 2570593-164.9_2_x.patch41.53 KBdww
#164 2570593-164.9_3_x.patch41.53 KBdww
#163 2570593.157_163.interdiff.txt2.78 KBdww
#163 2570593-163.9_2_x.patch41.21 KBdww
#163 2570593-163.9_3_x.patch41.21 KBdww
#157 2570593.155_157.interdiff.txt11 KBdww
#157 2570593-157.9_2_x.patch40.97 KBdww
#157 2570593-157.9_3_x.patch40.97 KBdww
#157 2570593-157.9_3_x.test-only.patch42.38 KBdww
#155 2570593-155.9_2_x.patch33.11 KBdww
#155 2570593-155.9_3_x.patch33.11 KBdww
#141 Screenshot from 2021-06-08 21-32-46.png179.87 KBdimitriskr
#105 interdiff-2570593-103-105.txt4.18 KBjeroent
#105 2570593-105.patch30.34 KBjeroent
#103 interdiff-2570593-101-103.txt496 bytesjeroent
#103 2570593-103.patch29.19 KBjeroent
#101 interdiff-2570593-96-101.txt479 bytesjeroent
#101 2570593-101.patch29.19 KBjeroent
#96 2570593-96.patch29.19 KBsokru
#93 interdiff.txt941 bytespfrenssen
#93 2570593-93.patch29.24 KBpfrenssen
#92 interdiff.txt10.47 KBpfrenssen
#92 2570593-92.patch29.21 KBpfrenssen
#91 interdiff.txt11.38 KBpfrenssen
#91 2570593-91.patch28.94 KBpfrenssen
#82 interdiff.txt3.34 KBpfrenssen
#82 2570593-82.patch26.33 KBpfrenssen
#80 interdiff.txt5.95 KBpfrenssen
#80 2570593-80.patch24.91 KBpfrenssen
#78 interdiff.txt8.72 KBpfrenssen
#78 2570593-78.patch26.91 KBpfrenssen
#74 interdiff71-74.txt3.43 KBeiriksm
#74 2570593-74.patch26.29 KBeiriksm
#71 2570593-70.patch25.38 KBeiriksm
#71 2570593-interdiff-57-70.txt5.03 KBeiriksm
#57 interdiff-2570593-51-57.txt618 bytesd70rr3s
#57 core-2570593-57.patch24.22 KBd70rr3s
#56 interdiff-2570593-51-56.txt598 bytesd70rr3s
#56 core-2570593-56.patch24.25 KBd70rr3s
#51 2570593entity_subclasses-2570593-51.patch24.22 KBdeviantintegral
#49 entity_subclasses-2570593-49-interdiff.txt8.55 KBdeviantintegral
#49 entity_subclasses-2570593-49.patch24.31 KBdeviantintegral
#47 interdiff-42-47.txt844 byteschi
#47 entity_subclasses-2570593-47.patch20.98 KBchi
#46 entity_subclasses-8.6.x-2570593-46.patch20.97 KBchi
#43 entity_subclasses-8.6.x-2570593-43.patch20.97 KBchi
#42 entity_subclasses-2570593-42.patch20.98 KBchi
#32 2570593-30-32-interdiff.txt1.88 KBmarkhalliwell
#32 2570593-32.patch20.97 KBmarkhalliwell
#32 2570593-32-8.4.x.patch20.89 KBmarkhalliwell
#31 2570593-31-8.4.x.patch20.3 KBmarkhalliwell
#30 2570593-25-30-interdiff.txt1.21 KBmarkhalliwell
#30 2570593-30.patch20.38 KBmarkhalliwell
#30 2570593-30-8.4.x.patch86.68 KBmarkhalliwell
#26 2570593-26-8.4.x.patch86.52 KBmarkhalliwell
#25 2570593-16-25-interdiff.txt10.9 KBmarkhalliwell
#25 2570593-25.patch20.22 KBmarkhalliwell
#16 2570593-15-16-interdiff.txt7.71 KBmarkhalliwell
#16 2570593-16.patch11.21 KBmarkhalliwell
#15 2570593-15.patch6.91 KBmarkhalliwell
entity-class.patch4.59 KBlarowlan

Issue fork drupal-2570593

Command icon 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

larowlan created an issue. See original summary.

larowlan’s picture

Issue summary: View changes
jibran’s picture

Issue tags: +Needs beta evaluation

+1 to this.

berdir’s picture

Hm. 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.

plach’s picture

And it's also totally compatible with #1867228: Make EntityTypeManager provide an entity factory, so I guess no problem with going this way for now.

+++ b/core/lib/Drupal/Core/Entity/EntityStorageBase.php
@@ -535,4 +537,11 @@ public function getAggregateQuery($conjunction = 'AND') {
+   * Gets the class-name of the entity.

Missing @param docs.

Status: Needs review » Needs work

The last submitted patch, entity-class.patch, failed testing.

Version: 8.0.x-dev » 8.1.x-dev

Drupal 8.0.6 was released on April 6 and is the final bugfix release for the Drupal 8.0.x series. Drupal 8.0.x will not receive any further development aside from security fixes. Drupal 8.1.0-rc1 is now available and sites should prepare to update to 8.1.0.

Bug reports should be targeted against the 8.1.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.2.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

joachim’s picture

While 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.

Version: 8.1.x-dev » 8.2.x-dev

Drupal 8.1.9 was released on September 7 and is the final bugfix release for the Drupal 8.1.x series. Drupal 8.1.x will not receive any further development aside from security fixes. Drupal 8.2.0-rc1 is now available and sites should prepare to upgrade to 8.2.0.

Bug reports should be targeted against the 8.2.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.3.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

Version: 8.2.x-dev » 8.3.x-dev

Drupal 8.2.6 was released on February 1, 2017 and is the final full bugfix release for the Drupal 8.2.x series. Drupal 8.2.x will not receive any further development aside from critical and security fixes. Sites should prepare to update to 8.3.0 on April 5, 2017. (Drupal 8.3.0-alpha1 is available for testing.)

Bug reports should be targeted against the 8.3.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.4.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

Version: 8.3.x-dev » 8.4.x-dev

Drupal 8.3.6 was released on August 2, 2017 and is the final full bugfix release for the Drupal 8.3.x series. Drupal 8.3.x will not receive any further development aside from critical and security fixes. Sites should prepare to update to 8.4.0 on October 4, 2017. (Drupal 8.4.0-alpha1 is available for testing.)

Bug reports should be targeted against the 8.4.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.5.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

bart vanhoutte’s picture

When 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:

$user->getBehavior('commerce')->getBillingAddress();
$user->getBehavior('mycustombehavior')->getFullName();

$node->getBehavior('article')->doSomething();

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.

markhalliwell’s picture

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.

Actually, 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:

use \Drupal\node\Entity\Node;
/** @var \Drupal\my_module\Plugin\EntityBehavior\Node\Article $article */
$article = Node::load($nid)->getBehavior('article');
$article->doSomething();
$article->getEntity()->save();

vs. a far cleaner and more human readable DX:

use \Drupal\my_module\Entity\Article;
$article = Article::load($nid);
$article->doSomething();
$article->save();

I've worked on a re-roll, will upload it shortly.

markhalliwell’s picture

Version: 8.4.x-dev » 8.5.x-dev
Status: Needs work » Needs review
Issue tags: +Needs tests
StatusFileSize
new6.91 KB
markhalliwell’s picture

StatusFileSize
new11.21 KB
new7.71 KB
joachim’s picture

> 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?

Status: Needs review » Needs work

The last submitted patch, 16: 2570593-16.patch, failed testing. View results

markhalliwell’s picture

Title: Make it easier for per-bundle entity classes by putting access to EntityStorageBase->entityClass behind a protected accessor » Allow entities to be subclassed more easily
Assigned: Unassigned » markhalliwell

I don't see it as a wrapper at all.

$article->getEntity()->save();, specifically ->getEntity() is what would make it a "wrapper".

Exactly the same as all the entity handlers (storage, list builder, views data, routing, etc).

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).

tim.plunkett’s picture

Title: Allow entities to be subclassed more easily » Allow entity types to be subclassed more easily

This 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 :)

markhalliwell’s picture

Meh, semantics. The node entity type plugin is also the class used to create the node entity object itself.

berdir’s picture

\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.

Actually, 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.

tim.plunkett’s picture

Too true, Berdir.

Also, "meh semantics" is not really a great reply when you're discussing changing how an entire subsystem works.

markhalliwell’s picture

Title: Allow entity types to be subclassed more easily » Allow entities to be subclassed more easily

I 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.

markhalliwell’s picture

Assigned: markhalliwell » Unassigned
Status: Needs work » Needs review
Issue tags: -Needs tests
StatusFileSize
new20.22 KB
new10.9 KB
markhalliwell’s picture

StatusFileSize
new86.52 KB

Here's an 8.4.x version of #25.

The last submitted patch, 25: 2570593-25.patch, failed testing. View results

joachim’s picture

> $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.

markhalliwell’s picture

Assigned: Unassigned » markhalliwell
Status: Needs review » Needs work

I think you're coming at this from the wrong side.

No, 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.

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.

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.

using plugins for entity behaviour is composition

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.

whereas what you're proposing here is inheritance

Correct, which is what this entire issue has been about: don't use $this->entityClass directly, abstract it so it can be overridden when needed or desired.

---

Re-assigning to myself to fix tests.

markhalliwell’s picture

Assigned: markhalliwell » Unassigned
Status: Needs work » Needs review
StatusFileSize
new86.68 KB
new20.38 KB
new1.21 KB
markhalliwell’s picture

StatusFileSize
new20.3 KB

Sigh, a rouge .orig file made it in the 8.4.x patch.

markhalliwell’s picture

StatusFileSize
new20.89 KB
new20.97 KB
new1.88 KB
joachim’s picture

The issue summary doesn't actually say what the use case is...

The last submitted patch, 30: 2570593-30.patch, failed testing. View results

The last submitted patch, 30: 2570593-30.patch, failed testing. View results

traviscarden’s picture

Issue summary: View changes

Per @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.

Version: 8.5.x-dev » 8.6.x-dev

Drupal 8.5.0-alpha1 will be released the week of January 17, 2018, which means new developments and disruptive changes should now be targeted against the 8.6.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

traviscarden’s picture

Issue summary: View changes

Adding 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/content view, which they wouldn't if they weren't nodes.

markhalliwell’s picture

I'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.

Version: 8.6.x-dev » 8.7.x-dev

Drupal 8.6.0-alpha1 will be released the week of July 16, 2018, which means new developments and disruptive changes should now be targeted against the 8.7.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

chi’s picture

Status: Needs review » Needs work
Issue tags: +Needs reroll
chi’s picture

Status: Needs work » Needs review
Issue tags: -Needs reroll
StatusFileSize
new20.98 KB
chi’s picture

StatusFileSize
new20.97 KB

Status: Needs review » Needs work

The last submitted patch, 43: entity_subclasses-8.6.x-2570593-43.patch, failed testing. View results

chi’s picture

+++ b/core/lib/Drupal/Core/Entity/EntityStorageBase.php
@@ -293,17 +308,31 @@ public function loadMultiple(array $ids = NULL) {
+        $function($items, $this->entityTypeId);

Workspace module uses hook_entity_load() to swap revision however that change remains within EntityStorageBase::postLoad() scope because we pass to the hook wrong variable.

chi’s picture

StatusFileSize
new20.97 KB
chi’s picture

Status: Needs work » Needs review
StatusFileSize
new20.98 KB
new844 bytes

I 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.

Version: 8.7.x-dev » 8.8.x-dev

Drupal 8.7.0-alpha1 will be released the week of March 11, 2019, which means new developments and disruptive changes should now be targeted against the 8.8.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

deviantintegral’s picture

Here's an update that:

  1. Cleans up a tiny bit of phpdoc to follow other optional parameters.
  2. Adds a getEntityClasses method in EntityStorageBase to clean up some duplicate code.
  3. Fixes not checking for ambiguous class definitions across both "exact" and "subclass" matches in EntityTypeRepository. I discovered this by refactoring out some methods. I'm not entirely happy with the $same_class pass-by-ref, so let's see if tests pass first. There also needs to be a test added for this case.

Status: Needs review » Needs work

The last submitted patch, 49: entity_subclasses-2570593-49.patch, failed testing. View results

deviantintegral’s picture

Status: Needs work » Needs review
StatusFileSize
new24.22 KB

I misunderstood how the class matching was working (thanks tests!), so this restores only checking for subclasses if an exact match is not found.

Version: 8.8.x-dev » 8.9.x-dev

Drupal 8.8.0-alpha1 will be released the week of October 14th, 2019, which means new developments and disruptive changes should now be targeted against the 8.9.x-dev branch. (Any changes to 8.9.x will also be committed to 9.0.x in preparation for Drupal 9’s release, but some changes like significant feature additions will be deferred to 9.1.x.). For more information see the Drupal 8 and 9 minor version schedule and the Allowed changes during the Drupal 8 and 9 release cycles.

mlncn’s picture

Status: Needs review » Reviewed & tested by the community

This is working wonderfully!

mlncn’s picture

And 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:

/**
 * Implements hook_entity_type_alter().
 *
 * This makes use of the patch from https://www.drupal.org/node/2570593
 */
function drutopia_findit_organization_entity_type_alter(array &$entity_types) {
  /** @var $entity_types \Drupal\Core\Entity\EntityTypeInterface[] */
  $entity_types['node']->setStorageClass('\Drupal\drutopia_findit_organization\FinditOpportunityNodeStorage');
}

In src/FinditOpportunityNodeStorage.php:


namespace Drupal\drutopia_findit_organization;

use Drupal\drutopia_findit_organization\FinditOpportunityNode;
use Drupal\node\NodeStorage;

/**
 * Provides a subclass for Find It opportunity bundles of the node entity type.
 *
 * @package Drupal\drutopia_findit_organization\Entity\Node
 */
class FinditOpportunityNodeStorage extends NodeStorage {

  /**
   * {@inheritDoc}
   */
  public static function bundleClasses() {
    $bundle_classes = parent::bundleClasses();
    $bundle_classes['findit_organization'] = FinditOpportunityNode::class;
    return $bundle_classes;
  }

}

In src/FinditOpportunityNode.php

/**
 * Provides a subclass for Find It opportunity bundles of the node entity type.
 *
 * @package Drupal\drutopia_findit_organization\Entity\Node
 */
class FinditOpportunityNode extends Node {
    /**
   * Return TRUE if an opportunity is outdated (hasn't been updated for a while)
   */
  public function isOutdated() {
    // ...
  }
}

And 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 %}

jonathanshaw’s picture

Status: Reviewed & tested by the community » Needs work
Issue tags: +Novice

The test fail is genuine (the patch uses deprecated code) so can't be RTBC. But fixing that is a novice task.

d70rr3s’s picture

Status: Needs work » Needs review
StatusFileSize
new24.25 KB
new598 bytes

Fixed test

d70rr3s’s picture

StatusFileSize
new24.22 KB
new618 bytes

My bad I generated the patch using a codebase instead a clean checkout from the repository.

jonathanshaw’s picture

Issue tags: -Needs beta evaluation
jonathanshaw’s picture

The patch (since #25) adds public methods to ContentEntityStorageBase but not to ContentEntityStorageInterface. At the least this might need a comment or discussion?

markhalliwell’s picture

Status: Needs review » Needs work

The patch (since #25) adds public methods to ContentEntityStorageBase but not to ContentEntityStorageInterface. At the least this might need a comment or discussion?

Adding 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 @internal tag 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.

gbirch’s picture

I 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!

eaton’s picture

Just 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.

e0ipso’s picture

I 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:

In the future, it may be possible to specify a custom class for entities of a given bundle. In that scenario, Node::load(12) will yield an object of type \Drupal\physical_media\Entity\Book, or a \Drupal\my_module\Entity\Building. Nowadays, it always returns a \Drupal\node\Entity\Node. While this is a step forward, it’s better to hide the implementation details of entities. Adding the business logic to entity classes increases the API surface and is bad for maintenance. This approach also creates a chance of naming collisions between custom methods and other added-to entities in the future.

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.

gbirch’s picture

In 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.

pfrenssen’s picture

The 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.

pfrenssen’s picture

I've tried this out and this is really exciting, it is a game changer for working with entities, awesome work!

Reviewed the patch:

  1. diff --git a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    index 23e1f32c56..6514124581 100644
    --- a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    +  public function getBundleFromValues(array $values, $throw_exception = TRUE) {
    

    I don't see a reason that this should be a public method, it is internal logic for ContentEntityStorageBase which is called during the creation of an entity.

  2. diff --git a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    index 23e1f32c56..6514124581 100644
    --- a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    +  public function getBundleFromClass($class_name) {
    

    This can be a static method.

  3. diff --git a/core/lib/Drupal/Core/Entity/EntityStorageBase.php b/core/lib/Drupal/Core/Entity/EntityStorageBase.php
    index 067b08acb5..2ef3beb6ca 100644
    --- a/core/lib/Drupal/Core/Entity/EntityStorageBase.php
    +++ b/core/lib/Drupal/Core/Entity/EntityStorageBase.php
    +  /**
    +   * Return an array of entities indexed by class name and ID.
    +   *
    +   * @param \Drupal\Core\Entity\EntityInterface[] $entities
    +   *   The array of entities to index.
    +   *
    +   * @return array
    +   *   An array of the passed-in entities, indexed by their class name and ID.
    +   */
    +  protected function getEntityClasses(array $entities) {
    

    Maybe Indexes the given array of entities by their class name and ID.?

  4. diff --git a/core/lib/Drupal/Core/Entity/EntityStorageBase.php b/core/lib/Drupal/Core/Entity/EntityStorageBase.php
    index 067b08acb5..2ef3beb6ca 100644
    --- a/core/lib/Drupal/Core/Entity/EntityStorageBase.php
    +++ b/core/lib/Drupal/Core/Entity/EntityStorageBase.php
    +  protected function getEntityClasses(array $entities) {
    +    $entity_classes = [];
    +
    +    foreach ($entities as $id => &$entity) {
    +      $entity_class = get_class($entity);
    +      if (!isset($entity_classes[$entity_class])) {
    +        $entity_classes[$entity_class] = [];
    +      }
    +      $entity_classes[$entity_class][$id] = &$entity;
    +    }
    +
    +    return $entity_classes;
    +  }
    

    Is there a reason that the &$entity values 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.

  5. diff --git a/core/lib/Drupal/Core/Entity/EntityTypeRepository.php b/core/lib/Drupal/Core/Entity/EntityTypeRepository.php
    index f63965a8b3..597985bd6d 100644
    --- a/core/lib/Drupal/Core/Entity/EntityTypeRepository.php
    +++ b/core/lib/Drupal/Core/Entity/EntityTypeRepository.php
    +  /**
    +   * Get the entity type ID, ensuring only a single class implements the bundle.
    +   *
    +   * @param string $class_name
    +   *   The fully-qualified class name to look up.
    +   * @param int &$same_class
    +   *   A counter to keep track of if multiple classes implement the same entity
    +   *   type.
    +   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
    +   *   The entity type corresponding to the class name.
    +   *
    +   * @return string
    +   *   The entity type ID.
    +   *
    +   * @throws \Drupal\Core\Entity\Exception\AmbiguousEntityClassException
    +   *   Thrown if multiple classes implement the same entity type and bundle.
    +   */
    +  private function getUnambiguousEntityType($class_name, int &$same_class, EntityTypeInterface $entity_type) {
    +    if ($same_class++) {
    +      throw new AmbiguousEntityClassException($class_name);
    +    }
    +
    +    return $entity_type->id();
    +  }
    

    This could use some dedicated test coverage, to ensure that the exception is thrown correctly in case multiple classes implement the same bundle.

pfrenssen’s picture

Title: Allow entities to be subclassed more easily » Allow entities to be subclassed using "bundle classes"
Issue summary: View changes
Issue tags: -Needs issue summary update

Updated issue summary.

pfrenssen’s picture

Issue summary: View changes
eiriksm’s picture

Assigned: Unassigned » eiriksm

Working on addressing #66 and #60

eiriksm’s picture

This 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

eiriksm’s picture

Assigned: eiriksm » Unassigned
StatusFileSize
new5.03 KB
new25.38 KB

Oops, forgot the files :D

markhalliwell’s picture

Instead of SubClassableEntityStorageInterface (which seems rather abstract), perhaps BundleEntityStorageInterface would 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"--

eiriksm’s picture

Fair 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 :)

eiriksm’s picture

Status: Needs work » Needs review
StatusFileSize
new26.29 KB
new3.43 KB

Changed the class to BundleEntityStorageInterface and added a test for #66.5

johnwebdev’s picture

+++ b/core/modules/system/tests/modules/entity_test_subclass/entity_test_subclass.module
@@ -0,0 +1,18 @@
+    $entity_types['entity_test_no_label']->setStorageClass('\Drupal\entity_test_subclass\EntityTestSubclassStorage');

+++ b/core/modules/system/tests/modules/entity_test_subclass/src/EntityTestSubclassStorage.php
@@ -0,0 +1,22 @@
+  public static function bundleClasses() {

Wouldn't this approach make it hard to let more than one module subclass a bundle for the same entity type?

berdir’s picture

I 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.

pfrenssen’s picture

Assigned: Unassigned » pfrenssen

Great idea! Assigning to change this to use hook_entity_bundle_info().

pfrenssen’s picture

StatusFileSize
new26.91 KB
new8.72 KB

Here 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 AmbiguousBundleClassException alongside the existing AmbiguousEntityClassException.

I am going to see if we now can simplify the changes in the ContentEntityStorageBase.

pfrenssen’s picture

Issue summary: View changes

Updated issue summary with a new example of how to define a bundle class using hook_entity_bundle_info_alter().

pfrenssen’s picture

StatusFileSize
new24.91 KB
new5.95 KB

Shedding some weight, and documenting the new class property for the entity bundle info hooks + providing an example.

Status: Needs review » Needs work

The last submitted patch, 80: 2570593-80.patch, failed testing. View results

pfrenssen’s picture

Status: Needs work » Needs review
StatusFileSize
new26.33 KB
new3.34 KB

So 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_test classes who all inherit each other.

pfrenssen’s picture

Assigned: pfrenssen » Unassigned

The 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.

claudiu.cristea’s picture

Status: Needs review » Needs work

This would be a big improvement for adding business logic to entity bundles. I have some generic remarks, followed by few nits:

  • I like the idea from #76 for its simplicity and DX. However, altering is something that you do to change or fix an existing behaviour. At least when a bundle is provided as a config entity, I would like to be able to define its (sub)class before altering it, in the main definition. And this is the config entity. I want to be able to define the (config entity) bundle node.type.article in this way:
    name: Article
    type: article
    # Of course this needs a schema entry.
    class: Drupal\my_module\Entity\Article
    description: 'Use <em>articles</em> for time-sensitive content like news, press releases or blog posts.'
    …
    

    This can be achieved in EntityTypeBundleInfo::getAllBundleInfo(). To be discussed.

  • The whole approach is governed by the assumption that bundle classes are extending the entity class. However, this is not tested anywhere. If we plan to allow bundle classes that are not extending the entity class, then we should fix 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, in EntityTypeBundleInfo::getAllBundleInfo().

Few inline remarks:

  1. +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    @@ -84,6 +85,34 @@ public function __construct(EntityTypeInterface $entity_type, EntityFieldManager
    +    // In some cases the entity bundle may be provided by the entity class'
    

    There's a quote ending this line.

  2. +++ b/core/lib/Drupal/Core/Entity/EntityBase.php
    @@ -560,7 +560,14 @@ public static function loadMultiple(array $ids = NULL) {
    +    if ($storage instanceof ContentEntityStorageBase && ($bundle = $storage->getBundleFromClass($class_name))) {
    

    Questioning whether EntityBase should "know" about the existence of ContentEntityStorageBase. I think this is more ContentEntityBase's business. Wouldn’t it be better to move the logic there?

  3. +++ b/core/lib/Drupal/Core/Entity/EntityStorageBase.php
    @@ -104,6 +104,20 @@ public function __construct(EntityTypeInterface $entity_type, MemoryCacheInterfa
    +   * @param string $bundle
    

    s/string/string|null

  4. +++ b/core/lib/Drupal/Core/Entity/Exception/AmbiguousBundleClassException.php
    @@ -0,0 +1,23 @@
    + * @ee \Drupal\Core\Entity\ContentEntityStorageBase::getBundleFromClass()
    

    s/@ee/@see

  5. +++ b/core/modules/system/tests/modules/entity_test_subclass/entity_test_subclass.module
    @@ -0,0 +1,21 @@
    +  if (\Drupal::state()->get('entity_test_subclass_enable_ambiguous_entity_types', 0)) {
    
    +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntitySubclassTest.php
    @@ -0,0 +1,78 @@
    +    $this->container->get('state')->set('entity_test_subclass_enable_ambiguous_entity_types', 1);
    

    It's a boolean expression, let's use booleans instead of integers.

  6. +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntitySubclassTest.php
    @@ -0,0 +1,78 @@
    +  public static $modules = ['entity_test_subclass'];
    

    s/public/protected

  7. +++ b/core/lib/Drupal/Core/Entity/EntityTypeRepository.php
    @@ -89,6 +91,20 @@ public function getEntityTypeFromClass($class_name) {
    +        if (is_subclass_of($class_name, $entity_type->getOriginalClass()) || is_subclass_of($class_name, $entity_type->getClass())) {
    

    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 a 9.1.x.

berdir’s picture

> 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 :)

claudiu.cristea’s picture

@Berdir

so it has to be optional anyway and could be a follow-up

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 a class key for other purposes.

johnwebdev’s picture

I'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.

claudiu.cristea’s picture

@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, in hook_entity_bundle_info_alter(). But a bundle defined via config entity is not a 1st class citizen because its class can only be defined in hook_entity_bundle_info_alter() and, as an effect, it cannot be swapped anymore by a 3rd party.

claudiu.cristea’s picture

Fixing in this ticket or as a follow-up would require to handle the BC problem in this way:

  • Create a patch against 8.9.x where we deprecate usage of a class key in bundle config entities.
  • Create a patch against 9.0.x that throws an exception if a class key exists in bundle config entities
  • Move this and the potential follow-up in 9.1.x.
pfrenssen’s picture

Assigned: Unassigned » pfrenssen

Assigning, 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.

pfrenssen’s picture

Status: Needs work » Needs review
StatusFileSize
new28.94 KB
new11.38 KB

Addressed all remarks from #84 except point 6 because it results in:

Fatal error: Access level to Drupal\KernelTests\Core\Entity\EntitySubclassTest::$modules must be public (as in class Drupal\KernelTests\Core\Entity\EntityKernelTestBase) core/tests/Drupal/KernelTests/Core/Entity/EntitySubclassTest.php on line 13

.

The protected modules patch (#2822382: Make every $modules property protected on classes extending BrowserTestBase and KernelTestBase) is only merged in 9.x.x.

pfrenssen’s picture

Assigned: pfrenssen » Unassigned
StatusFileSize
new29.21 KB
new10.47 KB

The patch was using "subclass" and "bundle class" interchangeably. I settled on "bundle class" since it is more descriptive than "subclass".

pfrenssen’s picture

StatusFileSize
new29.24 KB
new941 bytes

Fixing failures.

Version: 8.9.x-dev » 9.1.x-dev

Drupal 8.9.0-beta1 was released on March 20, 2020. 8.9.x is the final, long-term support (LTS) minor release of Drupal 8, which means new developments and disruptive changes should now be targeted against the 9.1.x-dev branch. For more information see the Drupal 8 and 9 minor version schedule and the Allowed changes during the Drupal 8 and 9 release cycles.

claudiu.cristea’s picture

Category: Task » Feature request
Status: Needs review » Needs work
Issue tags: +Needs reroll

Nice work, I see all the points got addressed. I have only a question and, depending on the answer, it might require a change:

+++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php
@@ -1125,6 +1125,23 @@ public function __unset($name) {
+    if ($storage instanceof ContentEntityStorageBase && ($bundle = $storage->getBundleFromClass($class_name))) {

I wonder if the $storage instanceof ContentEntityStorageBase check 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:

  1. The issue now is in 9.1.x. It needs reroll.
  2. The change record is still missed.
  3. It needs a follow-up, as per #90.

I also think that this is a Feature request, rather than a Task.

sokru’s picture

Issue tags: -Needs reroll
StatusFileSize
new29.19 KB

Just a reroll from #93.

claudiu.cristea’s picture

Status: Needs work » Needs review
pfrenssen’s picture

Status: Needs review » Needs work

Tests are failing, it seems this "Build successful" message is misleading.

jonathanshaw’s picture

Thanks 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:

      // $data is the array for a single queue job .
      $entity_class = NULL;
      if (isset($data['entity_bundle'])) {
        $bundle_info = $this->entityTypeBundleManager->getBundleInfo($data['entity_type']);
        $entity_class = $bundle_info[$data['entity_bundle']]['class'] ?? NULL;
      }
      if (is_null($entity_class)) {
        $entity_definition = $this->entityTypeManager->getDefinition($data['entity_type']);
        $entity_class = $entity_definition['class'];
      }
      $entity = $this->serializer->deserialize($data['entity'], $entity_class, 'json');

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?

pfrenssen’s picture

Yes 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().

jeroent’s picture

Status: Needs work » Needs review
StatusFileSize
new29.19 KB
new479 bytes

Tests are failing because of this error:

The 'core_version_requirement' constraint (9.1.0-dev) requires the 'core' key not be set in core/modules/system/tests/modules/entity_test_bundle_class/entity_test_bundle_class.info.yml 

Status: Needs review » Needs work

The last submitted patch, 101: 2570593-101.patch, failed testing. View results

jeroent’s picture

Status: Needs work » Needs review
StatusFileSize
new29.19 KB
new496 bytes
jeroent’s picture

Status: Needs review » Needs work

Back to needs work for #99.

jeroent’s picture

Status: Needs work » Needs review
StatusFileSize
new30.34 KB
new4.18 KB

We already added a getEntityClass method on EntitystorageBase. 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.

claudiu.cristea’s picture

Status: Needs review » Needs work
+++ b/core/lib/Drupal/Core/Entity/EntityStorageBase.php
@@ -412,21 +428,24 @@ public function delete(array $entities) {
+    foreach ($entity_classes as $entity_class => &$items) {
...
+      foreach ($items as $entity) {
           $this->invokeHook('predelete', $entity);
+      }
...
+      // Perform the delete and reset the static cache for the deleted entities.
+      $this->doDelete($items);
+      $this->resetCache(array_keys($items));
...
+      // Allow code to run after deleting.
...
+      foreach ($items as $entity) {
+        $this->invokeHook('delete', $entity);
+      }

If I'm reading correctly the code, the predelete and delete hooks are called for each module as many times as different entity classes we have. The same is true for:

      $this->doDelete($items);
      $this->resetCache(array_keys($items));

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.)

jonathanshaw’s picture

  1. +++ b/core/lib/Drupal/Core/Entity/EntityStorageBase.php
    @@ -412,21 +428,24 @@ public function delete(array $entities) {
           $keyed_entities[$entity->id()] = $entity;
    ...
    +    $entity_classes = $this->getEntityClasses($keyed_entities);
    

    AFACIS this $keyed_entities logic should be removed here and handled in getEntityClasses() instead.

  2. I can't see that #106 is right. Here's the complete loop:
    +++ b/core/lib/Drupal/Core/Entity/EntityStorageBase.php
    @@ -412,21 +428,24 @@ public function delete(array $entities) {
    +    foreach ($entity_classes as $entity_class => &$items) {
    +      $entity_class::preDelete($this, $items);
    +      foreach ($items as $entity) {
    +        $this->invokeHook('predelete', $entity);
    +      }
    ...
    +      // Perform the delete and reset the static cache for the deleted entities.
    +      $this->doDelete($items);
    +      $this->resetCache(array_keys($items));
    ...
    +      // Allow code to run after deleting.
    +      $entity_class::postDelete($this, $items);
    +      foreach ($items as $entity) {
    +        $this->invokeHook('delete', $entity);
    +      }
    

    $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.

Version: 9.1.x-dev » 9.2.x-dev

Drupal 9.1.0-alpha1 will be released the week of October 19, 2020, which means new developments and disruptive changes should now be targeted for the 9.2.x-dev branch. For more information see the Drupal 9 minor version schedule and the Allowed changes during the Drupal 9 release cycle.

pfrenssen’s picture

Title: Allow entities to be subclassed using "bundle classes" » 2570593-bundle-classes
Status: Needs work » Needs review

Addressed #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.

pfrenssen’s picture

Title: 2570593-bundle-classes » Allow entities to be subclassed using "bundle classes"
claudiu.cristea’s picture

Status: Needs review » Needs work

We 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:

  1. Needs a change record
  2. Needs a followup to implement #84 (first bullet point), as per #85, #87 and #90.

Setting as NW to fix the above.

claudiu.cristea’s picture

Status: Needs work » Reviewed & tested by the community
Issue tags: -Needs change record
dwkitchen’s picture

I have found this causes an issue with Webprofiler https://gitlab.com/drupalspoons/devel/-/issues/361

berdir’s picture

I 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

catch’s picture

Looks 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...

moshe weitzman’s picture

I 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,

catch’s picture

On #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.

jonathanshaw’s picture

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

One 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.

catch’s picture

or the group entity type from group.

Ahh 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).

catch’s picture

Untagging for release manager review, the potentially disruptive change has been deferred to #3191620: Config entity bundles are able to declare their class in config.

alexpott’s picture

Status: Reviewed & tested by the community » Needs review

Discussed this issue with @catch and @berdir. Berdir pointed out:

  1. Adding getSomething() can help us remove preprocess and make things really nice for twig
  2. It's a starting point for automated creation of methods for configurable fields
  3. It's really hard to create a revision UI for custom entity types at the moment (there are a few existing issues about this)

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.

jonathanshaw’s picture

Affected 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.

ghost of drupal past’s picture

Since 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::load which will be properly type hinted to be a NodePage object and $page->field_foo will 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.

dieterholvoet’s picture

At 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 & create methods 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.

bradjones1’s picture

Amen to:

Adding getSomething() can help us remove preprocess and make things really nice for twig
It's a starting point for automated creation of methods for configurable fields

Exciting stuff! Thank you to all who are working this actively.

eiriksm’s picture

Just 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

pfrenssen’s picture

We 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).

interface CustomPageInterface extends NodeInterface, GroupContentInterface, EntityPublicationTimeInterface, LogoInterface {
}

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):

class CustomPage extends Node implements CustomPageInterface {

  use EntityPublicationTimeTrait;
  use LogoTrait;
  use NodeCollectionContentTrait;

  public function getLogoFieldName(): string {
    return 'field_custom_page_logo';
  }
}

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):

trait LogoTrait {

  public function getLogoAsRenderArray($display_options = []): array {
    return $this->get($this->getLogoFieldName())->view($display_options);
  }

  public function getLogoAsFile(): ?FileInterface {
    $value = $this->getFirstReferencedEntity($this->getLogoFieldName());
    return $value instanceof FileInterface ? $value : NULL;
  }

}

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:

function mymodule_entity_access(EntityInterface $entity, $operation, AccountInterface $account) {
  // Only act on entities that are subject to workflow.
  if (!entity instanceof EntityWorkflowStateInterface) { // <<<< AWESOME
    return;
  }

  // ... 
}

DX

Being able to use the IDE autocomplete to discover methods on practically any entity is also a very nice bonus. NodeInterface doesn'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:

// Show the workflow state if the entity supports workflow and a workflow has been set.
if ($entity instanceof EntityWorkflowInterface && $entity->hasWorkflow()) {
  $build['workflow'] = [
    '#markup' => $entity->getWorkflowState(), // <<< Easy to use API
  ];
}

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:

// Show the workflow state if the entity supports workflow. We can find out
// by checking if it has a reference to a workflow field.
$field_name = array_reduce($entity->getFieldDefinitions(), function (string $carry, FieldDefinitionInterface $definition) {
  if (empty($carry) && $definition->getType() === 'state') {
    return $definition->getName();
  }
  return $carry;
}, '');
if (!empty($field_name)) {
  $item_list = $entity->get($field_name)->first();
  try {
    $item = $item_list->first();
    // The item list can be empty in case the entity is new.
    if (!empty($item) && $item instanceof StateItemInterface && !$item->isEmpty()) {
      $property = $item->mainPropertyName();
      $value = $item->$property;
      // The value is empty if no workflow state has been assigned yet.
      if (!empty($value)) {
        $build['workflow'] = [
          '#markup' => $item->$property,
        ];
      }
    }
  }
  catch (MissingDataException $e) {
  }
}

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 LogoInterface on 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.

moshe weitzman’s picture

Just 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.

berdir’s picture

Status: Needs review » Needs work

Reviewed the MR.

Version: 9.2.x-dev » 9.3.x-dev

Drupal 9.2.0-alpha1 will be released the week of May 3, 2021, which means new developments and disruptive changes should now be targeted for the 9.3.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

moshe weitzman’s picture

Status: Needs work » Needs review

I 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.

ghost of drupal past’s picture

+ // 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.

moshe weitzman’s picture

AFAICT, That’s pre-existing code that’s moved now into a conditional https://git.drupalcode.org/project/drupal/-/merge_requests/200/diffs#not....

moshe weitzman’s picture

I made that $entities_by_class change and rebased the MR.

moshe weitzman’s picture

If anyone is available to provide a final review, I'd appreciate it.

claudiu.cristea’s picture

Status: Needs review » Needs work

@moshe weitzman, Still needs work because of PHPCS

jeroent’s picture

Status: Needs work » Needs review
larowlan’s picture

Hiding all files now this uses a MR

larowlan’s picture

I've been using this on a new site, and got to say the developer ergonomics is awesome.

Left a review in gitlab

dimitriskr’s picture

StatusFileSize
new179.87 KB

Hello, 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?

bradjones1’s picture

@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...

dimitriskr’s picture

Ah I see, thank you very much! The file structure is now as expected

james hawthorn-byng’s picture

It 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?

dpi’s picture

@James Byng there is a Download as -> Plain diff menu adjacent to Check out branch button.

dpi’s picture

Status: Needs review » Needs work

Added some comments.

bradjones1’s picture

Rebased 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?

bradjones1’s picture

Good thing I didn't force-push, that rebase is no bueno. Going to do a squash and then not have to rebase 18 commits.

bradjones1’s picture

https://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.

dww made their first commit to this issue’s fork.

dww’s picture

Per Slack exchange in #contribute with @larowlan, we just fixed up MR 200 for this issue.

  • He changed the MR to point to 9.3.x as the target.
  • I force pushed a rebase of the original 2570593-bundle-classes branch relative to the HEAD of 9.3.x.
  • I then pushed a few commits to address some of the pending review feedback in 200. More to fix, so leaving NW, but I have to stop here for the night.
  • @bradjones1 then closed MR 994 to try to avoid confusion.

So I think we're full-steam ahead at 200 again. ;) I'll see if I can resolve anything else pending tomorrow...

Thanks!
-Derek

dww’s picture

Issue summary: View changes
Status: Needs work » Needs review
Issue tags: -Needs issue summary update

Status update:

  • Lots of progress.
  • Most review points are now addressed.
  • Expanded Test coverage.
  • I went through all changes with a fine-toothed comb and pushed some additional minor tweaks / cleanups / fixes.

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:

  1. https://git.drupalcode.org/project/drupal/-/merge_requests/200#note_14489 ("What happens if the preCreate sets a bundle that is altered to use a class?")
  2. https://git.drupalcode.org/project/drupal/-/merge_requests/200#note_27604 (postLoad())
  3. https://git.drupalcode.org/project/drupal/-/merge_requests/200#note_27608 (doDelete())

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:

  • Added a getEntityClass() method to Drupal\Core\Entity\EntityStorageInterface

Although 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...

Where there is a 1-1 relationship between a class and an interface, inherit from the class. Where a base class or trait is provided, use those. This way your class should inherit new methods from the class or trait when they're added.

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

dww’s picture

StatusFileSize
new33.11 KB
new33.11 KB

Although 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.

sime’s picture

Simple video to maybe sell the benefits. https://youtu.be/DtA7YZ1sFKc

dww’s picture

StatusFileSize
new42.38 KB
new40.97 KB
new40.97 KB
new11 KB

I believe everything in the MR is now addressed. Here's a test-only showing all the latest test coverage, with only commit 0b9a49968 reverted. 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

larowlan’s picture

Going to ping other FMs to say I've removed this tag

The last submitted patch, 157: 2570593-157.9_3_x.test-only.patch, failed testing. View results

dww’s picture

Yay, just as expected / advertised:
https://www.drupal.org/pift-ci-job/2165792

Testing Drupal\KernelTests\Core\Entity\BundleClassTest
F....                                                               5 / 5 (100%)

Time: 00:03.869, Memory: 6.00 MB

There was 1 failure:

1) Drupal\KernelTests\Core\Entity\BundleClassTest::testEntitySubclass
Failed asserting that 4 matches expected 2.

/var/www/html/vendor/phpunit/phpunit/src/Framework/Constraint/Equality/IsEqual.php:96
/var/www/html/core/tests/Drupal/KernelTests/Core/Entity/BundleClassTest.php:153
/var/www/html/vendor/phpunit/phpunit/src/Framework/TestResult.php:722

FAILURES!
Tests: 5, Assertions: 68, Failures: 1.

The last submitted patch, 157: 2570593-157.9_3_x.patch, failed testing. View results

Status: Needs review » Needs work

The last submitted patch, 157: 2570593-157.9_2_x.patch, failed testing. View results

dww’s picture

Status: Needs work » Needs review
StatusFileSize
new41.21 KB
new41.21 KB
new2.78 KB

Whoops. ;) @larowlan and I stand corrected. The "fix" for postLoad() actively breaks Drupal\user\Entity\Role::postLoad():

  public static function postLoad(EntityStorageInterface $storage, array &$entities) {
    parent::postLoad($storage, $entities);
    uasort($entities, 'static::sort');
  }

Once we're invoking postLoad() with $items, not $entities, that sort is only sorting the subarray, but the original $entities array 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 71a3a3b66f to 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...

dww’s picture

StatusFileSize
new41.53 KB
new41.53 KB
new3.45 KB
new5.69 KB

After 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 $entities array, 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 $items subarray 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.

berdir’s picture

Will 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 :)

larowlan’s picture

Great idea, forum is the ideal use case

claudiu.cristea’s picture

Status: Needs review » Needs work

Can we, please, also deprecate the potential use of the class key in the bundle config entities as per #84, first bullet point? It should be done now, so that we can implement that in Drupal 10

simonbaese’s picture

see #171

bradjones1’s picture

I'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.

berdir’s picture

Status: Needs work » Needs review

Yeah, 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.

simonbaese’s picture

Status: Needs review » Needs work

I am currently using the patch for Drupal 9.2.x and ran into a minor issue. In my module, I extend Node with the bundle class Project. 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

Warning: Undefined array key "label" in Drupal\Core\Entity\Plugin\DataType\Deriver\EntityDeriver->getDerivativeDefinitions() (line 108 of /var/www/html/web/core/lib/Drupal/Core/Entity/Plugin/DataType/Deriver/EntityDeriver.php) 

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

function mymodule_entity_bundle_info_alter(&$bundles) {
  if (isset($bundles['node']['project'])) {
	$bundles['node']['project']['class'] = Project::class;
  }
}
claudiu.cristea’s picture

@Berdir,

FWIW, I'm not quite convinced we need this to be on the config entity.

Please see #88 why, I think, bundle config entities should get a chance to define the class before is being altered.

bradjones1’s picture

I'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?

dww’s picture

Status: Needs work » Needs review

a) 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 class key 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

simonbaese’s picture

@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.

berdir’s picture

@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.

simonbaese’s picture

@Berdir Thanks for the advice.

aaronmchale’s picture

Also 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.

jibran’s picture

The 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?

berdir’s picture

Yes 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.

dww’s picture

StatusFileSize
new5.06 KB

It 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:

    // Invoke the right postLoad() depending on the kind of entity we are.
    $this->doPostLoad($entities);

    // Invoke all the external hooks when entities are loaded. Rest of the current function...
    // Call hook_entity_load().
    foreach ($this->moduleHandler()->getImplementations('entity_load') as $module) {
      $function = $module . '_entity_load';
      $function($entities, $this->entityTypeId);
    }
    // Call hook_TYPE_load().
    foreach ($this->moduleHandler()->getImplementations($this->entityTypeId . '_load') as $module) {
      $function = $module . '_' . $this->entityTypeId . '_load';
      $function($entities);
    }

Then EntityStorageBase::doPostLoad() is basically just:

   $entity_class = $this->getEntityClass();
   $entity_class::postLoad($this, $entities);

But ContentEntityStorageBase::doPostLoad() would become:

  $entities_by_class = $this->getEntitiesByClass($entities);
  ...

But we'd probably want the same hack to count the # of bundle classes and invoke postLoad() on the main $entities array if there's only 1 class. So I'm not really sure what all this refactoring would buy us...

catch’s picture

But we'd probably want the same hack to count the # of bundle classes and invoke postLoad() on the main $entities array if there's only 1 class. So I'm not really sure what all this refactoring would buy us...

If this is the case, i think we should go ahead with what we have.

kingdutch’s picture

Issue summary: View changes

I've updated the CR to have the isset check 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.

  1. Although this is perhaps an edge case. Developers that want to use bundle classes and perform sorting on all results are not able to achieve their goal. This is explained by a comment but it's an either OR situation.
    +++ b/core/lib/Drupal/Core/Entity/EntityStorageBase.php
    @@ -353,8 +361,23 @@ protected function preLoad(array &$ids = NULL) {
        *   Associative array of query results, keyed on the entity ID.
        */
       protected function postLoad(array &$entities) {
    -    $entity_class = $this->entityClass;
    -    $entity_class::postLoad($this, $entities);
    +    $entities_by_class = $this->getEntitiesByClass($entities);
    +
    +    // Invoke entity class specific postLoad() methods. If there's only a single
    +    // class involved, we want to pass in the original $entities array. For
    +    // example, \Drupal\user\Entity\Role::postLoad() sorts the array to enforce
    +    // role weights. We have to let it manipulate the final array, not a
    +    // subarray. However if there are multiple bundle classes involved, we only
    +    // want to pass each one the entities that match.
    +    if (count($entities_by_class) === 1) {
    +      $entity_class = array_key_first($entities_by_class);
    +      $entity_class::postLoad($this, $entities);
    +    }
    +    else {
    +      foreach ($entities_by_class as $entity_class => &$items) {
    +        $entity_class::postLoad($this, $items);
    +      }
    +    }
         // Call hook_entity_load().
         foreach ($this->moduleHandler()->getImplementations('entity_load') as $module) {
           $function = $module . '_entity_load';
    

    The workaround in that case would be to push the sorting into a hook_entity_load which still receives all the entities at once.

    This same section of code as 1. also receives $items by reference so that the input to EntityStorageBase::postLoad is mutated and available to the caller. We also loop over the results of getEntitiesByClass by reference. However, I believe, the getEntitiesByClass itself breaks the reference between the input to EntityStorageBase::postLoad and the $entity_class::postLoad call. This means that any sorting changes that a bundle specific postLoad function would not propagate as would happen for a postLoad call 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.

  2. Not an issue but something noteworthy. This changes the value with which entity constructors are called. The value is used by ContentEntityBase which doesn't propagate it and checks for a FALSEY value (which NULL still is). However, this could break entities -- parenthesis for logical grouping -- that (do not extend ContentEntityBase OR 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.
    +++ b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php
    @@ -503,9 +503,10 @@ protected function mapFromStorageRecords(array $records, $load_from_revision = F
    
         $entities = [];
         foreach ($values as $id => $entity_values) {
    -      $bundle = $this->bundleKey ? $entity_values[$this->bundleKey][LanguageInterface::LANGCODE_DEFAULT] : FALSE;
    +      $bundle = $this->bundleKey ? $entity_values[$this->bundleKey][LanguageInterface::LANGCODE_DEFAULT] : NULL;
           // Turn the record into an entity class.
    -      $entities[$id] = new $this->entityClass($entity_values, $this->entityTypeId, $bundle, array_keys($translations[$id]));
    +      $entity_class = $this->getEntityClass($bundle);
    +      $entities[$id] = new $entity_class($entity_values, $this->entityTypeId, $bundle, array_keys($translations[$id]));
         }
    
         return $entities;
    
  3. +++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php
    @@ -1128,6 +1128,23 @@ public function __unset($name) {
    +    // Always explicitly specify the bundle if the entity has a bundle class.
    +    if ($storage instanceof BundleEntityStorageInterface && ($bundle = $storage->getBundleFromClass($class_name))) {
    +      $values[$storage->getEntityType()->getKey('bundle')] = $bundle;
    +    }
    

    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.

pfrenssen’s picture

Replying to #2570593-183: Allow entities to be subclassed using "bundle classes":

  1. This either-or situation is included to prevent this from regressing in the Role module, and possible other similar cases. The documentation makes it sound like it endorses using this method for sorting entities, but this is not actually something that should be done here. Entities returned from storage have no guaranteed order. Case in point, there is no documentation for Role::loadMultiple(), RoleStorage::loadByProperties() or RoleStorage::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 the user_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.
  2. Interesting find! I agree that this will probably not be a big issue in practice.
  3. I also raised this same point in an earlier review (ref. https://www.drupal.org/project/drupal/issues/2570593#mr200-note14489), but I now think that this is not really an issue. If a developer writes Article::create(['type' => 'blog']) and then get an entity of type Article then they 100% deserve to get what they ask for :D
dww’s picture

Thanks for the reviews and comments.

Re: #183:

  1. Agreed, we could probably improve the docs on this point.
  2. Yes, good find.
  3. We've already got a follow-up for that edge case at #3230792: Decide what to do in ContentEntityStorageBase::create() if preCreate() tries to change the bundle.

Re: #184:

  1. Agreed all around. Thanks for writing this up.
  2. 👍
  3. Yup, see #3230792

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

dww’s picture

Sorry, 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.

pfrenssen’s picture

I rebased the MR, and pushed commit c729434f0 to try to improve the docs. Is that sufficient to address point 1?

Yes, this is very clear now and the point is addressed, thanks!

I think this looks great now. I would RTBC if I could :)

claudiu.cristea’s picture

Status: Needs review » Reviewed & tested by the community

Everything is addressed. Thank you!

kingdutch’s picture

Cool! 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!

alexpott’s picture

Do 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.

pfrenssen’s picture

The 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.

ghost of drupal past’s picture

A couple minor notes potentially could be addressed in followup:

  1. The new throws doxygen in EntityTypeRepositoryInterface does not make sense to me -- it might be me, of course. It's a copypaste of the existing doxygen so it's not a new problem and also quite easy to fix. The existing one says: thrown when multiple subclasses correspond to the called class. I believe this wanted to be thrown when multiple entity types correspond to the provided class a) 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.
  2. Related, entity.api.php does not emphasize two bundles should not use the same class.
  3. Do we prefer get_called_class(); to static::class? With PHP 8 introducing the staticreturn type, I prefer static::class (introduced in 5.5) but that's again very minor.
  4. "Bundle classes should extend the main entity class" -- but they can't be the main entity class then? Because is_subclass_of() is strict in this regard and if that's intentional then this also needs to be said explicitly in entity.api.php I have this feeling it'd be much more consistent if it was allowed to specify the main entity class as the bundle class but I understand that would need a little special casing in the repository, nonetheless let me table that idea.
claudiu.cristea’s picture

Status: Reviewed & tested by the community » Needs work

Setting to NW as per #190 and #192.

pfrenssen’s picture

Regarding 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.

dww’s picture

Status: Needs work » Needs review

Thanks for all the reviews.

Starting from the easy stuff, first. 😉

Re: @Charlie ChX Negyesi in #192:

  1. Moved to #3244542: Improve @throws documentation for EntityTypeRepositoryInterface. The new docs copy the existing docs. I don't want to start changing all the existing docs or it'll get flagged as scope creep.
  2. (Hopefully) improved in commit fbab41c14. Feedback / further improvements welcome, but we're running out of time for alpha deadline, and docs can always be further improved later...
  3. Sure, static::class is good. Better, in fact. 😉 Fixed in commit 6ad1ea7b6.
  4. Per @pfrenssen in #194: If you want to use the entity class, don't define a bundle subclass.

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 because private $entityClass = 'test'; is private. If it was protected, 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 property private? 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 to EntityStorageInterface? That seems like a yucky thing to have in the public API. Or would it be a protected method directly on EntityStorageBase? If all this effort is to protect child classes from setting this property directly, we again need to make it private or 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 extends ContentEntityStorageBase but tries to do naughty things? Then add a new Kernel test in core/tests/Drupal/KernelTests/Core/Entity that 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.

berdir’s picture

Yes, 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?

berdir’s picture

See \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.

dww’s picture

Since 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. 😉

kingdutch’s picture

@Kingdutch is about to post a comment with a hopefully better answer than the mess I started.

You'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 __get works is correct. So 5e949ffc0f on its own does not do what we want.

I think changing the visibility to private may create more issues than it solves. The way private and __get works 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 __get and extends this in another class that can override it's property.

The invocation and related output is as follows.

$b->getPropInA(); // value in class A.
$b->getPropInB(); // __get called. // value in class A.

$b->setPropInB(); // value in class A.
$b->getPropInA(); // set property in B.
$b->getPropInB(); // value in class B. 

So the result of making the property private is that without warning changing the entityClass value 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 raw entityClass value) will never be able to see the modified value anymore, which changes its behaviour. Additionally, if a class overrides the private property before access it will never call __get either and will still not be made aware of the deprecation.

Implementing __set as 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 __get on the base class again.

This makes implementing __get without __set a 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.

dww’s picture

Assigned: Unassigned » dww
Status: Needs review » Needs work
StatusFileSize
new13.16 KB

Okay, 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:

  1. Remove protected $entityClass entirely.
  2. Restore the magic __get() and the deprecation warning.
  3. Add a private $baseEntityClass
  4. Add a magic __set() watching 'entityClass'. If hit, trigger a deprecation and set $baseEntityClass
  5. Change EntityStorageBase::getEntityClass() implementation to see if $baseEntityClass is set, and if so, use it. Else, use $this->entityType->getClass() (the current behavior).
  6. Add test coverage of both these deprecations via Unit test per @Berdir in #197.

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.

dww’s picture

Status: Needs work » Needs review
Issue tags: +Needs tests

This 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?

Setting the entityClass property directly is deprecated in drupal:9.3.0 and has no effect in drupal:10.0.0. Use @todo instead. See https://www.drupal.org/node/3191609

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

berdir’s picture

You 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.

dww’s picture

Assigned: dww » Unassigned
Issue tags: -Needs tests

Those 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:

  • We no longer automatically call $this->entityType->getClass() in the constructor, so many tests no longer need to mock a return for it at all.
  • However, we now call it every time someone needs to know the class name to use, so some tests need to expect it twice, not once.

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->baseEntityClass is still NULL and we call $this->entityType->getClass(), we could save the value into baseEntityClass so that we don't call getClass() 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) or atleastOnce()?

Thanks everyone, almost there! 🎉🤞🙏🤞😉
-Derek

dww’s picture

Issue summary: View changes

Updating 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...

dww’s picture

p.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).

kingdutch’s picture

Status: Needs review » Needs work

Moving to Needs Work for a nitpick and a question. Other than that this is RTBC for me :D

dww’s picture

Status: Needs work » Needs review

Thanks for the review, @Kingdutch!

Back to NR.

kingdutch’s picture

Status: Needs review » Reviewed & tested by the community

I don't see a button to resolve my own threads, but the changes by dww address both my points :D

Looks good to me!

dww’s picture

Issue summary: View changes
StatusFileSize
new57.99 KB

We had quite a Slack thread about changes to the code examples in the CR, the benefits of duck typing vs. checking instanceof and 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.

larowlan’s picture

Issue summary: View changes

Adding issues credits. Credited @sime and @e0ipso for their advocacy work (youtube video, blog post) as that's important too.

  • larowlan committed fc76f6e on 9.3.x authored by pfrenssen
    Issue #2570593 by dww, pfrenssen, markhalliwell, moshe weitzman, JeroenT...
larowlan’s picture

Status: Reviewed & tested by the community » Fixed

Merged 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!

larowlan’s picture

Also, has to be close to the most comprehensive change notice ever, kudos to all the folks involved

dww’s picture

🎉🎉🎉🎉❤️ Thanks!!!

I think this is one of the biggest features of Drupal 9.3, even if its not user-facing - Developers are users too!

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.

has to be close to the most comprehensive change notice ever

Thanks. 😊 Glad we all landed on a CR we can all be happy with / proud of.

Yay everyone! 🎉

Thanks again,
-Derek

chi’s picture

A bundle class must be a subclass of the base entity class, such as \Drupal\node\Entity\Node.

You could define an abstract base class for your project, define subclasses for every bundle and register them all.

These two statements contradict each other as there is no way in PHP to extend multiple classes.

larowlan’s picture

@Chi so long as the parent extends from Node, it works. I'll try to make the CR more explicit in that regard

Node
-> AbstractClass
  -> ConcreteClass
larowlan’s picture

chi’s picture

@larwlan It makes sense. Thank you.

pfrenssen’s picture

I'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!

aaronmchale’s picture

This 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! 😀

timodwhit’s picture

Thank you all so much! This was fun to track and see the progress on! Well done and very exciting for the DX!

dww’s picture

Priority: Normal » Major

Thanks 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-...

Feature requests may be marked major based on their value to the project overall and/or the scope of work to complete them. Major feature requests are often identified by core product managers, initiative coordinators, or subsystem or topic maintainers.

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.

joachim’s picture

This 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.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

dieterholvoet’s picture

I created a follow up issue with an idea of how EntityInterface::loadMultiple and EntityInterface::load should work when called on a bundle class, input is always welcome: https://www.drupal.org/project/drupal/issues/3252421

siggy’s picture

StatusFileSize
new29.36 KB

Unofficial patch for 8.9.20 when also applying the patch : https://www.drupal.org/node/2765065

alexpott’s picture

We 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