Problem/Motivation

Field storage and entity type CRUD operations (e.g. install a new field in an update function, delete an entity type in an update function, etc.) need the ability to initialize a table mapping "on demand", mostly because they need to work with the last installed definitions instead of the ones living in code.

This is currently done by switching the entity type definition in the storage itself just for the duration of the CRUD operation, which is a "documented hack" with tons of @todos saying "fix this in #2274017: Make SqlContentEntityStorage table mapping agnostic ".

Proposed resolution

- Move the table mapping initialization code from \Drupal\Core\Entity\Sql\SqlContentEntityStorage::getTableMapping to the table mapping itself.

- Make the table mapping aware of the entity table names, and also generate default table names if the entity type doesn't provide them, just like the storage does.

Remaining tasks

Review.

User interface changes

Nope.

API changes

Nope.

Data model changes

Nope.

Comments

amateescu created an issue. See original summary.

amateescu’s picture

Status: Active » Needs review
StatusFileSize
new24.54 KB

Here's a patch lifted from #2274017: Make SqlContentEntityStorage table mapping agnostic which, unlike that one, contains only the minimal changes needed in order to be able to instantiate the table mapping from outside the storage.

tstoeckler’s picture

I think this makes a lot of sense. We had discussed where to put the initialization code when originally introducing the table mapping and at the time the table mapping was thought to be more of a value object without any logic, which is why we put it in the storage handler. It has since shifted its purpose a bit and the code is now a bit alien to the storage handler. Also, any way to remove complexity from the storage handler - even if it is simply moved verbatim to a different class - is appreciated as the storage handler is quite a monster.

All this is regardless of #2274017: Make SqlContentEntityStorage table mapping agnostic . I just wanted to provide some reasons why I think this makes sense as a separate issue regardless of the progress we make there.

Not RTBC-ing because, as this is both a very complex and a highly critical part of core, I want to scrutinize the moved code and its test coverage a bit more before RTBCing. Didn't see anything noteworthy from a cursory look, though.

plach’s picture

If we are moving towards deprecating the ability to specify shared table names in the entity type definition, which is a Good Thing™, and we are also decoupling the instantiation of the table mapping from the storage handler, we should retain the ability for individual entity types to determine the shared table names.

I'm wondering whether it would make sense to use a factory, as we do for entity queries, so that an entity type can provide an alternative table mapping with its own shared table names.

Additionally, a factory may help us in keeping the instantiation logic out of the table mapping itself, which would keep it as a value object (more or less). I remember @yched suggesting that maybe we would need a separate table mapping class for each layout we support (base, translatable, revisionable, both), but maybe encapsulating the instantiation logic in a factory would be enough for now, and could still allow us to implement that later without BC issues.

Thoughts?

amateescu’s picture

@plach, that sounds interesting and I'm curious where exactly would developers be able to specify the shared table names with your proposal :)

Like @tstoeckler said in #3, the actual goal of these issues should be to lower the complexity of the storage and storage_schema classes, and allow us to fix all those outstanding @todos about "do this in a better way when we're able to instantiate a table mapping from outside the storage".

plach’s picture

I'm curious where exactly would developers be able to specify the shared table names with your proposal

Well, by being able to specify a table mapping for each entity type we would be able to individually override table names, either by assigning a different value on construction or by doing that directly in the factory method, I didn't think about the details honestly.

[...] the actual goal of these issues should be to lower the complexity of the storage and storage_schema classes, and allow us to fix all those outstanding @todos about "do this in a better way when we're able to instantiate a table mapping from outside the storage".

Agreed, so coming up with a proper way to instantiate the table mapping seems definitely in scope to me :)

amateescu’s picture

Ok then, wanna go ahead and try to work on a PoC patch? Probably based on #2 because that one already does a lot of the heavy lifting needed.

plach’s picture

Ok then, wanna go ahead and try to work on a PoC patch?

Nope :)

I don't think I have time to work on that, right now. Feel free to move on with the current approach, if you don't have time or will to take #6 into account, but please make sure trying it later doesn't imply a BC break, by using @internal or whatever is needed ;)

amateescu’s picture

@plach, I don't mind working on it but the details matter here, that's why I asked how do you see the factory approach in #5. The issue is that the entity query factory service has been deprecated since 8.3 and the instantiation logic has been moved to the storage handler.

