diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e223feed83..c5203e1344a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v5.0.4 + +### Notes + +* Laravel: an optional `BelongsTo` or `MorphTo` relation (nullable foreign key column) is now typed as nullable, so it accepts `null` on write and clears the relation (#8664). This changes the generated docs for every nullable `BelongsTo`: JSON Schema and OpenAPI mark it nullable, and GraphQL drops the `!` on these fields, inputs included. + ## v5.0.3 API Platform 5.0.3 contains every change shipped in [v4.4.4](#v444) except the revert of #8600 ([#8637](https://github.com/api-platform/core/pull/8637)): 5.0 keeps documenting filterless array query parameters as both `key` and `key[]`, now guarded against `key[][]`, plus the changes below. diff --git a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php index 0fcfa13776c..645969d8db9 100644 --- a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php +++ b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php @@ -118,6 +118,8 @@ public function create(string $resourceClass, string $property, array $options = $type = Type::object($relation['related']); if ($collection) { $type = Type::iterable($type); + } elseif (true === ($relation['nullable'] ?? false)) { + $type = Type::nullable($type); } return $propertyMetadata diff --git a/src/Laravel/Eloquent/Metadata/ModelMetadata.php b/src/Laravel/Eloquent/Metadata/ModelMetadata.php index 6477748f51d..436e258a721 100644 --- a/src/Laravel/Eloquent/Metadata/ModelMetadata.php +++ b/src/Laravel/Eloquent/Metadata/ModelMetadata.php @@ -209,6 +209,7 @@ public function getRelations(Model $model): array } $relations = []; + $nullableColumns = null; $class = new \ReflectionClass($model); foreach ($class->getMethods() as $method) { @@ -262,6 +263,16 @@ public function getRelations(Model $model): array 'related' => \get_class($relation->getRelated()), 'foreign_key' => method_exists($relation, 'getForeignKeyName') ? $relation->getForeignKeyName() : null, ]; + + if ($relation instanceof BelongsTo) { + $nullableColumns ??= array_column($model->getConnection()->getSchemaBuilder()->getColumns($model->getTable()), 'nullable', 'name'); + $relations[$relationName]['nullable'] = (bool) ($nullableColumns[$relation->getForeignKeyName()] ?? false); + } + } + + // Don't cache nullability read from a missing table, see getAttributes(). + if ([] === $nullableColumns) { + return $relations; } return $this->relations[$model::class] = $relations; diff --git a/src/Laravel/Eloquent/State/PersistProcessor.php b/src/Laravel/Eloquent/State/PersistProcessor.php index af5eb28660a..93786c9577c 100644 --- a/src/Laravel/Eloquent/State/PersistProcessor.php +++ b/src/Laravel/Eloquent/State/PersistProcessor.php @@ -44,6 +44,18 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $toMany = []; foreach ($this->modelMetadata->getRelations($data) as $relation) { + // isset() is false for null, so an explicit null would be skipped and saved as a column named after the relation. + if ( + (BelongsTo::class === $relation['type'] || MorphTo::class === $relation['type']) + && \array_key_exists($relation['name'], $data->getAttributes()) + && null === $data->getAttributes()[$relation['name']] + ) { + $data->{$relation['method_name']}()->dissociate(); + unset($data->{$relation['name']}); + + continue; + } + if (!isset($data->{$relation['name']})) { continue; } diff --git a/src/Laravel/Tests/NullableBelongsToTest.php b/src/Laravel/Tests/NullableBelongsToTest.php new file mode 100644 index 00000000000..d90bea255d2 --- /dev/null +++ b/src/Laravel/Tests/NullableBelongsToTest.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Tests; + +use Illuminate\Foundation\Testing\RefreshDatabase; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Workbench\App\Models\DeliveryRequest; +use Workbench\App\Models\NullableMorphNote; +use Workbench\Database\Factories\NullableMorphNoteFactory; +use Workbench\Database\Factories\TimeSlotFactory; + +/** + * @see https://github.com/api-platform/core/issues/8664 + */ +final class NullableBelongsToTest extends TestCase +{ + use RefreshDatabase; + use WithWorkbench; + + public function testPostWithExplicitNullBelongsTo(): void + { + $response = $this->postJson('/api/delivery_requests', [ + 'pickupTimeSlot' => null, + 'note' => 'no slot', + ], ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); + + $response->assertStatus(201); + $this->assertNull(DeliveryRequest::query()->sole()->pickup_time_slot_id); // @phpstan-ignore-line + } + + public function testPatchWithExplicitNullClearsBelongsTo(): void + { + $iri = $this->createDeliveryRequestWithSlot(); + + $response = $this->patchJson($iri, [ + 'pickupTimeSlot' => null, + ], ['accept' => 'application/ld+json', 'content-type' => 'application/merge-patch+json']); + + $response->assertStatus(200); + $this->assertNull(DeliveryRequest::query()->sole()->pickup_time_slot_id); // @phpstan-ignore-line + } + + public function testOmittingBelongsToKeepsIt(): void + { + $iri = $this->createDeliveryRequestWithSlot(); + $slotId = DeliveryRequest::query()->sole()->pickup_time_slot_id; // @phpstan-ignore-line + + $response = $this->patchJson($iri, [ + 'note' => 'updated', + ], ['accept' => 'application/ld+json', 'content-type' => 'application/merge-patch+json']); + + $response->assertStatus(200); + $deliveryRequest = DeliveryRequest::query()->sole(); + $this->assertSame($slotId, $deliveryRequest->pickup_time_slot_id); // @phpstan-ignore-line + $this->assertSame('updated', $deliveryRequest->note); // @phpstan-ignore-line + } + + public function testPatchWithExplicitNullClearsMorphTo(): void + { + $slot = TimeSlotFactory::new()->create(); + $note = NullableMorphNoteFactory::new()->for($slot, 'noteable')->create(); + $this->assertNotNull(NullableMorphNote::query()->sole()->noteable_id); // @phpstan-ignore-line + + $response = $this->patchJson('/api/nullable_morph_notes/'.$note->id, [ // @phpstan-ignore-line + 'noteable' => null, + ], ['accept' => 'application/ld+json', 'content-type' => 'application/merge-patch+json']); + + $response->assertStatus(200); + $note = NullableMorphNote::query()->sole(); + $this->assertNull($note->noteable_id); // @phpstan-ignore-line + $this->assertNull($note->noteable_type); // @phpstan-ignore-line + } + + private function createDeliveryRequestWithSlot(): string + { + $slot = TimeSlotFactory::new()->create(); + + $response = $this->postJson('/api/delivery_requests', [ + 'pickupTimeSlot' => '/api/time_slots/'.$slot->id, // @phpstan-ignore-line + 'note' => 'with slot', + ], ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); + $response->assertStatus(201); + $this->assertNotNull(DeliveryRequest::query()->sole()->pickup_time_slot_id); // @phpstan-ignore-line + + return $response->json('@id'); + } +} diff --git a/src/Laravel/workbench/app/Models/DeliveryRequest.php b/src/Laravel/workbench/app/Models/DeliveryRequest.php index e79e35d23d7..bfbad142f5b 100644 --- a/src/Laravel/workbench/app/Models/DeliveryRequest.php +++ b/src/Laravel/workbench/app/Models/DeliveryRequest.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -35,6 +36,18 @@ ], ] ), + new Patch( + normalizationContext: [ + 'groups' => [ + 'delivery_request:read', + ], + ], + denormalizationContext: [ + 'groups' => [ + 'delivery_request:write', + ], + ] + ), ] )] #[ApiProperty(property: 'pickupTimeSlot', serialize: new Groups(['delivery_request:read', 'delivery_request:write']))] diff --git a/src/Laravel/workbench/app/Models/NullableMorphNote.php b/src/Laravel/workbench/app/Models/NullableMorphNote.php new file mode 100644 index 00000000000..5d3a804c375 --- /dev/null +++ b/src/Laravel/workbench/app/Models/NullableMorphNote.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\App\Models; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphTo; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + operations: [ + new Get( + normalizationContext: ['groups' => ['nullable_morph_note:read']], + ), + new Post( + normalizationContext: ['groups' => ['nullable_morph_note:read']], + denormalizationContext: ['groups' => ['nullable_morph_note:write']], + ), + new Patch( + normalizationContext: ['groups' => ['nullable_morph_note:read']], + denormalizationContext: ['groups' => ['nullable_morph_note:write']], + ), + ] +)] +#[ApiProperty(property: 'noteable', serialize: new Groups(['nullable_morph_note:read', 'nullable_morph_note:write']))] +#[ApiProperty(property: 'content', serialize: new Groups(['nullable_morph_note:read', 'nullable_morph_note:write']))] +class NullableMorphNote extends Model +{ + use HasFactory; + + public function noteable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/src/Laravel/workbench/database/factories/NullableMorphNoteFactory.php b/src/Laravel/workbench/database/factories/NullableMorphNoteFactory.php new file mode 100644 index 00000000000..84d96d1c674 --- /dev/null +++ b/src/Laravel/workbench/database/factories/NullableMorphNoteFactory.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\Database\Factories; + +use Illuminate\Database\Eloquent\Factories\Factory; +use Workbench\App\Models\NullableMorphNote; + +class NullableMorphNoteFactory extends Factory +{ + protected $model = NullableMorphNote::class; + + public function definition(): array + { + return [ + 'content' => $this->faker->sentence(), + ]; + } +} diff --git a/src/Laravel/workbench/database/migrations/2026_10_13_000000_create_nullable_morph_notes_table.php b/src/Laravel/workbench/database/migrations/2026_10_13_000000_create_nullable_morph_notes_table.php new file mode 100644 index 00000000000..3daf1ff7c33 --- /dev/null +++ b/src/Laravel/workbench/database/migrations/2026_10_13_000000_create_nullable_morph_notes_table.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration { + public function up(): void + { + Schema::create('nullable_morph_notes', static function (Blueprint $table): void { + $table->id(); + $table->nullableMorphs('noteable'); + $table->string('content')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('nullable_morph_notes'); + } +};