Skip to content

Commit 5060909

Browse files
Khartirclaude
andauthored
fix: Don't force |null on BelongsTo relations using ->withTrashed() (#1783)
When the related model uses SoftDeletes, `isRelationNullable()` was forcing the BelongsTo relation type to nullable even if the relation explicitly opted into trashed parents via `->withTrashed()`. Combined with a NOT NULL FK column and DB-level FK constraint, the relation is effectively non-nullable. The SoftDeletes branch is now skipped when the relation has removed the `SoftDeletingScope` and added no constraint on the qualified `deleted_at` column. `->onlyTrashed()` and `->withoutTrashed()` keep their nullable annotation because they restrict the parent to a specific soft-delete state. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3a53071 commit 5060909

4 files changed

Lines changed: 95 additions & 1 deletion

File tree

src/Console/ModelsCommand.php

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
use Illuminate\Database\Eloquent\Relations\MorphToMany;
4545
use Illuminate\Database\Eloquent\Relations\Pivot;
4646
use Illuminate\Database\Eloquent\Relations\Relation;
47+
use Illuminate\Database\Eloquent\SoftDeletingScope;
4748
use Illuminate\Database\Schema\Builder;
4849
use Illuminate\Filesystem\Filesystem;
4950
use Illuminate\Support\Arr;
@@ -943,13 +944,55 @@ protected function isRelationNullable(string $relation, Relation $relationObj):
943944
}
944945
}
945946

946-
if ($this->relatedModelUsesSoftDeletes($relationObj)) {
947+
if (
948+
$this->relatedModelUsesSoftDeletes($relationObj)
949+
&& !$this->relationIncludesNonTrashedParents($relationObj)
950+
) {
947951
return true;
948952
}
949953

950954
return false;
951955
}
952956

957+
/**
958+
* Check whether the relation explicitly opts into returning non-soft-deleted parents
959+
* via ->withTrashed(), in which case the SoftDeletes-based nullability no longer applies.
960+
*
961+
* Returns false for ->onlyTrashed() and ->withoutTrashed(), which still leave the
962+
* relation potentially empty depending on the parent's soft-delete state.
963+
*
964+
* @param Relation $relationObj
965+
*
966+
* @return bool
967+
*/
968+
protected function relationIncludesNonTrashedParents(Relation $relationObj): bool
969+
{
970+
$query = $relationObj->getQuery();
971+
972+
if (!in_array(SoftDeletingScope::class, $query->removedScopes(), true)) {
973+
return false;
974+
}
975+
976+
$relatedModel = $relationObj->getRelated();
977+
978+
if (!method_exists($relatedModel, 'getQualifiedDeletedAtColumn')) {
979+
return true;
980+
}
981+
982+
$deletedAtColumn = $relatedModel->getQualifiedDeletedAtColumn();
983+
984+
foreach ($query->getQuery()->wheres ?? [] as $where) {
985+
if (
986+
($where['column'] ?? null) === $deletedAtColumn
987+
&& in_array($where['type'] ?? null, ['Null', 'NotNull'], true)
988+
) {
989+
return false;
990+
}
991+
}
992+
993+
return true;
994+
}
995+
953996
/**
954997
* Check if the related model uses the SoftDeletes trait
955998
*

tests/Console/ModelsCommand/SoftDeletesRelations/Models/ModelWithRelations.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,21 @@ public function softDeletable(): BelongsTo
1717
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id');
1818
}
1919

20+
public function softDeletableWithTrashed(): BelongsTo
21+
{
22+
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->withTrashed();
23+
}
24+
25+
public function softDeletableOnlyTrashed(): BelongsTo
26+
{
27+
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->onlyTrashed();
28+
}
29+
30+
public function softDeletableWithoutTrashed(): BelongsTo
31+
{
32+
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->withoutTrashed();
33+
}
34+
2035
public function nonSoftDeletable(): BelongsTo
2136
{
2237
return $this->belongsTo(NonSoftDeletableModel::class, 'non_soft_deletable_model_id');

tests/Console/ModelsCommand/SoftDeletesRelations/__snapshots__/Test__testSoftDeletesForceNullableDisabled__1.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\NonSoftDeletableModel|null $nonSoftDeletableHasOne
1717
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel $softDeletable
1818
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel|null $softDeletableHasOne
19+
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel $softDeletableOnlyTrashed
20+
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel $softDeletableWithTrashed
21+
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel $softDeletableWithoutTrashed
1922
* @method static \Illuminate\Database\Eloquent\Builder<static>|ModelWithRelations newModelQuery()
2023
* @method static \Illuminate\Database\Eloquent\Builder<static>|ModelWithRelations newQuery()
2124
* @method static \Illuminate\Database\Eloquent\Builder<static>|ModelWithRelations query()
@@ -33,6 +36,21 @@ public function softDeletable(): BelongsTo
3336
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id');
3437
}
3538

39+
public function softDeletableWithTrashed(): BelongsTo
40+
{
41+
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->withTrashed();
42+
}
43+
44+
public function softDeletableOnlyTrashed(): BelongsTo
45+
{
46+
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->onlyTrashed();
47+
}
48+
49+
public function softDeletableWithoutTrashed(): BelongsTo
50+
{
51+
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->withoutTrashed();
52+
}
53+
3654
public function nonSoftDeletable(): BelongsTo
3755
{
3856
return $this->belongsTo(NonSoftDeletableModel::class, 'non_soft_deletable_model_id');

tests/Console/ModelsCommand/SoftDeletesRelations/__snapshots__/Test__test__1.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\NonSoftDeletableModel|null $nonSoftDeletableHasOne
1717
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel|null $softDeletable
1818
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel|null $softDeletableHasOne
19+
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel|null $softDeletableOnlyTrashed
20+
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel $softDeletableWithTrashed
21+
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel|null $softDeletableWithoutTrashed
1922
* @method static \Illuminate\Database\Eloquent\Builder<static>|ModelWithRelations newModelQuery()
2023
* @method static \Illuminate\Database\Eloquent\Builder<static>|ModelWithRelations newQuery()
2124
* @method static \Illuminate\Database\Eloquent\Builder<static>|ModelWithRelations query()
@@ -33,6 +36,21 @@ public function softDeletable(): BelongsTo
3336
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id');
3437
}
3538

39+
public function softDeletableWithTrashed(): BelongsTo
40+
{
41+
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->withTrashed();
42+
}
43+
44+
public function softDeletableOnlyTrashed(): BelongsTo
45+
{
46+
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->onlyTrashed();
47+
}
48+
49+
public function softDeletableWithoutTrashed(): BelongsTo
50+
{
51+
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->withoutTrashed();
52+
}
53+
3654
public function nonSoftDeletable(): BelongsTo
3755
{
3856
return $this->belongsTo(NonSoftDeletableModel::class, 'non_soft_deletable_model_id');

0 commit comments

Comments
 (0)