diff --git a/CHANGELOG.md b/CHANGELOG.md index 65c1bdf98..9f9833721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ - Drop support for Laravel versions earlier than 11.15. ### Added +- Introduce `enforce_nullable_relationships` configuration option to control how nullable Eloquent relationships are enforced during static analysis. This provides flexibility for scenarios where application logic ensures data integrity without relying on database constraints. [#1580 / jeramyhing](https://github.com/barryvdh/laravel-ide-helper/pull/1580) - Add support for AsCollection::using and AsEnumCollection::of casts [#1577 / uno-sw](https://github.com/barryvdh/laravel-ide-helper/pull/1577) diff --git a/config/ide-helper.php b/config/ide-helper.php index 2e5be0153..5519dca8b 100644 --- a/config/ide-helper.php +++ b/config/ide-helper.php @@ -299,6 +299,29 @@ */ 'additional_relation_return_types' => [], + /* + |-------------------------------------------------------------------------- + | Enforce nullable Eloquent relationships on not null columns + |-------------------------------------------------------------------------- + | + | When set to true (default), this option enforces nullable Eloquent relationships. + | However, in cases where the application logic ensures the presence of related + | records it may be desirable to set this option to false to avoid unwanted null warnings. + | + | Default: true + | A not null column with no foreign key constraint will have a "nullable" relationship. + | * @property int $not_null_column_with_no_foreign_key_constraint + | * @property-read BelongsToVariation|null $notNullColumnWithNoForeignKeyConstraint + | + | Option: false + | A not null column with no foreign key constraint will have a "not nullable" relationship. + | * @property int $not_null_column_with_no_foreign_key_constraint + | * @property-read BelongsToVariation $notNullColumnWithNoForeignKeyConstraint + | + */ + + 'enforce_nullable_relationships' => true, + /* |-------------------------------------------------------------------------- | Run artisan commands after migrations to generate model helpers diff --git a/src/Console/ModelsCommand.php b/src/Console/ModelsCommand.php index 021d449e7..7b212941e 100644 --- a/src/Console/ModelsCommand.php +++ b/src/Console/ModelsCommand.php @@ -836,13 +836,15 @@ protected function isRelationNullable(string $relation, Relation $relationObj): $fkProp = $reflectionObj->getProperty('foreignKey'); $fkProp->setAccessible(true); + $enforceNullableRelation = $this->laravel['config']->get('ide-helper.enforce_nullable_relationships', true); + foreach (Arr::wrap($fkProp->getValue($relationObj)) as $foreignKey) { if (isset($this->nullableColumns[$foreignKey])) { return true; } if (!in_array($foreignKey, $this->foreignKeyConstraintsColumns, true)) { - return true; + return $enforceNullableRelation; } } diff --git a/tests/Console/ModelsCommand/Relations/Test.php b/tests/Console/ModelsCommand/Relations/Test.php index 3c5e41c56..e78c6cb2a 100644 --- a/tests/Console/ModelsCommand/Relations/Test.php +++ b/tests/Console/ModelsCommand/Relations/Test.php @@ -46,4 +46,23 @@ public function test(): void $this->assertStringContainsString('Written new phpDocBlock to', $tester->getDisplay()); $this->assertMatchesMockedSnapshot(); } + + public function testRelationNotNullable(): void + { + // Disable enforcing nullable relationships + Config::set('ide-helper.enforce_nullable_relationships', false); + + $command = $this->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(); + + // Re-enable default enforcing nullable relationships + Config::set('ide-helper.enforce_nullable_relationships', true); + } } diff --git a/tests/Console/ModelsCommand/Relations/__snapshots__/Test__testRelationNotNullable__1.php b/tests/Console/ModelsCommand/Relations/__snapshots__/Test__testRelationNotNullable__1.php new file mode 100644 index 000000000..6108f44aa --- /dev/null +++ b/tests/Console/ModelsCommand/Relations/__snapshots__/Test__testRelationNotNullable__1.php @@ -0,0 +1,266 @@ +belongsTo(self::class, 'not_null_column_with_foreign_key_constraint'); + } + + public function notNullColumnWithNoForeignKeyConstraint(): BelongsTo + { + return $this->belongsTo(self::class, 'not_null_column_with_no_foreign_key_constraint'); + } + + public function nullableColumnWithForeignKeyConstraint(): BelongsTo + { + return $this->belongsTo(self::class, 'nullable_column_with_foreign_key_constraint'); + } + + public function nullableColumnWithNoForeignKeyConstraint(): BelongsTo + { + return $this->belongsTo(self::class, 'nullable_column_with_no_foreign_key_constraint'); + } +} +belongsTo( + self::class, + ['not_null_column_with_foreign_key_constraint', 'not_null_column_with_foreign_key_constraint'], + ['not_null_column_with_foreign_key_constraint', 'not_null_column_with_foreign_key_constraint'], + ); + } + + public function nonNullableMixedWithoutForeignKeyConstraint(): BelongsTo + { + return $this->belongsTo( + self::class, + ['not_null_column_with_foreign_key_constraint', 'not_null_column_with_no_foreign_key_constraint'], + ['not_null_column_with_foreign_key_constraint', 'not_null_column_with_no_foreign_key_constraint'], + ); + } + + public function nullableMixedWithForeignKeyConstraint(): BelongsTo + { + return $this->belongsTo( + self::class, + ['nullable_column_with_no_foreign_key_constraint', 'not_null_column_with_foreign_key_constraint'], + ['nullable_column_with_no_foreign_key_constraint', 'not_null_column_with_foreign_key_constraint'], + ); + } +} + $relationBelongsToMany + * @property-read int|null $relation_belongs_to_many_count + * @property-read \Illuminate\Database\Eloquent\Collection $relationBelongsToManyWithSub + * @property-read int|null $relation_belongs_to_many_with_sub_count + * @property-read \Illuminate\Database\Eloquent\Collection $relationBelongsToManyWithSubAnother + * @property-read int|null $relation_belongs_to_many_with_sub_another_count + * @property-read AnotherModel $relationBelongsToSameNameAsColumn + * @property-read \Illuminate\Database\Eloquent\Collection $relationHasMany + * @property-read int|null $relation_has_many_count + * @property-read Simple|null $relationHasOne + * @property-read Simple $relationHasOneWithDefault + * @property-read \Illuminate\Database\Eloquent\Collection $relationMorphMany + * @property-read int|null $relation_morph_many_count + * @property-read Simple|null $relationMorphOne + * @property-read Model|\Eloquent $relationMorphTo + * @property-read \Illuminate\Database\Eloquent\Collection $relationMorphedByMany + * @property-read int|null $relation_morphed_by_many_count + * @property-read \Illuminate\Database\Eloquent\Collection $relationSampleRelationType + * @property-read int|null $relation_sample_relation_type_count + * @property-read Model|\Eloquent $relationSampleToAnyMorphedRelationType + * @property-read \Illuminate\Database\Eloquent\Collection $relationSampleToAnyRelationType + * @property-read int|null $relation_sample_to_any_relation_type_count + * @property-read Simple $relationSampleToBadlyNamedNotManyRelation + * @property-read Simple $relationSampleToManyRelationType + * @method static \Illuminate\Database\Eloquent\Builder|Simple newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Simple newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Simple query() + * @method static \Illuminate\Database\Eloquent\Builder|Simple whereId($value) + * @mixin \Eloquent + */ +class Simple extends Model +{ + use HasTestRelations; + + // Regular relations + public function relationHasMany(): HasMany + { + return $this->hasMany(Simple::class); + } + + public function relationHasOne(): HasOne + { + return $this->hasOne(Simple::class); + } + + public function relationHasOneWithDefault(): HasOne + { + return $this->hasOne(Simple::class)->withDefault(); + } + + public function relationBelongsTo(): BelongsTo + { + return $this->belongsTo(Simple::class); + } + + public function relationBelongsToMany(): BelongsToMany + { + return $this->belongsToMany(Simple::class); + } + + public function relationBelongsToManyWithSub(): BelongsToMany + { + return $this->belongsToMany(Simple::class)->where('foo', 'bar'); + } + + public function relationBelongsToManyWithSubAnother(): BelongsToMany + { + return $this->relationBelongsToManyWithSub()->where('foo', 'bar'); + } + + public function relationMorphTo(): MorphTo + { + return $this->morphTo(); + } + + public function relationMorphOne(): MorphOne + { + return $this->morphOne(Simple::class, 'relationMorphTo'); + } + + public function relationMorphMany(): MorphMany + { + return $this->morphMany(Simple::class, 'relationMorphTo'); + } + + public function relationMorphedByMany(): MorphToMany + { + return $this->morphedByMany(Simple::class, 'foo'); + } + + // Custom relations + + public function relationBelongsToInAnotherNamespace(): BelongsTo + { + return $this->belongsTo(AnotherModel::class); + } + + public function relationBelongsToSameNameAsColumn(): BelongsTo + { + return $this->belongsTo(AnotherModel::class, __FUNCTION__); + } + + public function relationSampleToManyRelationType() + { + return $this->testToOneRelation(Simple::class); + } + + public function relationSampleRelationType() + { + return $this->testToManyRelation(Simple::class); + } + + public function relationSampleToAnyRelationType() + { + return $this->testToAnyRelation(Simple::class); + } + + public function relationSampleToAnyMorphedRelationType() + { + return $this->testToAnyMorphedRelation(Simple::class); + } + + public function relationSampleToBadlyNamedNotManyRelation() + { + return $this->testToBadlyNamedNotManyRelation(Simple::class); + } +}