Problem/Motivation
It is not possible to query on delta. This is a regression from Drupal 7 where we had fieldDeltaCondition since #1206200: Add support for field meta conditions in EntityFieldQuery (not 7.0 but long enough). Curiously enough I can't find https://www.drupal.org/core/beta-changes https://www.drupal.org/core/issue-priority any advice on the priority of such but given it's a regression it is allowed in beta.
Proposed resolution
Adopt a new syntax ->condition("fieldname.delta.columname"). Like ->condition("field_image.0.alt", "foo")
Also support ->condition('fieldname.?.columname')->condition('fieldname.?', deltavalue, 'deltaoperator') for the case when you need more complex delta operator(s). Yay, wildcards. I am reasonably sure we do not support the ? as a column name :)
Remaining tasks
Code it. Strike at the Tables::addField method:
$specifier = $specifiers[$key];
if ($key < $count && is_numeric($specifiers[$key + 1])) {
$delta = $specifiers[$key + 1];
$index_prefix .= ".$delta";
$key++;
}
then pass $delta 'round so that it ends up in addJoin, alongside of $langcode. I would replace $langcode with an associated array [langcode => $langcode, delta => $delta] and iterate it in addJoin making a minimal change there (beware that $delta can be 0 so an isset test is in order, $langcode defaults to NULL in the interface too).
In the if ($field_storage && $key < $count) { part reset $delta to NULL.
Adjust the new tests to the new syntax. The patch, altogether could be quite small.
User interface changes
none
API changes
Some field schema columns might turn to reserved.
| Comment | File | Size | Author |
|---|---|---|---|
| #99 | 2384459_89.patch | 14.38 KB | berdir |
| #89 | interdiff.txt | 9.32 KB | slashrsm |
| #89 | 2384459_89.patch | 14.38 KB | slashrsm |
| #85 | interdiff.txt | 1.32 KB | slashrsm |
| #85 | 2384459_85.patch | 11.78 KB | slashrsm |
Comments
Comment #1
mikran commentedMy first attempt at writing core patch
Comment #2
chx commentedLet's see what the entity people say to this. I am not 100%, myself, these are in the extra_columns in SqlContentEntityStorage::getTableMapping but I can't say for sure what does that mean.
Comment #3
mikran commentedtypo fixed from comments and test only patch added
Comment #4
chx commentedSo this method outside of entity query is used in:
now this means that you can't have these columns as fields. That's trouble. For example, bundle is fine because it is used as such as a property on several classes but on the other hand it's not really necessary since we know that the real name of the bundle field is. Now, neither entity_id nor revision_id is used a a property since properties are camelcased so there's a possibility someone might make a field out of them.
All in all, I would recommend removing bundle, entity_id and revision_id ; they can always be replaced with their entity kinds and so it's not needed to reserve them.
The Entity class itself has a langcode property so that one is absolutely fine.
The only one I am undecided about is delta. The real problem with it that's possibly a data-breaking thing to reserve :(
Comment #6
mikran commentedI see what you mean.
So tests would need to be extended with a field named 'delta' and a delta query. Then this list of columns to be queried with has to come from some other way than the reserved column names.
I'll try...
Comment #7
mikran commentedBut wait, the syntax is same. There is no way of knowing whether ->condition('field_name.delta') wants to target field column named delta (so actual column field_name_delta) or the delta of the field. So that wouldn't fix anything.
So the question is can delta be a reserved column name?
Comment #8
mikran commentedentity_id and revision_id removed
Comment #9
mikran commentedIssue summary updated.
Comment #10
chx commentedPutting back the issue on Berdir's table because I have no idea whether we can name delta a reserved column or not. Perhaps we can, perhaps we can't. It is extremely weird to have one for sure. It is also possible, alas, we need to have a different solution for relation D8.
Comment #11
berdirI'm not yet sure about langcode, but that is usually not something that is queried per-field. Instead, that's done globally, so you query on condition('langcode', 'en') and then the field joins should respect that. But you're never going to query a field on one langcode and another on a different one.. I think.
For delta, what if we support numbers directly somehow? $query->condition('some_ref_field.1.target_id', 5) ? We don't have to worry about clashes there and then we can create a combined condition for delta = 1 and target_id = 5. I don't know what happens if you try to use multiple deltas for the same field in the same query, but I don't think we need to support that and it wouldn't work if you specify it separately either anyway. So just detect that and throw an exception.*
Edit: Or make a second join?
Comment #12
berdirI think plach has thoughts about querying by langcode, so assigning to him for his opinion.
Comment #13
mikran commentedThat's better than nothing, but in Relation there could be a need to do a query for delta > 0. But then again there is also need to ensure that condition groups can not match the same delta value twice so maybe similar workaround could be used for delta > 0 case.
Comment #14
chx commentedlangcode in fact is part of the condition interface, not sure what I was smoking, sorry for the noise. Delta only. No problem with multiple delta queries, that's already solved by query groups. Will patch the weekend, @mikran if you can and want to get to it in the next 24 hours please do.
Comment #15
chx commentedI have written up a very detailed issue summary.
Comment #16
chx commentedComment #17
chx commentedMMM @mikran that's a complex case. Added to the IS.
Comment #18
chx commentedComment #19
chx commentedComment #20
mikran commentedI've added few tests to get this started.
Comment #21
mikran commentedSimple delta conditions implemented. Tests should fail from the wildcard '?' but tests prior to that could pass
Comment #23
mikran commentedAnd here is the whole deal. The test I posted earlier was a bit wrong so that's updated now. And proposed syntax in issue summary didn't work out as there is no way of knowing which field to target with
->condition('?', ...so I changed that to->condition('field_name.?', ....Comment #25
chx commentedThis is incredible! I only have a few documentation changes to add and then mikran will add more tests and we are done.
Comment #26
mikran commentedand here is the missing tests plus small test improvements.
Comment #27
chx commentedI think it's ready but berdir was doing a lot of work here recently so I'll ask him to review it.
Comment #28
jibranI think @alexpott has said in IRC he has no issue with this patch. Code review looks fine to me. Can we add/update a change notice for above cases? Thank you @mikran and @chx for all the hard work on the patch.
Comment #29
chx commentedI added delta queries here https://www.drupal.org/node/1827278
Comment #30
berdirLooks OK to me, although I'm not very happy to make get addField() method even larger. I'll try to refactor it a bit in #2391217: Support base fields with multiple columns in entity queries, I think structuring that a bit in multiple methods will help a lot with being able to read and understand the code in there (Especially for non-super-humans aka @chx ;))
A bit confused about the "?" magic though, what happens if you don't provide a corresponding condition for it? What happens if there is more than one?
This will conflict with #2068655: Entity fields do not support case sensitive queries, which I'd like to get in first. (Edit: Wrong issue reference there before, fixed now)
Comment #31
chx commentedThe reroll is trivial: this applies with patch even if it doesn't with git. So that's a non-issue. I thought multiple methods but there's so much interaction going on. I tried to factor out a little as visible. Also, congratulations to @mikran: you are now officially a super-human :D
> A bit confused about the "?" magic though, what happens if you don't provide a corresponding condition for it?
I thought I put in doxygen for that: that's just a delta query. You want entities with 6 tags? ->condition('tags.?', 5).
> What happens if there is more than one?
The only thing you need to know is that fieldname.? within a query group means a single delta. So then if you do
then you get entities where the value is foo, format is bar but not the first field item.
Comment #32
mikran commentedreroll
Comment #33
mikran commented#2068655: Entity fields do not support case sensitive queries is in, can we get this forward?
And I'd say this could also be major as a regression from D7.
Comment #36
mikran commentedanother reroll
Comment #37
berdirHad to debug this a bit to understand how it works, got it now.
That said, a lot of thought went into getFieldColumName() recently in #2392209: DefaultTableMapping::getFieldColumName is broken for base tables and how Tables uses it (and there is a follow-up related to deleted fields), and I'm not sure if this is the right way to do it.
Would be great if @yched could have a look at it.
Comment #38
mikran commentedreroll
Comment #39
yched commentedHaven't really delved into the code yet.
For now, just a note :
As pointed in #2392809: EFQ should not call DefaultTableMapping::getFieldColumName() for the 'deleted' column, I think it's really weird that DefaultTableMapping::getFieldColumName() has to account for EFQ calling it for non-field columns like "deleted" (in current HEAD) and "delta" (with this patch). I guess I'm fine with cleaning that up in that issue over there though.
Comment #40
yched commentedNo real opinion on the code internal to Tables - that code is too complex for me, so I'm happy if tests pass :-)
This frankly bugs me, though. It's syntax convention from EFQ leaking into the field storage code, I really wish we could avoid that.
The job of DefaultTableMapping::FieldColumnName() is to return the DB column name corresponding to a *field property*.
I understand that EFQ also needs express conditions on "meta" things that are *not* field properties (deleted, delta), and it currently has no-one to turn to to know the name of the actual db column for those.
I guess I'd be fine if FieldColumnName() also gets asked for the column names of meta properties, if those meta properties were clearly denoted with an underscore :
So, whatever the syntax EFQ choses for delta conditions, it should end up calling DefaultTableMapping::FieldColumnName() with $actual_field_property_name, '_deleted' or 'delta'.
I'm fine with handling the case of '_deleted' in #2392809: EFQ should not call DefaultTableMapping::getFieldColumName() for the 'deleted' column, but I'd prefer if we passed '_delta' rather than '?' in this patch here.
Comment #41
yched commentedAlso : in the current patch, DefaultTableMapping::getFieldColumnName() always replies "sure, I have a column for 'delta', and it's called 'delta'". But that is only true for fields stored in dedicated table.
If someone writes an EFQ with a delta condition on a field that is stored in a base table (typically, a base field like node.title), then the current code will execute a query on the 'delta' column of the base table :
- if you're lucky, there's no 'delta' column in that table and you query will fail, letting you know you wrote a flawed EFQ
- if the entity type happens to have a base field named 'delta', then the column does exist, but it's something completely unrelated, and the query will behave in very weird and unexpected ways.
In short :
- DefaultTableMapping::getFieldColumnName() should not lie, it should only return the name of the 'delta' column if the field is stored in a shared table
- I think we need to throw an exception when someone writes an EFQ with a delta condition on a field that is stored in a base table
Comment #42
yched commentedLast, just a matter of taste, feel free to ignore :
Not sure '?' is the most intuitive syntax convention for deltas, I'd think '*' would possibly make more sense, both for :
"count deltas" : ->condition("myfield.*", 3)
"condition on any delta" : ->condition("myfield.*.value", 'foobar')
Comment #43
mikran commentedI think I've now addressed your concerns. Tables is now passing
_deltainstead of?to DefaultTableMapping::getFieldColumnName() and that same function restructured a bit so that the new_deltais resolved only for dedicated table storage fields, same as with thedeleted.Comment #44
mikran commentedAnd about the wildcard. I think * has other meaning as a 'any' wildcard already. That could be understood as
->condition("myfield.*", 3)if any column has value of 3.Comment #45
yched commentedThanks for the fast update, @mikran.
The logic in DefaultTableMapping::getFieldColumnName() is not right yet.
This still means that if I write an EFQ with a delta condition on a field stored in a shared (base) table (for which there exists no 'delta' column for the field to begin with), the 1st 'if" branch here runs, and the resulting query will have a condition on an incorrect column - either $field_name or "{$field_name}___delta", depending on the field type. If the latter, the column doesn't exist and the query will break; if the former, the column does exist but is the one that holds the field values, so the query has unexpected behavior.
--> The "$this->allowsSharedTableStorage($storage_definition)" case also needs to check "if $property_name is _delta, return NULL".
What EFQ then does if getFieldColumnName() says "I don't have a delta column", I'm not sure.
If we throw an exception, it means the same EFQ will either work or throw an exception, depending on how the field is stored, and that's a situation we've always tried to avoid : the same code should work whatever the way the field is stored.
Semantically, a field stored in a shared base table only has either no value (db stores NULL) or one value at delta 0 - but there is no explicit 'delta' info in the db.
So ideally, if an EFQ contains a delta condition on a "base table" field, the EFQ runtime code would "translate" it according to the above semantics and ensure the actual query runs ok and returns the correct results. Not sure that's doable though :-/
Comment #46
chx commentedWith almost a week with no progress, I am not sure I clearly understand what's the next step is.
Comment #47
yched commentedSorry if #145 was confusing, the truth is, it's not clear to me how to move forward :-/
Trying to clarify. Bottomline is : how do we handle conditions on "delta" on fields that are stored in a shared table, since there is no "delta" column for the field in the db ?
1) DefaultTableMapping::getFieldColumnName('_delta') should return NULL instead of "delta" in the "if ($this->allowsSharedTableStorage($storage_definition))" case.
There is no delta information in the db in shared tables, we cannot pretend there is and build a query on it, because there could be a totally unrelated column that happens to be named "delta". In this case, the query would run OK, but return completely weird results.
2) When receiving that NULL, Tables::addField() needs to decide what to do, and this is where it gets tricky.
I see several options :
a) Throw an exception. This de-facto forbids writing an EFQ with a '_delta' condition on a field that is stored in a shared table.
That's sucky DX, because it requires you to know how the SqlStorage decided to store the field before writing your EFQ.
it also means that some code that works today might break tomorrow, for example if a base field gets changed from "mono value" to "multiple values'.
b) Just silently skip the condition. That would return "wrong" results.
For a condition of "field with value 'foo' in a delta > 3", keeping or ignoring that condition depending on how the field is stored means you cannot actually trust the result set actually matches the condition you specified.
c) Figure out a finer way of handling those conditions at runtime, since we can't rely on the query to enforce them. Like :
- if the condition is enforcing values in deltas > 0, then don't run the query at all, the result set is empty (a field in a shared table cannot have multiple values)
- If the condition is on delta 0, just treat it as a condition without the delta part
- ...
But getting all the cases right is likely to be tricky : we have "OR" groups, conditions on "value == 'foo'" are probably not the same as conditions on "value <> 'foo'"...
I don't have the answer, thoughts welcome :-)
Comment #48
chx commentedc) is the way forward. If something tells us there's no delta then we know the value of delta is 0. The config EQ backend has a matcher
Drupal\Core\Config\Entity\Query::matchfor known values. Then we can add an expression of 1 = 1 if the matcher came back with TRUE and 1 <> 1 if the matcher came back with FALSE and let SQL deal with the OR/AND groups from there.Comment #49
yched commentedYay - happy that you think there's a way to make c) work :-)
Comment #50
chx commentedComment #51
chx commentedComment #54
mikran commentedI'll work a bit on this, here is rerolled patch for starters
Comment #55
imiksuComment #56
mikran commentedUpdated patch. I tried to implement the idea of 'c' option.
I didn't touch
getFieldColumnName()but instead deltas are checked and handled before field columns. Wildcard conditions must match 0 and1 <> 1condition is added if delta is specified and the value is greater than 0.Comment #57
mikran commentedminor fix
Comment #58
mikran commentedand that was wrong patch, reupload
Comment #60
andrewbelcher commentedI've manually tested this and it looks to me like it applies and works correctly. I can't speak for the decision about the internals other than the code looks good and well documented, so I'm going to tentatively set this as RTBC. Would be fantastic to get this in as it's a really useful addition to entity queries.
I think the syntax for using it is nice and obvious. I agree that
*is probably more prone to confusion than?, though I can understand why?maybe also isn't ideal.I don't know if this can go into 8.0.x as a bug fix (as it was a regression and is documented as though it's a working feature in https://www.drupal.org/node/1827278), or if it needs to wait for 8.1.x as a new feature?
Comment #62
catchMoving to 8.1.x and task.
Some of the capabilities here I'm struggling with a use-case for, but if we had it in 7.x it's feasible that someone's relying on it.
Why ? as wildcard and not *?
Comment #63
catchComment #65
mikran commentedOkay, maybe I don't have a strong argument for which wildcard we should use but I do have many use cases for delta queries.
One example for the wildcard. Let's assume a multi-value field that can be ordered. Field title is "My favorite artists" and on the artist page we then show a number of users who include this artist in their TOP5 artists.
To get that number we need a query with a delta wildcard condition like:
->condition('field_artist.?.name', '{artist-name}')->condition('field_artist.?', 5 '<')Comment #66
david_garcia commentedLooks like this needs to be rolled again as it won't apply to 8.x-1.x
Comment #67
imiksuCleaning up tags.
Comment #68
mbovan commentedRerolled the patch from #58.
Comment #69
mbovan commentedReplaced
?wild card with*.Comment #70
berdirI'm not sure I fully understand the need for the * on the condition. Is it that it shares the same $index_prefix internally so it uses the same join? We should try to document this better.
delta is not part of the key.
Isn't that a problem if you for example have two different conditions for the same field, one with delta and one without.
Can we add a new argument to a protected method? in a minor release? in a patch release?
I guess we should make the whole class @internal, which would then definitely allow changes like this, but again not sure under which conditions we can do that exactly.
is _delta safe enough as a magic property name? should we make that a constant? Or use an invalid character for an actual propery, like :delta or *delta, to stick to the wildcard character?
based on what I wrote above, I think we need a test that does a check on two different deltas, e.g. a an or condition?
Comment #71
slashrsm commentedShould this be a bug since it is a regression compared to D7?
Comment #72
mbovan commentedAddressed #70.4 for now. Is
\Drupal\Core\Entity\Query\QueryInterfaceright place to add this constant?Comment #73
mbovan commentedWrong patch.
Comment #74
mbovan commentedWrong patch uploaded. This should be the correct one.
Moved the constant in
Drupal\Core\Entity\Sql\TableMappingInterfaceas discussed with @Berdir and @slashrsm.Regarding #70.2, it looks like $index_prefix already contains a delta in it. Added tests for it.
Comment #76
slashrsm commentedPatch is passing.
Comment #77
slashrsm commentedAttempt to improve the documentation.
Comment #78
slashrsm commentedAnt 74-77 interdiff.
Comment #79
berdirDocumentation itself seems fine, just some changes on the wording/grammar.
I would remove "This will require condition.." sentence, instead, move the explanation below the code example above, The condition below... and I'd just write "the 6th value" instead of exactly.
.. on *the* delta ... Maybe also leave out "as a wildcard", because it's actually not really a wildcard. It's just the character we use for the delta.
Also not sure if we make code examples part of a sentence like that, might be easier to just have the explanation above.
Comment #80
slashrsm commentedFixed most of comments from #79.
Comment #81
slashrsm commentedI was discussing this with @berdir and we agreed we could use something that is more self-explanatory instead of "*". "%delta" for example. In this case it would make sense to use this for
const DELTAtoo.Comment #82
mbovan commentedReplaced
*with%delta.Comment #83
catch%delta feels a lot better.
Comment #84
berdirSome grammar things, native speakers are welcome to correct me:
The condition above will require the 6th value of the field to match the provided value.
.. by using '%delta'.
I still think that it's not really a wildcard? and that code as part of sentences is problematic.
Comment #85
slashrsm commentedWhat if we just omit the word "wildcard". It still sounds clear enough to me.
Comment #86
dawehnerJust for my own clarification, we completely ignore these string when we deal with config entities?
Comment #87
berdir@dawehner: Yes. config and content entities interpret conditions in completely different ways, code isn't shared there.
But yes, it's possible that we should document that this only works for content entities?
Comment #88
alexpottThis doesn't flow right.
I think the @code should be indented too? no. I couldn't see the standards for this on https://www.drupal.org/coding-standards/docs#code. But we certainly got other instances in core.
Wrapping.
I think this documentation could be a clearer as to what is going on here.
wrapping
Comment #89
slashrsm commentedRerolled. Fixed all items from #88. Also added a note about config entities and added few more test cases.
Comment #90
berdirThis is my only remaining remark.
I think we can do this in 8.2.x since it is a protected method, but it's not something we can get into 8.1.x AFAIK. If it's worth fixing there, we could set it back to 8.1.x after committing to 8.2.x and find a way that avoids this change?
It's worth noting that this is a (soft) blocker for the now critical #1266748: Changing cardinality lower than highest existing delta causes data loss upon save, as this would give us a very easy way to check if there are any entities with a delta > the one the user is trying to limit the field to.
Comment #91
alexpottCommitted 7109454 and pushed to 8.2.x. Thanks!
Personally I think we should consider committing this as is to aloow for an easy fix to #1266748: Changing cardinality lower than highest existing delta causes data loss upon save. The
ensureFieldTable()is protected and work arounds are likely to be complex.Fixed on commit.
Comment #93
chx commentedGlad this made it in. I would very strongly urge people interested in EFQ to tackle #2537324: EntityQuery sorting for cardinality > 1 fields and translations next. You should come together at DCNOLA and figure it out.
Comment #94
catchMoving back to 8.1.x for review there.
While this changes a protected method, there's nothing in semver that says you can't change protected methods in patch releases - it just says you should define your public API and stick to it whether in a patch or minor release.
Our own bc policy says that we avoid making changes to the non-public API/internal things in minor releases - this is our own choice and largely exists due to the large surface area of that non-public API.
If this is a blocker for [#1266748 (and it looks like it is) then I think it's OK to make the change in a patch release - should have a change record though. However if it did result in actual breakage somewhere, we could also only fix #1266748: Changing cardinality lower than highest existing delta causes data loss upon save in 8.2.x
Comment #95
catchAlso thinking about it more, postponing the 8.1.x commit for this on the 8.2.x commit of #1266748: Changing cardinality lower than highest existing delta causes data loss upon save - they can go into 8.1.x together assuming it works out.
Comment #96
catchComment #97
berdirLooks like it's working out :)
Comment #99
berdirRe-uploading the patch so it runs against 8.1.x
Comment #101
duaelfrThe test is green. Get back to RTBC.
Comment #102
chx commentedGlad this makes it 8.1.x. I would very strongly urge people interested in EFQ to tackle #2537324: EntityQuery sorting for cardinality > 1 fields and translations next. You should come together at Milan and figure it out.
Comment #103
alexpottCommitted 2116b91 and pushed to 8.1.x. Thanks!
Comment #104
alexpottComment #107
pingwin4egI encountered a PHP notice related to the committed code.
@see #2772531: Notice: Undefined offset in Drupal\Core\Entity\Query\Sql\Tables->addField()