If we apply that pattern here it would mean that it's still the storage's responsibility to instantiate the table mapping, which is exactly what we're trying to change in this issue :)

plach’s picture

Sorry, I misunderstood your earlier comments :)

I wasn't aware about the Entity Query factory deprecation. Maybe it could still be acceptable if we delegated the table mapping factory instantiation to the storage handler? This would only pass the entity type id and the entity type manager to the factory, while the entity type and field storage definitions would still be parameters passed to the instantiation method, e.g.

/** @var TableMappingFactoryInterface $factory */
$factory = $storage->getTableMappingFactory();
$table_mapping = $factory->getTableMapping($entity_type, $storage_definitions);

Edit: ::getTableMapping() would only check that the entity type id matches the one the factory was initialized with.

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.

amateescu’s picture

StatusFileSize
new23.79 KB
new6.85 KB

I thought a lot in the past few days about how to proceed with the factory approach from #4 / #10, but I feel that it really needs a proper discussion on its own, so I will open a dedicated issue for it.

In the meantime, this patch is pretty much the cornerstone of my plan to clean up the entity storage API, so any reviews are very much appreciated :)

To answer #8, this patch does not prevent in any way the possibility of having a dedicated factory for the table mapping (or separate table mapping classes), it simply moves the table instantiation code from the storage class to the default table mapping itself.

A happy consequence of this move is that it makes DefaultTableMapping an actual value object, since its state can no longer be changed from the outside and immutability is one of the basic characteristics of a value object.

Here's a new patch which removes all the unnecessary changes, hopefully making it easier to review.

amateescu’s picture

