We are encountering an issue where we are unable to filter by column values in a reference field. The docs page for Filtering says:
Tip: You can filter by sub-properties of a field. For example, a path like field_phone.country_code will work even though field_phone isn't a relationship.
But this doesn't seem to apply for entity reference fields. For example, if I have an image field attached to my article content type, the following requests to filter by the alternate text column values fail:
GET: /jsonapi/node/article?filter[field_image.alt][value]=test
Symfony\Component\HttpKernel\Exception\BadRequestHttpException: Invalid nested filtering. The field `alt`, given in the path `field_image.alt`, does not exist. in Drupal\jsonapi\Context\FieldResolver->resolveInternal() (line 135 of web/modules/contrib/jsonapi/src/Context/FieldResolver.php).
Note that the following EntityQuery does in fact work:
$query = \Drupal::entityQuery('node');
$query->condition('type', 'article');
$query->condition('field_image.alt', 'Test');
$query->execute();
Comments
Comment #2
dave reidComment #3
dave reidComment #4
wim leersThanks for reporting this! This seems at least a major bug.
Comment #5
wim leers#2962457: Handle computed fields in entity queries: throwing a helpful exception is better than a PHP fatal error seems related.
Comment #6
gabesulliceThis is actually a separate issue from computed fields (although they both have to do with the field resolver), but they don't need to be handled together.
The issue here is that we are blindly saying that any path part after a reference field must be a field on the targeted entity and are ignoring the fact that reference fields can have other properties.
The most obvious case is
target_id, which is not a reference property like the (computed)entityproperty on entity reference fields.Another issue I discovered while doing this is that it would also be impossible to target references that do not have the
entityproperty. Thelanguageproperty on a language field is an example of this kind of reference (although it's inapplicable to filtering because it's computed).Tests and patch attached.
Comment #7
gabesulliceComment #9
wim leersThis is more complex than I first thought. Or maybe it's my still congested head that's having trouble understanding this.
Nit: s/is/will/ (because it's for the next iteration of the loop.
Hm … why is this necessary? Following multiple levels of entity references already was working before. That's why we have the
while ($part = array_shift($parts)) {loop. Why are we now skipping ahead one iteration of the loop?Nit: s/sub-property/property/
Fields have properties. Subproperties are not a thing.
Why this move?
Nit:
array $candidate_definitionsThis uses
\Drupal\Core\TypedData\DataReferenceTargetDefinition, which is not an instance of\Drupal\Core\TypedData\DataReferenceDefinitionInterface. Why is this now working?This is just a normal
DataDefinition… I don't understand why this didn't work in HEAD, even though I can see that the test is failing.I can see that it's fixing certain use cases simply because your newly added tests are failing and are passing after making the changes. But I don't understand yet why the changes make things work. I could step through it with a debugger, but I suspect it might be faster for you to explain?
Comment #10
gabesulliceAh, yes, this is complicated. The change is relatively simple, but how it all fits together is definitely not.
Just as a reminder before I dig in: The entity query system expects
uid.entity.namebut JSON API makes it possible to just douid.nameto be mirror the dot-separated syntax of include paths. The current logic blindly assumesentityis the "glue" between path parts, but that's not truly the case.Okay, so:
- An entity reference field has two properties,
target_idandentity-
target_idis an instance ofDataReferenceTargetDefinition-
entityis an instance ofDataReferenceDefinition(missing "Target")- Our heuristic for determining if a field is a reference field is: "does the field have a
DataReferenceTargetDefinitionproperty?"That's here:
- If the current part is a reference based on that heuristic, we added it to the "breadcrumbs"
=== If the field is a reference ===
- Then, we advance to the next part of the path, assuming the next part is a field on the targeted entity type. (e.g. uid.name on a node would say "uid" is a reference, therefore continue on the path and evaluate "name" against the User entity type)
- When we are done resolving the path, we then concatenate all the breadcrumbs with "entity"
- This works because the entity query system looks at the property definitions of a field and checks for
DataReferenceDefinitions and "entity" is the property for those on most (perhaps all) entity reference-like fields.- To say it differently, the entity query system does not use the
target_idproperty to determine what entity table to join against, it uses theDataReferenceDefinitionidentified in the path (usually "entity" is this property)=== If the field is not a reference ===
- We assume all remaining parts target deltas or properties of the field (or properties of those properties, etc etc. in the case of nested Map or ComplexData property types, hence "sub-properties"). An example of this might be
body.2.value.Where does this fail:
If the field is a reference, then we assumed (wrongly) that the next part of the path must be a field on the target entity type.
However, if the part is actually a property of the reference field, that possibility is never considered.
It's also possible that the
DataReferenceDefinitionis not actually namedentity, in that case, it could never have worked with JSON API, because we always assume that it is.How does this fix things:
If the field is a reference, we now "peek" ahead to the next path part to see if it's actually a property of the reference field.
If it's not, we continue with the path described above.
If it is, then we check if it's a reference property or not (instanceof
DataReferenceDefinition).If it is a reference property like
entity(or anything other string), then we push that onto the breadcrumbs:$reference_breadcrumbs[] = array_shift($parts);.If it is not a reference property, but it is a property of the reference field, then we assume that all remaining parts of the string are "sub-properties" of that property.
In explaining this, I realized two errors/unhandled cases in my logic 😅I've written the above as if the logic is correct.
The two flaws I found are:
1inbody.1.valuecontinue;is incorrect. The loop still needs to do some processing in case the next part is a reference. So:becomes:
More tests and patch to follow.
Comment #11
wim leersThanks for making it all click :) I still don't understand the
field_test_text.valuecase though. Text fields aren't referencing anything at all.Looking forward to the next iteration!
I thought about this too. But I'm not sure you can support this; whether you can depends not on logic in
FieldResolver, but of capabilities of the Entity Field Query system?P.S.: I think this could be made much easier to understand (through refactoring) by having more methods, that each do one thing explicitly.
Comment #12
gabesulliceOkay, I tried to flatten out the logic and more verbose commentary. I added more test cases as well. I'm attaching an interdiff as well, but I doubt it'll be that useful.
I don't understand what you don't understand. If you mean:
It was the
entitypart that was failing because it wasn't possible to specify a property of any kind on the entity reference.The field resolver is primarily an entity query validator with two JSON API specific transformations:
It is therefore possible to support this because it's possible in the entity query system.
Indeed, I ended up adding more methods and also repeating some logic rather than trying to cleverly reduce if/else statements.
I still think stepping through with a debugger would be helpful.
Comment #15
gabesulliceWhoops, that included some other debugging stuff.
Comment #17
gabesulliceAnd fixing the CS violations.
Comment #18
wim leers#12
The many early returns you added make it much easier to understand this logic — thank you! And this interdiff actually was useful! I applied #12 locally to look at helper methods not being introduced in #12, but #12's interdiff allowed me to see what you were changing and why.
D'oh, of course! 🙈😅 Thanks for clarifying!
I didn't know the entity query system supported this! I just checked
\Drupal\Core\Entity\Query\QueryInterface::condition(), and sure enough, it's there. Grep for "delta".This actually revealed to me that there is a special query condition called
%delta, which allows one to find entities which have e.g. 5 items (5 deltas). Does JSON API's querying/filtering already support this? If not, can we add an explicit failing test? Can we create an issue to clarify this?Interdiff review:
This comment is most definitely necessary! This is where PHP the language gets in the way, and such careful conditions (and accompanying components) are an absolute necessity!
MUCH clearer ❤️
Nit: the comment is fine, but can we revert the code reformatting? It's an unnecessary distraction.
This implies you could have an expression like
body.0orfield_tags.3.Do we actually support those? This logic suggests we do. If we do, then we should have explicit test coverage for it.
This says "must be", but it doesn't validate this; it only checks the next expression path part. Can we add a
@todoto validate this in the future?Woah! I think this would benefit from an example in the comment.
I stepped through this with a debugger. I first thought this was necessary only for the most complex edge cases. But actually it's necessary for even something as simple as
type.uuid! This comment sounds more complex than it is: it usually will result in'entity'being appended to$reference_breadcrumbs.Nit: s/Determine/Determines/
Can't this reuse
static::isReferenceProperty()?👌
Comment #19
wim leersAnd one more review of the entire patch:
There's no explicit test coverage for this yet. Having that would also help address #18.6.
These comments should be turned into array keys for these test cases; these array keys are considered *test case labels* by PHPUnit.
So when any of them fail, you'll see "test case #15 ()" rather than just "test case #15").
Comment #20
wim leersOnce #18 and #19 are addressed (mostly nits, but nits that help improve maintainability), this is RTBC. This is obviously a very important bugfix, comes with explicit test coverage (just see how many more test cases
\Drupal\Tests\jsonapi\Kernel\Context\FieldResolverTest::resolveInternalProvider()gets!), and helps make the crucialFieldResolvercomponent of JSON API more maintainable!Comment #21
gabesulliceDone.
We do, and there is coverage for it :) See:
Done.
I flipped the logic so the common case is first, added more commentary and an example.
18.7: Done
Not really. I renamed the method to make it more specific to its use case.
Without a custom field type, this isn't possible to test. I'm happy to remove the logic, make a follow-up issue and hard-code the word
entityin the method for now. What do you think? Add the robust logic now, without a test, or have a hardcoded value now with a follow-up and test?Done!
Comment #22
gabesulliceComment #23
wim leers😅 I even saw it after I wrote that remark. Forgot to remove that remark. Sorry!
+
✔️
Let's add the robust logic now and add a test in a follow-up. (Also: I was gonna say we already have a
jsonapi_test_field_typetest module, but that doesn't actually allow you to test this particular… which means I can add a
@todoand don't need to wait for you to just add a single comment for this patch to be RTBC'able/committable 😃🎉Comment #24
wim leersThis is the issue I created for that explicit test coverage btw: #2971281: Test coverage: ambiguous filter expression paths.
Comment #25
wim leersAnd I of course meant to RTBC in #23 — I said it, but didn't do it. Sorry for the noise.
Comment #27
wim leersGreen on both 8.5 & 8.6, let's ship this excellent bugfix!