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.
| Comment | File | Size | Author |
|---|---|---|---|
| #45 | interdiff-45.txt | 3.67 KB | amateescu |
| #45 | 2955442-45.patch | 3.94 KB | amateescu |
| #40 | 2955442-40.patch | 3.91 KB | amateescu |
Comments
Comment #2
amateescu commentedHere'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.
Comment #3
amateescu commentedWhile 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 promoteDefaultTableMapping::requiresDedicatedTableStorage()up to the interface.Alternative SQL storage table mapping implementations will be able to return
FALSEfrom 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.
Comment #5
jibran\Drupal\Core\Entity\Sql\DefaultTableMappinggives me headache.Isn't
\Drupal\Core\Entity\Sql\DefaultTableMapping::getDedicatedRevisionTableNamelet us do that?Just curious when did this change? Also, the updated comment doesn't make sense to me.
We need comment here to explain what is going on.
We'd need a change record for this.
Comment #6
jibranNever mind my question it is already answered in IS.
Comment #7
amateescu commentedThis should fix the fails.
Re #5:
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.
Comment #9
amateescu commentedHmm, those fails are.. interesting, but I'll wait for an approval of the plan before digging further.
Comment #10
plachIn 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:
Since this and its data table counterpart are becoming the new canonical way to retrieve this info, can we add static caching to both?
"... otherwise the revision table"
I think we can remove the last sentence in this case.
If I'm not mistaken this logic is common to both methods, can we move it into a separate protected helper method?
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.
Comment #11
amateescu commentedThanks for reviewing!
Re #10:
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.
Comment #13
plachI'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.
Comment #14
amateescu commentedI just checked and you're right! The code in
\Drupal\Core\Entity\Query\Sql\Tablesis needlessly complex and tries to make D7-style distinctions between shared and dedicated tables, but I think it can be refactored to rely more onTableMappingInterface. 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.
Comment #15
amateescu commentedWhile 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 :)
Comment #16
plachLooks good!
We still need CR and follow-up.
Comment #17
plachAlso
Comment #19
amateescu commentedHere's the CR: https://www.drupal.org/node/2957624 and the followup: #2957627: Remove all non-test usages of DefaultTableMapping::getDedicated(Revision)TableName()
Comment #20
pifagor commented+1
Comment #21
amateescu commentedI'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.
Comment #22
amateescu commentedAnd 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.
Comment #24
berdirSomehow 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.
Noticed this too yesterday ;)
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.
Comment #25
amateescu commentedRe #24:
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 ofgetFieldTableName(), 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
DefaultTableMappingdoes 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.Comment #27
amateescu commentedForgot to upload the combined patch :)
Comment #28
berdirFrom 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? ;)
Comment #29
amateescu commentedWell, 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 :)
Comment #30
amateescu commentedDiscussed 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.Comment #31
amateescu commentedRebased 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.
Comment #33
amateescu commentedComment #34
amateescu commented#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 :)
Comment #35
plachI 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.
Comment #36
amateescu commentedSo 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 :)
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.
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
DefaultTableMappingto 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.Comment #37
berdirI 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. .
Comment #40
amateescu commentedAfter 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)
Comment #41
amateescu commentedThere'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!
Comment #44
jibranPatch still applies and I have updated the core version in the change notice so RTBC.
Comment #45
amateescu commentedLooking 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 togetAllFieldTableNames().Comment #46
amateescu commentedUpdated the IS and the CR as well.
Comment #47
alexpottCommitted 329a3d9 and pushed to 9.1.x. Thanks!