diff --git a/core/modules/jsonapi/src/Context/FieldResolver.php b/core/modules/jsonapi/src/Context/FieldResolver.php index 7da702576d5ae094a73e68cf24cf630debdb1074..ab410e9b93c7f115f2bf980e9d920f9d72fc4620 100644 --- a/core/modules/jsonapi/src/Context/FieldResolver.php +++ b/core/modules/jsonapi/src/Context/FieldResolver.php @@ -256,13 +256,16 @@ public static function resolveInternalIncludePath(ResourceType $resource_type, a * The JSON:API resource type from which to resolve the field name. * @param string $external_field_name * The public field name to map to a Drupal field name. + * @param string $operator + * (optional) The operator of the condition for which the path should be + * resolved. * * @return string * The mapped field name. * * @throws \Drupal\Core\Http\Exception\CacheableBadRequestHttpException */ - public function resolveInternalEntityQueryPath(ResourceType $resource_type, $external_field_name) { + public function resolveInternalEntityQueryPath(ResourceType $resource_type, $external_field_name, $operator = NULL) { $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:filter', 'url.query_args:sort']); if (empty($external_field_name)) { throw new CacheableBadRequestHttpException($cacheability, 'No field name was provided for the filter.'); @@ -355,7 +358,10 @@ public function resolveInternalEntityQueryPath(ResourceType $resource_type, $ext // If there are no remaining path parts, the process is finished unless // the field has multiple properties, in which case one must be specified. if (empty($parts)) { - if ($property_specifier_needed) { + // If the operator is asserting the presence or absence of a + // relationship entirely, it does not make sense to require a property + // specifier. + if ($property_specifier_needed && (!$at_least_one_entity_reference_field || !in_array($operator, ['IS NULL', 'IS NOT NULL'], TRUE))) { $possible_specifiers = array_map(function ($specifier) use ($at_least_one_entity_reference_field) { return $at_least_one_entity_reference_field && $specifier !== 'id' ? "meta.$specifier" : $specifier; }, $candidate_property_names); diff --git a/core/modules/jsonapi/src/Query/Filter.php b/core/modules/jsonapi/src/Query/Filter.php index e87f0081a45aa8c3b0ede2a026040efc7a4b4d79..2ca708b433ff192357976f1c23c83b4c1743233f 100644 --- a/core/modules/jsonapi/src/Query/Filter.php +++ b/core/modules/jsonapi/src/Query/Filter.php @@ -157,7 +157,8 @@ public static function createFromQueryParameter($parameter, ResourceType $resour foreach ($expanded as &$filter_item) { if (isset($filter_item[static::CONDITION_KEY][EntityCondition::PATH_KEY])) { $unresolved = $filter_item[static::CONDITION_KEY][EntityCondition::PATH_KEY]; - $filter_item[static::CONDITION_KEY][EntityCondition::PATH_KEY] = $field_resolver->resolveInternalEntityQueryPath($resource_type, $unresolved); + $operator = $filter_item[static::CONDITION_KEY][EntityCondition::OPERATOR_KEY]; + $filter_item[static::CONDITION_KEY][EntityCondition::PATH_KEY] = $field_resolver->resolveInternalEntityQueryPath($resource_type, $unresolved, $operator); } } return new static(static::buildEntityConditionGroup($expanded)); diff --git a/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php index ea78b4f53f2a68b1f47f18af2d51eb76de18c187..35379fa1f065fad94e0049cb84990ab7d298c93c 100644 --- a/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php +++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php @@ -918,6 +918,62 @@ public function testMapFieldTypeNormalizationFromIssue3040590() { $this->assertSame(['foo' => 'bar'], $data['data'][0]['attributes']['data']); } + /** + * Ensure filtering for entities with empty entity reference fields works. + * + * @see https://www.drupal.org/project/jsonapi/issues/3025372 + */ + public function testEmptyRelationshipFilteringFromIssue3025372() { + // Set up data model. + $this->drupalCreateContentType(['type' => 'folder']); + $this->createEntityReferenceField( + 'node', + 'folder', + 'field_parent_folder', + NULL, + 'node', + 'default', + [ + 'target_bundles' => ['folder'], + ], + FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED + ); + $this->rebuildAll(); + + // Create data. + $node = Node::create([ + 'title' => 'root folder', + 'type' => 'folder', + ]); + $node->save(); + + // Test. + $user = $this->drupalCreateUser(['access content']); + $url = Url::fromRoute('jsonapi.node--folder.collection'); + $request_options = [ + RequestOptions::HEADERS => [ + 'Content-Type' => 'application/vnd.api+json', + 'Accept' => 'application/vnd.api+json', + ], + RequestOptions::AUTH => [$user->getAccountName(), $user->pass_raw], + ]; + $response = $this->request('GET', $url, $request_options); + $this->assertSame(200, $response->getStatusCode(), (string) $response->getBody()); + $this->assertSame($node->uuid(), Json::decode((string) $response->getBody())['data'][0]['id']); + $response = $this->request('GET', $url->setOption('query', [ + 'filter[test][condition][path]' => 'field_parent_folder', + 'filter[test][condition][operator]' => 'IS NULL', + ]), $request_options); + $this->assertSame(200, $response->getStatusCode(), (string) $response->getBody()); + $this->assertSame($node->uuid(), Json::decode((string) $response->getBody())['data'][0]['id']); + $response = $this->request('GET', $url->setOption('query', [ + 'filter[test][condition][path]' => 'field_parent_folder', + 'filter[test][condition][operator]' => 'IS NOT NULL', + ]), $request_options); + $this->assertSame(200, $response->getStatusCode(), (string) $response->getBody()); + $this->assertEmpty(Json::decode((string) $response->getBody())['data']); + } + /** * Tests that the response still has meaningful error messages. */ diff --git a/core/modules/jsonapi/tests/src/Kernel/Query/FilterTest.php b/core/modules/jsonapi/tests/src/Kernel/Query/FilterTest.php index 66c06900136529866779b89b5080cfc06c553b37..4adb923590b94493d8a537916f6c5da0cffa0786 100644 --- a/core/modules/jsonapi/tests/src/Kernel/Query/FilterTest.php +++ b/core/modules/jsonapi/tests/src/Kernel/Query/FilterTest.php @@ -413,7 +413,7 @@ public function testCreateFromQueryParameterNested() { */ protected function getFieldResolverMock(ResourceType $resource_type) { $field_resolver = $this->prophesize(FieldResolver::class); - $field_resolver->resolveInternalEntityQueryPath($resource_type, Argument::any())->willReturnArgument(1); + $field_resolver->resolveInternalEntityQueryPath($resource_type, Argument::any(), Argument::any())->willReturnArgument(1); return $field_resolver->reveal(); }