From 3bd70eafd04eb7ac34ff947984baa03cfdff3a12 Mon Sep 17 00:00:00 2001 From: Pieter Willekens Date: Tue, 10 Feb 2026 15:19:29 +0100 Subject: [PATCH] fix: wrap bare intersection types in parentheses when adding nullable (#1756) When a MorphTo relation's docblock uses a bare intersection type in the generic parameter (e.g. `MorphTo`), the generated property type incorrectly becomes `BaseModel&CanBeAssigned|null` instead of the correct `(BaseModel&CanBeAssigned)|null`. This adds `wrapIntersectionType()` which ensures bare intersection types are wrapped in parentheses before `|null` is appended, producing valid DNF type syntax. Applied in both `setProperty()` and `applyNullability()`. --- src/Console/ModelsCommand.php | 20 +++++++- .../MorphToIntersection/Models/BaseModel.php | 11 +++++ .../Models/CanBeAssigned.php | 9 ++++ .../Models/MorphToIntersection.php | 31 ++++++++++++ .../MorphToIntersection/Test.php | 24 ++++++++++ .../__snapshots__/Test__test__1.php | 48 +++++++++++++++++++ 6 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 tests/Console/ModelsCommand/MorphToIntersection/Models/BaseModel.php create mode 100644 tests/Console/ModelsCommand/MorphToIntersection/Models/CanBeAssigned.php create mode 100644 tests/Console/ModelsCommand/MorphToIntersection/Models/MorphToIntersection.php create mode 100644 tests/Console/ModelsCommand/MorphToIntersection/Test.php create mode 100644 tests/Console/ModelsCommand/MorphToIntersection/__snapshots__/Test__test__1.php diff --git a/src/Console/ModelsCommand.php b/src/Console/ModelsCommand.php index be35b39..9303d80 100644 --- a/src/Console/ModelsCommand.php +++ b/src/Console/ModelsCommand.php @@ -534,7 +534,7 @@ class ModelsCommand extends Command } if ($isNullable) { - $type .= '|null'; + $type = $this->wrapIntersectionType($type) . '|null'; } else { $type = str_replace($nullString, '', $type); } @@ -542,6 +542,22 @@ class ModelsCommand extends Command return $type; } + /** + * Wraps a bare intersection type in parentheses for correct DNF syntax. + * + * For example, `A&B` becomes `(A&B)` so that adding `|null` produces + * `(A&B)|null` instead of the ambiguous `A&B|null`. + * Types that are already parenthesized or contain union types are returned as-is. + */ + protected function wrapIntersectionType(string $type): string + { + if (str_contains($type, '&') && !str_contains($type, '|') && $type[0] !== '(') { + return '(' . $type . ')'; + } + + return $type; + } + /** * Returns the override type for the give type. * @@ -995,7 +1011,7 @@ class ModelsCommand extends Command if ($type !== null) { $newType = $this->getTypeOverride($type); if ($nullable) { - $newType .= '|null'; + $newType = $this->wrapIntersectionType($newType) . '|null'; } $this->properties[$name]['type'] = $newType; } diff --git a/tests/Console/ModelsCommand/MorphToIntersection/Models/BaseModel.php b/tests/Console/ModelsCommand/MorphToIntersection/Models/BaseModel.php new file mode 100644 index 0000000..fc012e4 --- /dev/null +++ b/tests/Console/ModelsCommand/MorphToIntersection/Models/BaseModel.php @@ -0,0 +1,11 @@ + */ + public function assigneeWithParens(): MorphTo + { + return $this->morphTo(type: 'nullable_relation_morph_to_type', id: 'nullable_relation_morph_to_id'); + } + + /** @return MorphTo */ + public function assigneeWithoutParens(): MorphTo + { + return $this->morphTo(type: 'nullable_relation_morph_to_type', id: 'nullable_relation_morph_to_id'); + } + + /** @return MorphTo<(BaseModel&CanBeAssigned), $this> */ + public function nonNullableAssignee(): MorphTo + { + return $this->morphTo(type: 'relation_morph_to_type', id: 'relation_morph_to_id'); + } +} diff --git a/tests/Console/ModelsCommand/MorphToIntersection/Test.php b/tests/Console/ModelsCommand/MorphToIntersection/Test.php new file mode 100644 index 0000000..d99a7b1 --- /dev/null +++ b/tests/Console/ModelsCommand/MorphToIntersection/Test.php @@ -0,0 +1,24 @@ +app->make(ModelsCommand::class); + + $tester = $this->runCommand($command, [ + '--write' => true, + ]); + + $this->assertSame(0, $tester->getStatusCode()); + $this->assertStringContainsString('Written new phpDocBlock to', $tester->getDisplay()); + $this->assertMatchesMockedSnapshot(); + } +} diff --git a/tests/Console/ModelsCommand/MorphToIntersection/__snapshots__/Test__test__1.php b/tests/Console/ModelsCommand/MorphToIntersection/__snapshots__/Test__test__1.php new file mode 100644 index 0000000..0e5d6ba --- /dev/null +++ b/tests/Console/ModelsCommand/MorphToIntersection/__snapshots__/Test__test__1.php @@ -0,0 +1,48 @@ +|MorphToIntersection newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|MorphToIntersection newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|MorphToIntersection query() + * @method static \Illuminate\Database\Eloquent\Builder|MorphToIntersection whereNullableRelationMorphToId($value) + * @method static \Illuminate\Database\Eloquent\Builder|MorphToIntersection whereNullableRelationMorphToType($value) + * @method static \Illuminate\Database\Eloquent\Builder|MorphToIntersection whereRelationMorphToId($value) + * @method static \Illuminate\Database\Eloquent\Builder|MorphToIntersection whereRelationMorphToType($value) + * @mixin \Eloquent + */ +class MorphToIntersection extends Model +{ + protected $table = 'morphs'; + + /** @return MorphTo<(BaseModel&CanBeAssigned), $this> */ + public function assigneeWithParens(): MorphTo + { + return $this->morphTo(type: 'nullable_relation_morph_to_type', id: 'nullable_relation_morph_to_id'); + } + + /** @return MorphTo */ + public function assigneeWithoutParens(): MorphTo + { + return $this->morphTo(type: 'nullable_relation_morph_to_type', id: 'nullable_relation_morph_to_id'); + } + + /** @return MorphTo<(BaseModel&CanBeAssigned), $this> */ + public function nonNullableAssignee(): MorphTo + { + return $this->morphTo(type: 'relation_morph_to_type', id: 'relation_morph_to_id'); + } +}