Skip to content

Commit 472c1ba

Browse files
authored
Feature: Add Config Option to Enforce Nullable Relationships (#1580)
* feat: add config option for nullable relationships Introduce enforce_nullable_relationships configuration to control nullable Eloquent relationships. * refactor: update logic for nullable relationships Update isRelationNullable method to respect new config option. * test: add tests for nullable relationship config - Verify behavior of enforce_nullable_relationships configuration option. - Create snapshot to reflect enforce_nullable_relationships set to false. * docs: improve documented context and usage * docs: update CHANGELOG with enforce_nullable_relationships option
1 parent 90e7c5a commit 472c1ba

File tree

5 files changed

+312
-1
lines changed

5 files changed

+312
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
- Drop support for Laravel versions earlier than 11.15.
4040

4141
### Added
42+
- 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)
4243

4344
- Add support for AsCollection::using and AsEnumCollection::of casts [#1577 / uno-sw](https://github.com/barryvdh/laravel-ide-helper/pull/1577)
4445

config/ide-helper.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,29 @@
299299
*/
300300
'additional_relation_return_types' => [],
301301

302+
/*
303+
|--------------------------------------------------------------------------
304+
| Enforce nullable Eloquent relationships on not null columns
305+
|--------------------------------------------------------------------------
306+
|
307+
| When set to true (default), this option enforces nullable Eloquent relationships.
308+
| However, in cases where the application logic ensures the presence of related
309+
| records it may be desirable to set this option to false to avoid unwanted null warnings.
310+
|
311+
| Default: true
312+
| A not null column with no foreign key constraint will have a "nullable" relationship.
313+
| * @property int $not_null_column_with_no_foreign_key_constraint
314+
| * @property-read BelongsToVariation|null $notNullColumnWithNoForeignKeyConstraint
315+
|
316+
| Option: false
317+
| A not null column with no foreign key constraint will have a "not nullable" relationship.
318+
| * @property int $not_null_column_with_no_foreign_key_constraint
319+
| * @property-read BelongsToVariation $notNullColumnWithNoForeignKeyConstraint
320+
|
321+
*/
322+
323+
'enforce_nullable_relationships' => true,
324+
302325
/*
303326
|--------------------------------------------------------------------------
304327
| Run artisan commands after migrations to generate model helpers

src/Console/ModelsCommand.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -836,13 +836,15 @@ protected function isRelationNullable(string $relation, Relation $relationObj):
836836
$fkProp = $reflectionObj->getProperty('foreignKey');
837837
$fkProp->setAccessible(true);
838838

839+
$enforceNullableRelation = $this->laravel['config']->get('ide-helper.enforce_nullable_relationships', true);
840+
839841
foreach (Arr::wrap($fkProp->getValue($relationObj)) as $foreignKey) {
840842
if (isset($this->nullableColumns[$foreignKey])) {
841843
return true;
842844
}
843845

844846
if (!in_array($foreignKey, $this->foreignKeyConstraintsColumns, true)) {
845-
return true;
847+
return $enforceNullableRelation;
846848
}
847849
}
848850

tests/Console/ModelsCommand/Relations/Test.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,23 @@ public function test(): void
4646
$this->assertStringContainsString('Written new phpDocBlock to', $tester->getDisplay());
4747
$this->assertMatchesMockedSnapshot();
4848
}
49+
50+
public function testRelationNotNullable(): void
51+
{
52+
// Disable enforcing nullable relationships
53+
Config::set('ide-helper.enforce_nullable_relationships', false);
54+
55+
$command = $this->app->make(ModelsCommand::class);
56+
57+
$tester = $this->runCommand($command, [
58+
'--write' => true,
59+
]);
60+
61+
$this->assertSame(0, $tester->getStatusCode());
62+
$this->assertStringContainsString('Written new phpDocBlock to', $tester->getDisplay());
63+
$this->assertMatchesMockedSnapshot();
64+
65+
// Re-enable default enforcing nullable relationships
66+
Config::set('ide-helper.enforce_nullable_relationships', true);
67+
}
4968
}
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\Relations\Models;
6+
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
10+
/**
11+
*
12+
*
13+
* @property int $id
14+
* @property int $not_null_column_with_foreign_key_constraint
15+
* @property int $not_null_column_with_no_foreign_key_constraint
16+
* @property int|null $nullable_column_with_foreign_key_constraint
17+
* @property int|null $nullable_column_with_no_foreign_key_constraint
18+
* @property-read BelongsToVariation $notNullColumnWithForeignKeyConstraint
19+
* @property-read BelongsToVariation $notNullColumnWithNoForeignKeyConstraint
20+
* @property-read BelongsToVariation|null $nullableColumnWithForeignKeyConstraint
21+
* @property-read BelongsToVariation|null $nullableColumnWithNoForeignKeyConstraint
22+
* @method static \Illuminate\Database\Eloquent\Builder|BelongsToVariation newModelQuery()
23+
* @method static \Illuminate\Database\Eloquent\Builder|BelongsToVariation newQuery()
24+
* @method static \Illuminate\Database\Eloquent\Builder|BelongsToVariation query()
25+
* @method static \Illuminate\Database\Eloquent\Builder|BelongsToVariation whereId($value)
26+
* @method static \Illuminate\Database\Eloquent\Builder|BelongsToVariation whereNotNullColumnWithForeignKeyConstraint($value)
27+
* @method static \Illuminate\Database\Eloquent\Builder|BelongsToVariation whereNotNullColumnWithNoForeignKeyConstraint($value)
28+
* @method static \Illuminate\Database\Eloquent\Builder|BelongsToVariation whereNullableColumnWithForeignKeyConstraint($value)
29+
* @method static \Illuminate\Database\Eloquent\Builder|BelongsToVariation whereNullableColumnWithNoForeignKeyConstraint($value)
30+
* @mixin \Eloquent
31+
*/
32+
class BelongsToVariation extends Model
33+
{
34+
public function notNullColumnWithForeignKeyConstraint(): BelongsTo
35+
{
36+
return $this->belongsTo(self::class, 'not_null_column_with_foreign_key_constraint');
37+
}
38+
39+
public function notNullColumnWithNoForeignKeyConstraint(): BelongsTo
40+
{
41+
return $this->belongsTo(self::class, 'not_null_column_with_no_foreign_key_constraint');
42+
}
43+
44+
public function nullableColumnWithForeignKeyConstraint(): BelongsTo
45+
{
46+
return $this->belongsTo(self::class, 'nullable_column_with_foreign_key_constraint');
47+
}
48+
49+
public function nullableColumnWithNoForeignKeyConstraint(): BelongsTo
50+
{
51+
return $this->belongsTo(self::class, 'nullable_column_with_no_foreign_key_constraint');
52+
}
53+
}
54+
<?php
55+
56+
declare(strict_types=1);
57+
58+
namespace Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\Relations\Models;
59+
60+
use Illuminate\Database\Eloquent\Model;
61+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
62+
63+
/**
64+
*
65+
*
66+
* @property int $id
67+
* @property int $not_null_column_with_foreign_key_constraint
68+
* @property int $not_null_column_with_no_foreign_key_constraint
69+
* @property int|null $nullable_column_with_foreign_key_constraint
70+
* @property int|null $nullable_column_with_no_foreign_key_constraint
71+
* @property-read CompositeBelongsToVariation $bothNonNullableWithForeignKeyConstraint
72+
* @property-read CompositeBelongsToVariation $nonNullableMixedWithoutForeignKeyConstraint
73+
* @property-read CompositeBelongsToVariation|null $nullableMixedWithForeignKeyConstraint
74+
* @method static \Illuminate\Database\Eloquent\Builder|CompositeBelongsToVariation newModelQuery()
75+
* @method static \Illuminate\Database\Eloquent\Builder|CompositeBelongsToVariation newQuery()
76+
* @method static \Illuminate\Database\Eloquent\Builder|CompositeBelongsToVariation query()
77+
* @method static \Illuminate\Database\Eloquent\Builder|CompositeBelongsToVariation whereId($value)
78+
* @method static \Illuminate\Database\Eloquent\Builder|CompositeBelongsToVariation whereNotNullColumnWithForeignKeyConstraint($value)
79+
* @method static \Illuminate\Database\Eloquent\Builder|CompositeBelongsToVariation whereNotNullColumnWithNoForeignKeyConstraint($value)
80+
* @method static \Illuminate\Database\Eloquent\Builder|CompositeBelongsToVariation whereNullableColumnWithForeignKeyConstraint($value)
81+
* @method static \Illuminate\Database\Eloquent\Builder|CompositeBelongsToVariation whereNullableColumnWithNoForeignKeyConstraint($value)
82+
* @mixin \Eloquent
83+
*/
84+
class CompositeBelongsToVariation extends Model
85+
{
86+
public $table = 'belongs_to_variations';
87+
88+
public function bothNonNullableWithForeignKeyConstraint(): BelongsTo
89+
{
90+
// Note, duplicating the keys here for simplicity.
91+
return $this->belongsTo(
92+
self::class,
93+
['not_null_column_with_foreign_key_constraint', 'not_null_column_with_foreign_key_constraint'],
94+
['not_null_column_with_foreign_key_constraint', 'not_null_column_with_foreign_key_constraint'],
95+
);
96+
}
97+
98+
public function nonNullableMixedWithoutForeignKeyConstraint(): BelongsTo
99+
{
100+
return $this->belongsTo(
101+
self::class,
102+
['not_null_column_with_foreign_key_constraint', 'not_null_column_with_no_foreign_key_constraint'],
103+
['not_null_column_with_foreign_key_constraint', 'not_null_column_with_no_foreign_key_constraint'],
104+
);
105+
}
106+
107+
public function nullableMixedWithForeignKeyConstraint(): BelongsTo
108+
{
109+
return $this->belongsTo(
110+
self::class,
111+
['nullable_column_with_no_foreign_key_constraint', 'not_null_column_with_foreign_key_constraint'],
112+
['nullable_column_with_no_foreign_key_constraint', 'not_null_column_with_foreign_key_constraint'],
113+
);
114+
}
115+
}
116+
<?php
117+
118+
declare(strict_types=1);
119+
120+
namespace Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\Relations\Models;
121+
122+
use Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\Relations\ModelsOtherNamespace\AnotherModel;
123+
use Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\Relations\Traits\HasTestRelations;
124+
use Illuminate\Database\Eloquent\Model;
125+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
126+
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
127+
use Illuminate\Database\Eloquent\Relations\HasMany;
128+
use Illuminate\Database\Eloquent\Relations\HasOne;
129+
use Illuminate\Database\Eloquent\Relations\MorphMany;
130+
use Illuminate\Database\Eloquent\Relations\MorphOne;
131+
use Illuminate\Database\Eloquent\Relations\MorphTo;
132+
use Illuminate\Database\Eloquent\Relations\MorphToMany;
133+
134+
/**
135+
*
136+
*
137+
* @property int $id
138+
* @property-read Simple $relationBelongsTo
139+
* @property-read AnotherModel $relationBelongsToInAnotherNamespace
140+
* @property-read \Illuminate\Database\Eloquent\Collection<int, Simple> $relationBelongsToMany
141+
* @property-read int|null $relation_belongs_to_many_count
142+
* @property-read \Illuminate\Database\Eloquent\Collection<int, Simple> $relationBelongsToManyWithSub
143+
* @property-read int|null $relation_belongs_to_many_with_sub_count
144+
* @property-read \Illuminate\Database\Eloquent\Collection<int, Simple> $relationBelongsToManyWithSubAnother
145+
* @property-read int|null $relation_belongs_to_many_with_sub_another_count
146+
* @property-read AnotherModel $relationBelongsToSameNameAsColumn
147+
* @property-read \Illuminate\Database\Eloquent\Collection<int, Simple> $relationHasMany
148+
* @property-read int|null $relation_has_many_count
149+
* @property-read Simple|null $relationHasOne
150+
* @property-read Simple $relationHasOneWithDefault
151+
* @property-read \Illuminate\Database\Eloquent\Collection<int, Simple> $relationMorphMany
152+
* @property-read int|null $relation_morph_many_count
153+
* @property-read Simple|null $relationMorphOne
154+
* @property-read Model|\Eloquent $relationMorphTo
155+
* @property-read \Illuminate\Database\Eloquent\Collection<int, Simple> $relationMorphedByMany
156+
* @property-read int|null $relation_morphed_by_many_count
157+
* @property-read \Illuminate\Database\Eloquent\Collection<int, Simple> $relationSampleRelationType
158+
* @property-read int|null $relation_sample_relation_type_count
159+
* @property-read Model|\Eloquent $relationSampleToAnyMorphedRelationType
160+
* @property-read \Illuminate\Database\Eloquent\Collection<int, Simple> $relationSampleToAnyRelationType
161+
* @property-read int|null $relation_sample_to_any_relation_type_count
162+
* @property-read Simple $relationSampleToBadlyNamedNotManyRelation
163+
* @property-read Simple $relationSampleToManyRelationType
164+
* @method static \Illuminate\Database\Eloquent\Builder|Simple newModelQuery()
165+
* @method static \Illuminate\Database\Eloquent\Builder|Simple newQuery()
166+
* @method static \Illuminate\Database\Eloquent\Builder|Simple query()
167+
* @method static \Illuminate\Database\Eloquent\Builder|Simple whereId($value)
168+
* @mixin \Eloquent
169+
*/
170+
class Simple extends Model
171+
{
172+
use HasTestRelations;
173+
174+
// Regular relations
175+
public function relationHasMany(): HasMany
176+
{
177+
return $this->hasMany(Simple::class);
178+
}
179+
180+
public function relationHasOne(): HasOne
181+
{
182+
return $this->hasOne(Simple::class);
183+
}
184+
185+
public function relationHasOneWithDefault(): HasOne
186+
{
187+
return $this->hasOne(Simple::class)->withDefault();
188+
}
189+
190+
public function relationBelongsTo(): BelongsTo
191+
{
192+
return $this->belongsTo(Simple::class);
193+
}
194+
195+
public function relationBelongsToMany(): BelongsToMany
196+
{
197+
return $this->belongsToMany(Simple::class);
198+
}
199+
200+
public function relationBelongsToManyWithSub(): BelongsToMany
201+
{
202+
return $this->belongsToMany(Simple::class)->where('foo', 'bar');
203+
}
204+
205+
public function relationBelongsToManyWithSubAnother(): BelongsToMany
206+
{
207+
return $this->relationBelongsToManyWithSub()->where('foo', 'bar');
208+
}
209+
210+
public function relationMorphTo(): MorphTo
211+
{
212+
return $this->morphTo();
213+
}
214+
215+
public function relationMorphOne(): MorphOne
216+
{
217+
return $this->morphOne(Simple::class, 'relationMorphTo');
218+
}
219+
220+
public function relationMorphMany(): MorphMany
221+
{
222+
return $this->morphMany(Simple::class, 'relationMorphTo');
223+
}
224+
225+
public function relationMorphedByMany(): MorphToMany
226+
{
227+
return $this->morphedByMany(Simple::class, 'foo');
228+
}
229+
230+
// Custom relations
231+
232+
public function relationBelongsToInAnotherNamespace(): BelongsTo
233+
{
234+
return $this->belongsTo(AnotherModel::class);
235+
}
236+
237+
public function relationBelongsToSameNameAsColumn(): BelongsTo
238+
{
239+
return $this->belongsTo(AnotherModel::class, __FUNCTION__);
240+
}
241+
242+
public function relationSampleToManyRelationType()
243+
{
244+
return $this->testToOneRelation(Simple::class);
245+
}
246+
247+
public function relationSampleRelationType()
248+
{
249+
return $this->testToManyRelation(Simple::class);
250+
}
251+
252+
public function relationSampleToAnyRelationType()
253+
{
254+
return $this->testToAnyRelation(Simple::class);
255+
}
256+
257+
public function relationSampleToAnyMorphedRelationType()
258+
{
259+
return $this->testToAnyMorphedRelation(Simple::class);
260+
}
261+
262+
public function relationSampleToBadlyNamedNotManyRelation()
263+
{
264+
return $this->testToBadlyNamedNotManyRelation(Simple::class);
265+
}
266+
}

0 commit comments

Comments
 (0)