Since @tstoeckler said in #3 that he'd like to understand the test changes better, here's an overview of a couple of points that might seem strange before thinking everything through:

  1. +++ b/core/tests/Drupal/Tests/Core/Entity/Sql/DefaultTableMappingTest.php
    @@ -41,20 +41,20 @@ public function testGetTableNames() {
         $table_mapping = new DefaultTableMapping($this->entityType, []);
    -    $this->assertSame([], $table_mapping->getTableNames());
    +    $this->assertSame(['entity_test'], $table_mapping->getTableNames());
    

    This change and the similar ones below it are needed because now the table mapping will *always* have a base table.

    I guess this can be seen as a behavior change, but what would be the point of an empty table mapping in the first place?

  2. +++ b/core/tests/Drupal/Tests/Core/Entity/Sql/DefaultTableMappingTest.php
    @@ -431,7 +421,7 @@ public function providerTestGetFieldTableName() {
    -    $data[] = [['revision' => 'revision_table'], 'revision_table'];
    +    $data[] = [['revision' => 'revision_table'], 'entity_test'];
    

    This is another change that might look awkward, but the "new" behavior is actually in line with the fact that revision metadata fields are the only ones that are stored in the revision table and nowhere else.

    Also, testGetFieldTableName() and its data provider, providerTestGetFieldTableName() are not really testing what they think they are.

    The point of TableMappingInterface::getFieldTableName() is to return the proper table name for various field definitions: (non-)revisionable, (non-)translatable and single/multi value, but right now testGetFieldTableName() is basically testing DefaultTableMapping::setFieldNames() :)

berdir’s picture

Various comments below, also on parts of the code that is just moved here. I'm not suggesting to change any of that, just trying to find my way back into the topic by going through this a bit and connecting some pieces and thinking out loud. Feel free to ignore anything that is not strictly relevant in your opinion and/or get back to it later in other issues.

  1. +++ b/core/lib/Drupal/Core/Entity/Sql/DefaultTableMapping.php
    @@ -87,6 +115,120 @@ class DefaultTableMapping implements TableMappingInterface {
    +    // @todo Remove table names from the entity type definition in
    +    //   https://www.drupal.org/node/2232465.
    +    $this->baseTable = $this->entityType->getBaseTable() ?: $this->entityType->id();
    +    if ($revisionable) {
    

    Commented over there, this is just moved but I honestly think it's more than unlikely that we will be able to do this any time soon. Best we can hope for for now would be to offer defaults that can easily be overridden?

    And if we'd really get there at some point then I guess they would still remain as a read-only API on this object, so this is yet another step where this is going into the right direction.

  2. +++ b/core/lib/Drupal/Core/Entity/Sql/DefaultTableMapping.php
    @@ -87,6 +115,120 @@ class DefaultTableMapping implements TableMappingInterface {
    +    $key_fields = array_values(array_filter([$id_key, $revision_key, $bundle_key, $uuid_key, $langcode_key]));
    

    Interesting that we here treat only very specific keys as actual keys (whitelist) while \Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema::getSharedTableFieldSchema() uses a blacklist approach to make all keys except a few not null. I guess things there will change with/after #2841291: Fix NOT NULL handling in the entity storage and 'primary key' changes when updating the storage definition of an identifier field?

    Also wondering if there is a specific reason that the bundle is in this list/the base table, I don't think there is a good reason for that?

  3. +++ b/core/lib/Drupal/Core/Entity/Sql/DefaultTableMapping.php
    @@ -87,6 +115,120 @@ class DefaultTableMapping implements TableMappingInterface {
    +      // performant queries. This means that only the UUID is not stored on
    +      // the data table.
    

    I've always wondered if there is a reason for that, avoiding duplication of the fairly long field?

    This is one of the reasons why I got somewhat stuck on #2875033: Optimize joins and table selection in SQL entity query implementation as it resulted in a regression for a pretty common entity query.

  4. +++ b/core/lib/Drupal/Core/Entity/Sql/DefaultTableMapping.php
    @@ -227,6 +365,12 @@ public function getFieldColumnName(FieldStorageDefinitionInterface $storage_defi
    +   *   before Drupal 9.0.0. There will be no replacement for it because the
    +   *   default table mapping is now able to be initialized on its own.
    

    If we would move this to a factory then I guess this would no longer be true but I guess we'd instead inject it in the constructor to keep it immutable so just wrong on the details.

  5. +++ b/core/tests/Drupal/Tests/Core/Entity/Sql/DefaultTableMappingTest.php
    @@ -41,20 +41,20 @@ public function testGetTableNames() {
         // The storage definitions are only used in getColumnNames() so we do not
         // need to provide any here.
         $table_mapping = new DefaultTableMapping($this->entityType, []);
    

    It is apparently still not actually required to pass field definitions but at least the comment is no longer correct like that?

    And as you said, this is not really testing that much

  6. +++ b/core/tests/Drupal/Tests/Core/Entity/Sql/DefaultTableMappingTest.php
    @@ -362,47 +362,37 @@ public function testGetFieldTableName($table_names, $expected) {
    -    \Drupal::setContainer($container);
    +    $this->entityType
    +      ->expects($this->any())
    +      ->method('getRevisionMetadataKeys')
    +      ->willReturn([]);
     
    

    Nice cleanup of the mocking required here.

    I guess the EntityType classes are not quite plain value objects enough to just use them directly of mocking it and changing that would also result in large changes to the whole class.

    Part of the mocking seems a bit backwards to where we want to go in regards to #2232465: Deprecate table names from entity definitions, e.g. defining isTranslatable/isRevisionble based on whether we have the respective tables but we can change that later.

  7. +++ b/core/tests/Drupal/Tests/Core/Entity/Sql/SqlContentEntityStorageSchemaTest.php
    @@ -389,13 +389,22 @@ public function testGetSchemaBase() {
    +    $this->entityType->expects($this->any())
    +      ->method('getRevisionMetadataKeys')
    +      ->will($this->returnValue([]));
    

    Yep, exactly the BC layer in there is I guess one of if not the main problem to using them as non-mocked value objects in these tests. Too bad :)

    (Although I'm wondering if we could instead provide explicit values to skip the BC layer)

amateescu’s picture

Thanks for re-starting the discussion :)

Re #14:

  1. Commented in #2232465: Deprecate table names from entity definitions as well. As for how it affects this patch, it just depends on which one gets in first, so this or the other will need to be updated either way.
  2. The usage of entity keys will hopefully disappear from SqlContentEntityStorageSchema::getSharedTableFieldSchema(), so nothing to worry about here :)

    I'm pretty sure we keep the bundle in the base table for historical reasons, for queries like "give me all nodes of this content type". We could open a separate issue to discuss if that still makes sense with the current core table layout.

  3. I had to dig up a lot of history, and it seems that #1498674-20: Refactor node properties to multilingual says that it was decided in #1498634: [meta] Define revision/translation SQL schema for core entities (no patch) to only have the uuid on the base table. As for why that decision was made.. I guess we need to ask @plach :)
  4. Yes, if we want to have a factory class for instantiating table mappings, setFieldNames() and setExtraColumns() will no longer be @deprecated, just @internal, so we can still say that the table mapping is a value object.
  5. IMO the comment is still accurate, and enforcing a non-empty list of storage definitions would be another behavior change that I'd like to avoid in this issue..
  6. and 7. I'm rewriting that test method and its provider in #2955442: Add a way to get all the tables in which a field is stored from TableMappingInterface, so don't waste too much time digging into it :)
amateescu’s picture

StatusFileSize
new23.97 KB
new830 bytes

@Berdir convinced me to change the comment for #14.5 :D

berdir’s picture

Status: Needs review » Reviewed & tested by the community

Thanks, I think this makes sense then as a first step, we have many other/related issues to make things nicer.

Status: Reviewed & tested by the community » Needs work

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

amateescu’s picture

Status: Needs work » Reviewed & tested by the community

Random testbot failure.

plach’s picture

Status: Reviewed & tested by the community » Needs review
  1. +++ b/core/lib/Drupal/Core/Entity/Sql/DefaultTableMapping.php
    @@ -25,6 +25,34 @@ class DefaultTableMapping implements TableMappingInterface {
    +  /**
    +   * The table that stores revision field data if the entity supports revisions.
    +   *
    +   * @var string
    +   */
    +  protected $revisionDataTable;
    

    This is missing a bit: "[...] and has multilingual support."

  2. +++ b/core/lib/Drupal/Core/Entity/Sql/DefaultTableMapping.php
    @@ -87,6 +115,120 @@ class DefaultTableMapping implements TableMappingInterface {
    +    $this->initializeMapping();
    

    This looks potentially BC-breaking to me: what if a DefaultTableMapping class is instantiated and expected to be empty/clean?

    Very unlikely scenario, I guess, but what if we added an @internal static ::create() method encapsulating the logic of ::initializeMapping() instead?

    DefaultTableMapping::create($entity_type, $storage_definitions);
    

    This would allow to keep the constructor "clean", and would be closer to an actual factory method.

  3. +++ b/core/lib/Drupal/Core/Entity/Sql/DefaultTableMapping.php
    @@ -227,6 +365,12 @@ public function getFieldColumnName(FieldStorageDefinitionInterface $storage_defi
    +   * @internal
    
    @@ -254,6 +398,12 @@ public function getExtraColumns($table_name) {
    +   * @internal
    

    Isn't @deprecated enough here? Are we adding those in case we need to un-deprecate these methods, if we end up introducing a factory? In that case I'd rely on constructor parameters/initial values and keep those methods as @deprecated.

amateescu’s picture

StatusFileSize
new24 KB
new624 bytes

Re #20:

1. Fixed.

2. Adding a static create() method means that we also need to add getters for base/data/revision/revision data tables, and I wanted to minimize the changes in this issue/patch as much as possible, because all the other work on entity storage depends on it.

So.. how about we move all the discussion about table mapping instantiation to a new issue? :)

3.

Are we adding those in case we need to un-deprecate these methods, if we end up introducing a factory?

That's right. And, same as above, we can figure out what needs to happen with these two methods in the instantiation issue. For the moment, I just took the safest approach.

plach’s picture

Adding a static create() method means that we also need to add getters for base/data/revision/revision data tables

Why is that so? A static method can access protected and private members of a class instance. You just need to instantiate a $table_mapping in the ::create() method and replace all occurrences of $this with $table_mapping:

  public static function create(ContentEntityTypeInterface $entity_type, array $storage_definitions) {
    $table_mapping = new static($entity_type, $storage_definitions);
    $revisionable = $table_mapping->entityType->isRevisionable();
    $translatable = $table_mapping->entityType->isTranslatable();

    // Rest of the code from ::initializeMapping()...

    return $table_mapping;
  }   
That's right. And, same as above, we can figure out what needs to happen with these two methods in the instantiation issue. For the moment, I just took the safest approach.

I was asking because both @xjm and I found weird that we are adding both the @deprecated and the @internal tags: @deprecated should be enough to tell people they are not supposed to use those methods anymore.

amateescu’s picture

StatusFileSize
new24.7 KB
new10.57 KB

Ohh, that's right, I forgot we can do that!

Status: Needs review » Needs work

The last submitted patch, 23: 2916018-23.patch, failed testing. View results

amateescu’s picture

Status: Needs work » Needs review

Interesting failures :) Since we are now supposed to instantiate the table mapping via the new create() method, we also need to update all the test coverage to use it. @plach, do you agree?

plach’s picture

Interesting indeed! I had a closer look and I discovered one thing that I didn't realize earlier: in HEAD the table mapping retrieves the base table names internally in ::getFieldTableName(), which makes sense, since it should ultimately be responsible to provide this knowledge to the outside world :)

Hence I think we should always initialize the table names upon construction, instead of doing that in the create method. This should be more compatible with the HEAD behavior and in line with the plan of deprecating the table name entity type keys.

In fact, if we do so, we should be able to revert all changes in DefaultTableMappingTest, except for the mocking on the entity type keys.

amateescu’s picture

StatusFileSize
new22.76 KB
new4.83 KB

I also thought about initializing the table names in the constructor, not sure why I didn't do it at the time but I'm glad you brought it up :)

Status: Needs review » Needs work

The last submitted patch, 27: 2916018-27.patch, failed testing. View results

amateescu’s picture

Status: Needs work » Needs review
StatusFileSize
new23.69 KB
new1.26 KB

There we go.

berdir’s picture

Status: Needs review » Reviewed & tested by the community

I think the review has been addressed and this is RTBC again.

plach’s picture

Looks great, thanks, I'm planning to commit this tomorrow!

+++ b/core/tests/Drupal/Tests/Core/Entity/Sql/DefaultTableMappingTest.php
@@ -362,38 +362,35 @@ public function testGetFieldTableName($table_names, $expected) {
+      ->willReturn(isset($table_names['data']) ? TRUE : FALSE);
...
+      ->willReturn(isset($table_names['revision']) ? TRUE : FALSE);

If you really wish to listen to my OCD, we could simplify these to just ->willReturn(isset($table_names['revision'])); ;)

plach’s picture

Saving credit

amateescu’s picture

StatusFileSize
new23.66 KB
new888 bytes

Sure, why not :)

  • plach committed 295e297 on 8.6.x
    Issue #2916018 by amateescu, plach, Berdir, tstoeckler: Allow the table...
plach’s picture

Status: Reviewed & tested by the community » Fixed

Committed 295e297 and pushed to 8.6.x. Thanks!

andypost’s picture

+++ b/core/lib/Drupal/Core/Entity/Sql/DefaultTableMapping.php
@@ -227,6 +378,10 @@ public function getFieldColumnName(FieldStorageDefinitionInterface $storage_defi
+   * @deprecated in Drupal 8.6.0 and will be changed to a protected method
...
   public function setFieldNames($table_name, array $field_names) {

@@ -254,6 +409,10 @@ public function getExtraColumns($table_name) {
+   * @deprecated in Drupal 8.6.0 and will be changed to a protected method
...
   public function setExtraColumns($table_name, array $column_names) {

needs follow-up and CR

plach’s picture

Right, sorry.

I forgot about those because this is fairly internal code and I don't expect it to be heavily used directly.

plach’s picture

On a second thought I'm not sure what to recommend in the CR: the new ::create() method is @internal and should not be used directly as well. The only legitimate way to instantiate a table mapping is still through the storage at the moment.

andypost’s picture

I mean that @deprecated needs link to CR https://www.drupal.org/core/deprecation#how-method

plach’s picture

Yes, but I'm not sure what an hypothetical CR would day: those setters were kind of internal before...

amateescu’s picture

@andypost, there's nothing really to say in a CR for this specific issue. We will have to write a big one for #2960147: Finalize the entity storage instead :)

plach’s picture

Issue tags: +@deprecated

Adding the deprecated tag to make sure we don't lose track of this issue and reference it in the CR, thanks for the reminder @andypost!

Status: Fixed » Closed (fixed)

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

amateescu’s picture

We forgot to update the storage to not generate table names by itself: #2981220: SqlContentEntityStorage should use the table names provided by its table mapping