Problem/Motivation

The purpose of TableMappingInterface is to be the only API you need to interact with if you want to get database-specific information about an entity type stored in SQL storage. However, there is no way of getting the table which stores individual revisions, if the SQL storage chooses to use separate tables for default / all revisions.

The consequence is that all places that need this information are forced to use core's table mapping implementation (DefaultTableMapping), which makes the interface kind of useless and prevents alternative SQL storage / table mapping implementations.

Proposed resolution

Add a getAllFieldTableNames() method to TableMappingInterface and implement it in DefaultTableMapping

Remaining tasks

Discuss / review.

User interface changes

Nope.

API changes

API addition: a new method is added to TableMappingInterface. This change is allowed by our BC policy since we have a base class for it: DefaultTableMapping

Data model changes

Nope.

Comments

amateescu created an issue. See original summary.

amateescu’s picture

Status: Active » Needs review
StatusFileSize
new5.8 KB

Here's a patch for my proposal. We basically need to copy the code from TableMappingInterface::getFieldTableName() and make it look for revision data tables first.

There's no need for a test-only patch because this is new (missing) functionality.

amateescu’s picture

StatusFileSize
new14.62 KB
new9.52 KB

While working through updating all the non-test usages of \Drupal\Core\Entity\Sql\DefaultTableMapping::getDedicatedRevisionTableName() to use new API, it became apparent that we also need to promote DefaultTableMapping::requiresDedicatedTableStorage() up to the interface.

Alternative SQL storage table mapping implementations will be able to return FALSE from that method, which should make entity query and views not try to do any additional joins.

Entity query's code is already quite good in using the D8 API, Views however is still very problematic, but at least we have a dedicated issue for it: #2079019: Make Views use SqlEntityStorageInterface.

Status: Needs review » Needs work

The last submitted patch, 3: 2955442-3.patch, failed testing. View results

jibran’s picture

\Drupal\Core\Entity\Sql\DefaultTableMapping gives me headache.

No way to get the dedicated revision table of a field from TableMappingInterface

