Discovered as part of #2953321: Comprehensive JSON API integration test coverage phase 5: nested includes and sparse field sets.

The spec reads:

Compound documents require “full linkage”, meaning that every included resource MUST be identified by at least one resource identifier object in the same document. These resource identifier objects could either be primary data or represent resource linkage contained within primary or included resources.

The only exception to the full linkage requirement is when relationship fields that would otherwise contain linkage data are excluded via sparse fieldsets. (emphasis mine)

This implies that if compound documents are to be supported, include should operated independently of fields, meaning it should be possible to include a related resource even if the relationship field is not a member of the request's sparse fieldset.

Proposed Solution

Evaluate includes independently from relationship field normalization because included resources can be resolved and added to JsonApiDocumentTopLevel within EntityResource. Doing this prior to field normalization means that included resources won't depend on fields being in the primary data of the response.

Comments

gabesullice created an issue. See original summary.

gabesullice’s picture

Issue summary: View changes

This is related to #2946537: Test coverage: Inclusion of intermediate resources when include is a multi-part relationship path because it also has to do with how included resources are added to the document.

Also added a proposed solution to the IS

gabesullice’s picture

Issue summary: View changes
wim leers’s picture

Confirmed the proposed resolution. This will mean lifting this logic out of \Drupal\jsonapi\Normalizer\RelationshipItemNormalizer::normalize():

    $host_field_name = $relationship_item->getParent()->getPropertyName();
    if (!empty($context['include']) && in_array($host_field_name, $context['include'])) {
      $context = $this->buildSubContext($context, $target_entity, $host_field_name);
      $entity_and_access = EntityResource::getEntityAndAccess($target_entity);
      $included_normalizer_value = $this
        ->jsonapiDocumentToplevelNormalizer
        ->buildNormalizerValue($entity_and_access['entity'], $format, $context);
    }
    else {
      $included_normalizer_value = NULL;
    }

and running it independently of relationship normalization.

wim leers’s picture

Status: Active » Needs review
StatusFileSize
new6.29 KB

Observation: I'm not sure how/if this will be possible, because this (only includes of visible relationships) has been pretty much an essential basic assumption in the entire normalization architecture. This is what ValueExtractorInterface is designed for: a tree of objects implementing this interface allows includes to bubble up from the deepest level to the top level, and to the response.

The solution proposed in the IS would change this fundamental assumption. I believe it'd be simpler, or at least more maintainable for now, to stick to the current design.

I believe that's doable by converting RelationshipNormalizerValue value objects to some new object, which has no values to be rasterized, but only includes. That'd be perfectly consistent with what we already have.

Patch attached. This patch works! We can prove that by enabling the test coverage that #2953321: Comprehensive JSON API integration test coverage phase 5: nested includes and sparse field sets added. In doing so, I noticed that it's not wise to list preferred_langcode as one of the fields in the sparse fieldset. Because that particular field is only viewable by the user itself, which means that the access checking adds the user cache context. Replacing it with created still tests the same principle, while removing the need for complex test alterations.

gabesullice’s picture

StatusFileSize
new3.15 KB
new6.37 KB
  1. +++ b/src/Normalizer/EntityNormalizer.php
    @@ -93,7 +96,16 @@ class EntityNormalizer extends NormalizerBase implements DenormalizerInterface {
           if (!in_array($field_name, $field_names)) {
    ...
    +          if (!$field instanceof NullFieldNormalizerValue && !empty($normalized_field->getIncludes())) {
    ...
             continue;
    

    I find this somewhat hard to follow, especially when it ends in an "early return" (the continue). IOW, I think this can be rearranged.

    WRT to $field instanceof EntityReferenceFieldItemListInterface, let's try to reuse ResourceType::getRelatableFields as much and as often as possible so we don't need to hardcode the ER field, doing so maximizes compatibility with contrib.

  2. +++ b/src/Normalizer/Value/EntityNormalizerValue.php
    @@ -67,16 +67,18 @@ class EntityNormalizerValue implements ValueExtractorInterface, CacheableDepende
    +    $this->includes = array_map(function ($value) {
    +      return $value->getIncludes();
    +    }, $values);
    ...
    -      return !($value instanceof NullFieldNormalizerValue);
    +      return !($value instanceof NullFieldNormalizerValue || $value instanceof IncludeOnlyRelationshipNormalizerValue);
    

    Nice, this is a shockingly simple way of doing this. Way to go! I was thinking this issue was going to be a slog.

  3. +++ b/src/Normalizer/Value/IncludeOnlyRelationshipNormalizerValue.php
    @@ -0,0 +1,34 @@
    +    if (empty($relationship_normalizer_value->getIncludes())) {
    +      throw new \RuntimeException(sprintf('%s should only be used for relationships that do have includes.', get_called_class()));
    

    Why not assert?


I made my own suggestions. RTBC if @Wim Leers is +1.

FWIW, I enable the test coverage for #2946537: Test coverage: Inclusion of intermediate resources when include is a multi-part relationship path locally and this does fix it!

Edit: welp, dunno what changed.

Status: Needs review » Needs work

The last submitted patch, 6: 2976108-6.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new3.59 KB
new7.49 KB

I spent >30 minutes trying to reproduce the test failures; but then I suddenly could. I must've done something wrong, but don't know what!

  1. I can see that. I just tried to minimize for change. The entirety of \Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue::__construct() is pretty hard to follow IMHO.

    I specifically did instanceof INTERFACE rather than instanceof CLASS to maximize contrib compatibility. But I agree that your proposal is better.

    However… the irony is that I find your code much harder to follow :P There now is a conditional early return (well, continue), and the same conditions reappear later. And in doing so, you introduced a subtle bug that causes the test failures: a field that is not listed in the sparse fieldset and is a relationship field will never enter the if-test with the continue statement, and if no ?include is specified, $has_includes will always be false, which means that it'll be included inappropriately.
    This is the danger of repeating complex conditions multiple times. I prefer the structure I had before, because it stresses how exceptional it is: it is only in an extremely rare case that we need to wrap/decorate a normalization value we get back!

    That doesn't mean I don't see your point! I think there's another way to achieve DRY. See interdiff. This does result in some unnecessary work, but optimizing that seems premature optimization TBH :) Simplicity & correctness first, performance second!

  2. 🙂 I was also pleasantly surprised!
  3. ➕ WFM
gabesullice’s picture

Status: Needs review » Reviewed & tested by the community

However… the irony is that I find your code much harder to follow :P

😅hope it wasn't too frustrating to debug...

This looks good to me :)

  • Wim Leers committed 10ffb96 on 8.x-1.x
    Issue #2976108 by Wim Leers, gabesullice: Spec Compliance: Impossible to...
  • Wim Leers committed 5b02084 on 8.x-2.x
    Issue #2976108 by Wim Leers, gabesullice: Spec Compliance: Impossible to...
wim leers’s picture

Status: Reviewed & tested by the community » Fixed

Nope, that was fine :) And it's fascinating how different things can be simpler to different people!

Pushed!

Status: Fixed » Closed (fixed)

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