diff --git a/src/JsonApi/JsonSchema/SchemaFactory.php b/src/JsonApi/JsonSchema/SchemaFactory.php index cc2ba9d72ce..3228d172386 100644 --- a/src/JsonApi/JsonSchema/SchemaFactory.php +++ b/src/JsonApi/JsonSchema/SchemaFactory.php @@ -321,7 +321,12 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, } $relatedDefinitions[$propertyName] = array_flip($refs); if ($isOne) { - $relationships[$propertyName]['properties']['data'] = self::RELATION_PROPS; + $relationships[$propertyName]['properties']['data'] = [ + 'oneOf' => [ + ['type' => 'null'], + self::RELATION_PROPS, + ], + ]; continue; } $relationships[$propertyName]['properties']['data'] = [ diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 47a5879a4b4..4b3084db277 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -460,16 +460,16 @@ private function getPopulatedRelations(object $object, ?string $format, array $c $relationshipName = $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context); } - $data[$relationshipName] = [ - 'data' => [], - ]; - - if (!$attributeValue) { - continue; - } - // Many to one relationship if ('one' === $relationshipDataArray['cardinality']) { + $data[$relationshipName] = [ + 'data' => null, + ]; + + if (!$attributeValue) { + continue; + } + unset($attributeValue['data']['attributes']); $data[$relationshipName] = $attributeValue; @@ -477,6 +477,14 @@ private function getPopulatedRelations(object $object, ?string $format, array $c } // Many to many relationship + $data[$relationshipName] = [ + 'data' => [], + ]; + + if (!$attributeValue) { + continue; + } + foreach ($attributeValue as $attributeValueElement) { if (!isset($attributeValueElement['data'])) { throw new UnexpectedValueException(\sprintf('The JSON API attribute \'%s\' must contain a "data" key.', $relationshipName)); diff --git a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php index d6bea46d1ca..6775d2339ba 100644 --- a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php +++ b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php @@ -488,4 +488,93 @@ public function testDenormalizeRelationIsNotResourceLinkage(): void $normalizer->denormalize($data, Dummy::class, ItemNormalizer::FORMAT); } + + public function testNormalizeWithNullToOneAndEmptyToManyRelationships(): void + { + $dummy = new Dummy(); + $dummy->setId(1); + $dummy->setName('Dummy with relationships'); + + $propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->method('create')->willReturn( + new PropertyNameCollection(['id', 'name', 'relatedDummy', 'relatedDummies']) + ); + + $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturnCallback(function ($class, $property) { + return match ($property) { + 'id' => (new ApiProperty())->withReadable(true)->withIdentifier(true), + 'name' => (new ApiProperty())->withReadable(true), + 'relatedDummy' => (new ApiProperty()) + ->withReadable(true) + ->withReadableLink(true) + ->withNativeType(Type::nullable(Type::object(RelatedDummy::class))), + 'relatedDummies' => (new ApiProperty()) + ->withReadable(true) + ->withReadableLink(true) + ->withNativeType(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class))), + default => new ApiProperty(), + }; + }); + + $iriConverter = $this->createMock(IriConverterInterface::class); + $iriConverter->method('getIriFromResource')->willReturn('/dummies/1'); + + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $resourceClassResolver->method('getResourceClass')->willReturn(Dummy::class); + $resourceClassResolver->method('isResourceClass')->willReturnCallback(fn ($class) => \in_array($class, [Dummy::class, RelatedDummy::class], true)); + + $propertyAccessor = $this->createMock(PropertyAccessorInterface::class); + $propertyAccessor->method('getValue')->willReturnCallback(function ($object, $property) { + return match ($property) { + 'id' => 1, + 'name' => 'Dummy with relationships', + 'relatedDummy' => null, + 'relatedDummies' => [], + default => null, + }; + }); + + $resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->method('create')->willReturn( + new ResourceMetadataCollection('Dummy', [ + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + ]) + ); + + $serializer = $this->createStub(Serializer::class); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $iriConverter, + $resourceClassResolver, + $propertyAccessor, + null, + null, + [], + $resourceMetadataCollectionFactory, + ); + + $normalizer->setSerializer($serializer); + + $result = $normalizer->normalize($dummy, ItemNormalizer::FORMAT, [ + 'resources' => [], + 'resource_class' => Dummy::class, + ]); + + $this->assertIsArray($result); + $this->assertArrayHasKey('data', $result); + $this->assertArrayHasKey('relationships', $result['data']); + + // Verify to-one relationship with null value returns {"data": null} + $this->assertArrayHasKey('relatedDummy', $result['data']['relationships']); + $this->assertSame(['data' => null], $result['data']['relationships']['relatedDummy']); + + // Verify to-many relationship with empty array returns {"data": []} + $this->assertArrayHasKey('relatedDummies', $result['data']['relationships']); + $this->assertSame(['data' => []], $result['data']['relationships']['relatedDummies']); + } }