Isn't \Drupal\Core\Entity\Sql\DefaultTableMapping::getDedicatedRevisionTableName let us do that?

  1. +++ b/core/lib/Drupal/Core/Entity/Query/Sql/Tables.php
    @@ -99,9 +99,9 @@ public function addField($field, $type, $langcode) {
    -      // If there is revision support, only the current revisions are being
    ...
    +      // If there is revision support, all revisions are being queried, and the
    

    Just curious when did this change? Also, the updated comment doesn't make sense to me.

  2. +++ b/core/lib/Drupal/Core/Entity/Query/Sql/Tables.php
    @@ -349,21 +349,41 @@ protected function ensureEntityTable($index_prefix, $property, $type, $langcode,
    -      if ($field->getCardinality() != 1) {
    ...
    +      if ($table_mapping->requiresDedicatedTableStorage($field_storage_definition)) {
    

    We need comment here to explain what is going on.

  3. +++ b/core/modules/views/src/EntityViewsData.php
    @@ -286,7 +285,7 @@ public function getViewsData() {
    -          $table = $table_mapping->getDedicatedDataTableName($field_definition->getFieldStorageDefinition());
    +          $table = $table_mapping->getFieldTableName($field_definition->getName());
    
    @@ -299,7 +298,7 @@ public function getViewsData() {
    -            $revision_table = $table_mapping->getDedicatedRevisionTableName($field_definition->getFieldStorageDefinition());
    +            $revision_table = $table_mapping->getFieldRevisionTableName($field_definition->getName());
    

    We'd need a change record for this.

jibran’s picture

Never mind my question it is already answered in IS.

which makes the interface kind of useless and prevents alternative SQL storage / table mapping implementations.

amateescu’s picture

Status: Needs work » Needs review
Issue tags: +Needs change record
StatusFileSize
new13.92 KB
new1.87 KB

This should fix the fails.

Re #5:

Isn't \Drupal\Core\Entity\Sql\DefaultTableMapping::getDedicatedRevisionTableName let us do that?

The whole point of this issue, as written in the IS, is that we want to be able to rely only on the interface, not on core's default implementation of it.

1. There is no "change", the existing comment is inaccurate. But it's not really in the scope of this issue so I reverted that change.
2. I think the method name requiresDedicatedTableStorage() is pretty self-explanatory. Again, that's no functional change, just code cleanup.
3. Yes, we need a change record for this issue, but first we need to agree on the solution.

Status: Needs review » Needs work

The last submitted patch, 7: 2955442-7.patch, failed testing. View results

amateescu’s picture

Status: Needs work » Needs review

Hmm, those fails are.. interesting, but I'll wait for an approval of the plan before digging further.

plach’s picture

In general I'm onboard with the proposed solution and the end goal, however I'm not sure about a) this being a bug and not a task and b) this targeting 8.5.x. This does not seem the kind of change allowed in a patch release.

Some more specific thoughts:

  1. +++ b/core/lib/Drupal/Core/Entity/Sql/DefaultTableMapping.php
    @@ -182,6 +182,59 @@ public function getFieldTableName($field_name) {
    +  public function getFieldRevisionTableName($field_name) {
    

    Since this and its data table counterpart are becoming the new canonical way to retrieve this info, can we add static caching to both?

  2. +++ b/core/lib/Drupal/Core/Entity/Sql/DefaultTableMapping.php
    @@ -182,6 +182,59 @@ public function getFieldTableName($field_name) {
    +      // place where field revision data is stored, otherwise the base table is
    

    "... otherwise the revision table"

  3. +++ b/core/lib/Drupal/Core/Entity/Sql/DefaultTableMapping.php
    @@ -182,6 +182,59 @@ public function getFieldTableName($field_name) {
    +      // responsible for storing field data. Revision metadata is an exception
    +      // as it's stored only in the revision table.
    

    I think we can remove the last sentence in this case.

  4. +++ b/core/lib/Drupal/Core/Entity/Sql/DefaultTableMapping.php
    @@ -182,6 +182,59 @@ public function getFieldTableName($field_name) {
    +      // Collect field columns.
    +      $field_columns = [];
    +      foreach (array_keys($storage_definition->getColumns()) as $property_name) {
    +        $field_columns[] = $this->getFieldColumnName($storage_definition, $property_name);
    +      }
    +
    +      foreach (array_filter($table_names) as $table_name) {
    +        $columns = $this->getAllColumns($table_name);
    +        // We assume finding one field column belonging to the mapping is enough
    +        // to identify the field table.
    +        if (array_intersect($columns, $field_columns)) {
    +          $result = $table_name;
    +          break;
    +        }
    +      }
    

    If I'm not mistaken this logic is common to both methods, can we move it into a separate protected helper method?

  5. +++ b/core/lib/Drupal/Core/Entity/Sql/TableMappingInterface.php
    @@ -120,4 +120,32 @@ public function getFieldColumnName(FieldStorageDefinitionInterface $storage_defi
    +  public function requiresDedicatedTableStorage(FieldStorageDefinitionInterface $storage_definition);
    

    I can see why this could be moved to the interface, it's a generic enough concept, however I'm not sure it's necessary. If I'm not mistaken it'd be used only in the EQ backend: maybe knowing whether the field is not stored in the primary table would be enough?

    I think it would be useful to abstract the concept of primary table instead, i.e. where most field data is stored. In the core table layout, this primary table would be the data table for translatable entity types and the base table for non-translatable entity types.

amateescu’s picture

StatusFileSize
new17.24 KB
new9.4 KB

Thanks for reviewing!

Re #10:

  1. Good point, added static caches for both of them.
  2. Fixed.
  3. Fixed.
  4. Fixed.
  5. I think it would be useful to abstract the concept of primary table instead, i.e. where most field data is stored. In the core table layout, this primary table would be the data table for translatable entity types and the base table for non-translatable entity types.

    While having a method for getting the primary table (and also one for the primary revision table) would be very useful, I assume the usage you have in mind is for EQ and Views to do a check like: "if this field is not stored in the primary table, it uses a dedicated one so do this other logic". However, this won't work for special fields like uuid, which is only stored in the base table even when the entity type is translatable and similarly for revision metadata fields which are only stored in the base revision table.

    So I still think that having at least one method which tells you that a field is stored in the shared tables or not is generally useful :)

I also figured out the problem with the failing tests, we're hitting another bug there (#2705205: Improve test coverage around updating table mapping after a new field storage definition is added) so I reverted those changes and left a bunch of @todos in place.

Status: Needs review » Needs work

The last submitted patch, 11: 2955442-11.patch, failed testing. View results

plach’s picture

I assume the usage you have in mind is for EQ and Views to do a check like: "if this field is not stored in the primary table, it uses a dedicated one so do this other logic".

I'm not sure about the Views code but the EQ code at first sight seems to distinguish between simple and complex query, where the complex case implies One (or more) join(s). My assumption was that it didn't to know whether the field lives in a non-primary shared table or dedicated table, any of the two would trigger the complex case. Of course I might be wrong, but I'd like to verify if that's the case, because it would be a shame to needlessly generalize the concept of dedicated table.

amateescu’s picture

Status: Needs work » Needs review
StatusFileSize
new8.81 KB
new8.61 KB

My assumption was that it didn't to know whether the field lives in a non-primary shared table or dedicated table, any of the two would trigger the complex case.

I just checked and you're right! The code in \Drupal\Core\Entity\Query\Sql\Tables is needlessly complex and tries to make D7-style distinctions between shared and dedicated tables, but I think it can be refactored to rely more on TableMappingInterface. I won't be easy, but doable :)

In other news, #2705205: Improve test coverage around updating table mapping after a new field storage definition is added actually prevents us from doing any conversion to the new method, so I've removed all those hunks from the patch and will open a followup issue for them.

amateescu’s picture

While looking through EQ's code, I found #2956411: Simplify \Drupal\Core\Entity\Query\Sql\Tables::addField() a little, if anyone following this issue wants to take a look :)

plach’s picture

Status: Needs review » Reviewed & tested by the community
Issue tags: +Needs followup

Looks good!

We still need CR and follow-up.

plach’s picture

Title: No way to get the dedicated revision table of a field from TableMappingInterface » Add a way to get the dedicated revision table of a field from TableMappingInterface
Version: 8.5.x-dev » 8.6.x-dev
Category: Bug report » Task
Priority: Normal » Major

Also

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 14: 2955442-14.patch, failed testing. View results

amateescu’s picture

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

+1

amateescu’s picture

Status: Reviewed & tested by the community » Needs review
StatusFileSize
new4.75 KB

I've been re-reading all the entity storage issues in the past few days and, based on #2326719-28: Move pseudo-private table mapping functions from ContentEntityDatabaseStorage to public API of DefaultTableMapping, I think we need a different solution here instead of hard-coding the revisionable concept in a dedicated method.

So here's a new patch, applied on top of #2916018-12: Allow the table mapping to be initialized from outside the storage. Test coverage is still WIP but it would be nice to get feedback on the new approach first.

amateescu’s picture

StatusFileSize
new44.41 KB
new24.09 KB
new19.87 KB

And here's the test coverage. Also posting a combined patch with the dependency (#2916018: Allow the table mapping to be initialized from outside the storage) to check that everything still works as expected.

The last submitted patch, 22: 2955442-22-combined.patch, failed testing. View results

berdir’s picture

  1. +++ b/core/lib/Drupal/Core/Entity/Sql/DefaultTableMapping.php
    @@ -104,6 +104,16 @@ class DefaultTableMapping implements TableMappingInterface {
       /**
    
    @@ -276,22 +286,37 @@ public function getFieldNames($table_name) {
    +    $cache_key = $field_name
    +      . '.' . ($default_revision_only ? 'default_revision' : 'all_revisions')
    +      . '.' . ($default_language_only ? 'default_language' : 'all_languages');
    +
    +    if (isset($this->fieldStorageDefinitions[$field_name]) && !isset($this->fieldTableNames[$cache_key])) {
    

    Somehow not fully happy about the key, thought about a nested array instead but I guess a single key with a clear label is useful when debugging things.

  2. +++ b/core/lib/Drupal/Core/Entity/Sql/TableMappingInterface.php
    @@ -108,16 +108,22 @@ public function getReservedColumns();
    -   * Gets the table name for a given column.
    +   * Gets the table name for a given field.
    

    Noticed this too yesterday ;)

  3. +++ b/core/lib/Drupal/Core/Entity/Sql/TableMappingInterface.php
    @@ -108,16 +108,22 @@ public function getReservedColumns();
    -   *   The name of the entity field to return the column mapping for.
    +   *   The name of the entity field to return the table name for.
    +   * @param bool $default_revision_only
    +   *   (optional) Whether to return the table which stores the default revisions
    +   *   (TRUE) or all revisions (FALSE). Defaults to TRUE.
    +   * @param bool $default_language_only
    +   *   (optional) Whether to return the table which stores the default languages
    +   *   (TRUE) or all translations (FALSE). Defaults to TRUE.
        *
    

    Writing this the second time because somehow I lost my previous comment.

    I'm rather confused by the variable names and descriptions.

    One part is the "only" suffix, that kind of sounds like setting it to FALSE returns more than one value to me.

    And while default revision makes sense as that's how the storage works, default_language IMHO does not. We don't store the default language (translation) in a different place than other others.

    It's also not really about translatable, as we actually store all fields in the data table, translatable or not.

    I'm not quite sure how to name it either. Internally we talk about base/data tables, but I suppose you'd prefer to consider that an implementation detail and different storages might handle things differently?

    Again revision is pretty clear, at least for configurable fields. But the second argument actually only makes sense for fields that are stored in multiple tables, which is for the most part only keys/metadata fields. Put different,y what is the expected return value of getFieldTableName('title', TRUE, TRUE) ? Right now in HEAD ,that returns 'node_field_data' because that is where it is stored. But didn't I ask for default translation only? :) One option might be to make it tristate, so that explicitly passing TRUE would allow us to say: no, this field is not stored in a non-translated way?. Not sure, just thinking out loud...

    Or we just drop that argument entirely, because we actually don't seem to be using it at all right now :) Which on the other side makes it impossible to get the field table name for the base/data table for those cases where you really want to know.

    But even for the revision argument is not fully clear. Base fields actually can be non-revisionable, in which case, unlike translation, they are *not* stored in revision_data. Given I have such a field, what is getFieldTableName('unrevisionable_field', FALSE) supposed to return? Right now it will return the data table. In which case the _only suffix kind of makes sense, but the description doesn't.

amateescu’s picture

StatusFileSize
new24.67 KB
new5.2 KB

Re #24:

  1. I also started with a nested array but arrived at the same conclusion as you, that a flat array with clear labels is better for debugging :)
  2. This phpdoc has been bugging me for days :P
  3. To understand how I've arrived at this solution with two arguments, it will be very helpful to read through #2326719-26: Move pseudo-private table mapping functions from ContentEntityDatabaseStorage to public API of DefaultTableMapping and #28 in that issue.

    Basically, what I did in this patch is that I took the proposed getPrimaryTableName(FSDI, $default_revision_only = FALSE, $default_language_only = FALSE) method there and turned it around, mostly to preserve the current behavior of getFieldTableName(), which right now returns the table where the default revisions and default translations are stored.

    After reading those two comments from @effulgentsia, it will also be clear why we need an additional parameter for "default language/translation", so that alternative table mapping implementations can experiment with storing default translations in a separate table, just like DefaultTableMapping does for default revisions.

    If you think the argument names and descriptions are still confusing after considering the design decisions above, an alternative could be to change them into $all_revisions = FALSE, $all_translations = FALSE, so the default behavior is still to return the table for the default revisions and default translations. Would that help clarify things?

I will write this in the detailed roadmap as well, but maybe it's helpful to point out that I want us to stop thinking in terms of base/data/revision/revision_data tables, and just consider everything a field table.

This patch should fix the tests by not taking over the current setUpDefinition() method which is used in many places in the test.

Status: Needs review » Needs work

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

amateescu’s picture

Status: Needs work » Needs review
StatusFileSize
new44.98 KB

Forgot to upload the combined patch :)

berdir’s picture

From that issue:

> I suggest that for any given field, table mapping implementations MAY provide additional tables for either the default revision only, or the default language only, or both

I get the theoretical idea, but languages and revisions are *not* the same in the storage and can IMHO not be treated equally.

You can load a specific revision but usually don't and just want the default , so it makes sense to optimize for that.

However, you can not and most likely never will be able to load just a single language. Our APIs are just not capable to do that and while it would be interesting for some use cases (sites with a large amount of languages), I don't see how it practically could work. Given that, it makes zero sense to support storing languages in separate tables? And if we treat the default language differently, why not store all translations in separate tables, where does it stop? ;)

amateescu’s picture

I get the theoretical idea, but languages and revisions are *not* the same in the storage and can IMHO not be treated equally.

Well, we have #2274017: Make SqlContentEntityStorage table mapping agnostic to make the storage respect whatever table layout is given to it by an object that implements TableMappingInterface, so what we should be aiming for here is to decide whether we want to allow per-language tables, for example.

The current APIs might not be able to load a single language, but a custom solution can definitely try to achieve that, having the goal to use "lighter" entity objects that hold less data, so I don't see why we should actively prevent this kind of exploration :)

amateescu’s picture

StatusFileSize
new20.82 KB
new41.29 KB
new14.14 KB

Discussed with @Berdir and we agreed to drop the argument for translations for now, at least until we have an actual use case for it. And switch around the revisions argument to $all_revisions = FALSE.

amateescu’s picture

StatusFileSize
new20.79 KB

Rebased on top of the latest patch from #2916018: Allow the table mapping to be initialized from outside the storage. The combined patch is the same so no need to post it.

Status: Needs review » Needs work

The last submitted patch, 31: 2955442-31.patch, failed testing. View results

amateescu’s picture

Status: Needs work » Needs review
amateescu’s picture

#2916018: Allow the table mapping to be initialized from outside the storage is in so the patch from #31 should apply correctly.

This is now the highest priority issue from #2960147: Finalize the entity storage because it's the one that's blocking most of the other improvements :)

plach’s picture

+++ b/core/lib/Drupal/Core/Entity/Sql/DefaultTableMapping.php
@@ -289,22 +299,35 @@ public function getFieldNames($table_name) {
+  public function getFieldTableName($field_name, $all_revisions = FALSE) {

I like the idea of introducing the "all revisions" terminology also here, to be consistent with the Entity Query API, however I preferred the solution exposing a dedicated method for revisions.

AAMOF with that parameter we are still implying that a field may live in different tables depending on which revision we are dealing with.

IMHO the API would be cleaner if we got rid of the bool parameter and introduced a ::getAllRevisionsFieldTableName() method instead.

A dedicated method, besides making things more readable, allows subclassess to override only a specific behavior if they need to.

amateescu’s picture

So the first thing that I want to point out is that we also need to keep #2960037: Add a way to get the primary table of an entity type from TableMappingInterface in mind with this discussion, because that method will have to mirror whatever we decide here :)

AAMOF with that parameter we are still implying that a field may live in different tables depending on which revision we are dealing with.

That's probably because you're thinking about it in terms of the "should we allow individual tables per-revision" question from #2326719-26: Move pseudo-private table mapping functions from ContentEntityDatabaseStorage to public API of DefaultTableMapping point 5, while the actual meaning of the extra parameter, as I see it and how I'd like it to be interpreted, is something more like: we allow the table mapping to provide an optimized table structure for revisions, and try to get the "optimal" table by default, while also offering the possibility to retrieve the table which contains *all* the data.

A dedicated method, besides making things more readable, allows subclassess to override only a specific behavior if they need to.

Reading through that article, I think what we're trying to design here falls into the "Deriving the flag" category, the analogy over there where a customer can be 'elite' or 'regular' can be transposed to a field being revisionable or not in our case.

So if we go with separate methods, we're actually placing the burden on the *caller* to decide which method to use, based on a check whether it is dealing with a revisionable field or not.

And we would put that burden on the caller every time just to allow subclasses of DefaultTableMapping to reuse a small bit of code? I'm sorry but that doesn't sound right..

Here's an actual example of the nice DX provided by the single method approach: in #2960497: Simplify SqlContentEntityStorage::countFieldData() I'm able to call $table_name = $table_mapping->getFieldTableName($field_name, TRUE) to retrieve the table which stores all the data of that field without having to know anything about its revisionable status.

berdir’s picture

I think we switched to a single function here when you addded the translation argument?

I think that's also something we need to consider, two flags would then actually result in 4 combinations, so if we do add another concept in the future, then we would need to add two additional methods. .

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.

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.

amateescu’s picture

StatusFileSize
new3.91 KB

After a few months of mulling over this, I think it would be better to add a method which returns _all_ the tables in which a field is stored.

This method is useful in at least two scenarios:

- an Entity Query rewrite (#2875033: Optimize joins and table selection in SQL entity query implementation) could determine the base table to use based on the intersection of table names for all the fields involved in the query. For example, if the query has only two condition on the label and the ID field, the data table would be used, if it only has a condition on the UUID field, the base table would be used, etc.

- #2960497: Simplify SqlContentEntityStorage::countFieldData() could use the _last_ table returned by this method in order to do its query on the table which has most data stored (the revision data table for translatable and revisionable entity types)

amateescu’s picture

Title: Add a way to get the dedicated revision table of a field from TableMappingInterface » Add a way to get all the tables in which a field is stored from TableMappingInterface

There's a new patch over at #2960497-8: Simplify SqlContentEntityStorage::countFieldData() which uses this new method, and my initial testing shows that it works great!

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.

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.

jibran’s picture

Status: Needs review » Reviewed & tested by the community

Patch still applies and I have updated the core version in the change notice so RTBC.

amateescu’s picture

StatusFileSize
new3.94 KB
new3.67 KB

Looking again at this after a quite a while, I think the new method name is too similar to the existing getFieldTableName(), so I've renamed the new one to getAllFieldTableNames().

amateescu’s picture

Issue summary: View changes

Updated the IS and the CR as well.

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Committed 329a3d9 and pushed to 9.1.x. Thanks!

  • alexpott committed 329a3d9 on 9.1.x
    Issue #2955442 by amateescu, plach, jibran, Berdir: Add a way to get all...

Status: Fixed » Closed (fixed)

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