From 72ead269c960e780225460c357d647b43aaf72be Mon Sep 17 00:00:00 2001 From: Casey Dwyer Date: Tue, 14 Oct 2025 04:08:15 -0500 Subject: [PATCH] [4.x] Bug: Nested JSON fields break with same-name helper/accessor method on model (#18199) * snapshot updates * tests and test model/resource updates * `hasAttribute()` check As a workaround for Laravel's false-positives in `hasRelationship()`. * Update Post.php Signed-off-by: Dan Harrin * Update config method to use array syntax Signed-off-by: Dan Harrin * Update HasCellState.php Signed-off-by: Dan Harrin * clean up elsewhere --------- Signed-off-by: Dan Harrin Co-authored-by: Dan Harrin --- .../forms/src/Components/CheckboxList.php | 2 +- .../forms/src/Components/ModalTableSelect.php | 6 ++++ .../forms/src/Components/MorphToSelect.php | 2 +- packages/forms/src/Components/Repeater.php | 2 +- packages/forms/src/Components/Select.php | 6 ++++ packages/forms/src/Components/TableSelect.php | 6 ++++ .../Resource/Concerns/BelongsToTenant.php | 4 +-- .../Concerns/CanGetStateFromRelationships.php | 6 ++++ .../support/src/Concerns/HasCellState.php | 28 +++++++++++++++++-- packages/tables/src/Columns/SelectColumn.php | 6 ++++ .../src/Filters/Concerns/HasRelationship.php | 6 ++++ .../Operators/IsRelatedToOperator.php | 6 ++++ packages/tables/src/Grouping/Group.php | 6 ++++ .../src/Table/Concerns/CanSearchRecords.php | 6 ++++ ...erate_a_form_schema_class_for_a_model.snap | 2 ++ ..._form_component_with_generated_fields.snap | 2 ++ ...he_form_and_table_of_a_resource_class.snap | 2 ++ ..._content_embedded_in_a_resource_class.snap | 5 ++++ ...an_generate_the_resource_form_content.snap | 2 ++ ...enerate_the_resource_infolist_content.snap | 3 ++ .../migrations/create_posts_table.php | 1 + tests/src/Fixtures/Livewire/PostsTable.php | 1 + tests/src/Fixtures/Models/Post.php | 6 ++++ tests/src/Tables/ColumnTest.php | 9 ++++++ 24 files changed, 118 insertions(+), 7 deletions(-) diff --git a/packages/forms/src/Components/CheckboxList.php b/packages/forms/src/Components/CheckboxList.php index cb8e058f5b..bf998d397f 100644 --- a/packages/forms/src/Components/CheckboxList.php +++ b/packages/forms/src/Components/CheckboxList.php @@ -298,7 +298,7 @@ class CheckboxList extends Field implements Contracts\CanDisableOptions, Contrac $record = $this->getModelInstance(); - if (! $record->isRelation($name)) { + if ($record->hasAttribute($name) || (! $record->isRelation($name))) { throw new LogicException("The relationship [{$name}] does not exist on the model [{$this->getModel()}]."); } diff --git a/packages/forms/src/Components/ModalTableSelect.php b/packages/forms/src/Components/ModalTableSelect.php index 2d03f85286..04093ced2a 100644 --- a/packages/forms/src/Components/ModalTableSelect.php +++ b/packages/forms/src/Components/ModalTableSelect.php @@ -568,6 +568,12 @@ class ModalTableSelect extends Field $relationshipName = $this->getRelationshipName(); foreach (explode('.', $relationshipName) as $nestedRelationshipName) { + if ($record->hasAttribute($nestedRelationshipName)) { + $relationship = null; + + break; + } + if (! $record->isRelation($nestedRelationshipName)) { $relationship = null; diff --git a/packages/forms/src/Components/MorphToSelect.php b/packages/forms/src/Components/MorphToSelect.php index bbe49316d9..aa6113dd3d 100644 --- a/packages/forms/src/Components/MorphToSelect.php +++ b/packages/forms/src/Components/MorphToSelect.php @@ -203,7 +203,7 @@ class MorphToSelect extends Component $relationshipName = $this->getName(); - if (! $record->isRelation($relationshipName)) { + if ($record->hasAttribute($relationshipName) || (! $record->isRelation($relationshipName))) { throw new LogicException("The relationship [{$relationshipName}] does not exist on the model [{$this->getModel()}]."); } diff --git a/packages/forms/src/Components/Repeater.php b/packages/forms/src/Components/Repeater.php index 0202f4009d..1228663aea 100644 --- a/packages/forms/src/Components/Repeater.php +++ b/packages/forms/src/Components/Repeater.php @@ -1119,7 +1119,7 @@ class Repeater extends Field implements CanConcealComponents, HasExtraItemAction $relationshipName = $this->getRelationshipName(); - if (! $record->isRelation($relationshipName)) { + if ($record->hasAttribute($relationshipName) || (! $record->isRelation($relationshipName))) { throw new LogicException("The relationship [{$relationshipName}] does not exist on the model [{$this->getModel()}]."); } diff --git a/packages/forms/src/Components/Select.php b/packages/forms/src/Components/Select.php index 3a103b8ea7..a519062ead 100644 --- a/packages/forms/src/Components/Select.php +++ b/packages/forms/src/Components/Select.php @@ -1255,6 +1255,12 @@ class Select extends Field implements Contracts\CanDisableOptions, Contracts\Has $relationshipName = $this->getRelationshipName(); foreach (explode('.', $relationshipName) as $nestedRelationshipName) { + if ($record->hasAttribute($nestedRelationshipName)) { + $relationship = null; + + break; + } + if (! $record->isRelation($nestedRelationshipName)) { $relationship = null; diff --git a/packages/forms/src/Components/TableSelect.php b/packages/forms/src/Components/TableSelect.php index 216afcad06..fc827c2d46 100644 --- a/packages/forms/src/Components/TableSelect.php +++ b/packages/forms/src/Components/TableSelect.php @@ -253,6 +253,12 @@ class TableSelect extends Field $relationshipName = $this->getRelationshipName(); foreach (explode('.', $relationshipName) as $nestedRelationshipName) { + if ($record->hasAttribute($nestedRelationshipName)) { + $relationship = null; + + break; + } + if (! $record->isRelation($nestedRelationshipName)) { $relationship = null; diff --git a/packages/panels/src/Resources/Resource/Concerns/BelongsToTenant.php b/packages/panels/src/Resources/Resource/Concerns/BelongsToTenant.php index 4eab5aed9d..0972ceb3ba 100644 --- a/packages/panels/src/Resources/Resource/Concerns/BelongsToTenant.php +++ b/packages/panels/src/Resources/Resource/Concerns/BelongsToTenant.php @@ -71,7 +71,7 @@ trait BelongsToTenant { $relationshipName = static::getTenantOwnershipRelationshipName(); - if (! $record->isRelation($relationshipName)) { + if ($record->hasAttribute($relationshipName) || (! $record->isRelation($relationshipName))) { $resourceClass = static::class; $recordClass = $record::class; @@ -93,7 +93,7 @@ trait BelongsToTenant { $relationshipName = static::getTenantRelationshipName(); - if (! $tenant->isRelation($relationshipName)) { + if ($tenant->hasAttribute($relationshipName) || (! $tenant->isRelation($relationshipName))) { $resourceClass = static::class; $tenantClass = $tenant::class; diff --git a/packages/schemas/src/Components/Concerns/CanGetStateFromRelationships.php b/packages/schemas/src/Components/Concerns/CanGetStateFromRelationships.php index 17af641f7f..155be13e64 100644 --- a/packages/schemas/src/Components/Concerns/CanGetStateFromRelationships.php +++ b/packages/schemas/src/Components/Concerns/CanGetStateFromRelationships.php @@ -30,6 +30,12 @@ trait CanGetStateFromRelationships $relationship = null; foreach (explode('.', $statePath ?? $this->getStateRelationshipName()) as $nestedRelationshipName) { + if ($record->hasAttribute($nestedRelationshipName)) { + $relationship = null; + + break; + } + if (! $record->isRelation($nestedRelationshipName)) { $relationship = null; diff --git a/packages/support/src/Concerns/HasCellState.php b/packages/support/src/Concerns/HasCellState.php index 8e5f805934..9b73f72d3d 100644 --- a/packages/support/src/Concerns/HasCellState.php +++ b/packages/support/src/Concerns/HasCellState.php @@ -188,6 +188,10 @@ trait HasCellState return false; } + if ($record->hasAttribute((string) str($name)->before('.'))) { + return false; + } + return $record->isRelation((string) str($name)->before('.')); } @@ -221,6 +225,10 @@ trait HasCellState $relationship = null; foreach ($nameParts as $namePart) { + if ($record->hasAttribute($namePart)) { + break; + } + if (! $record->isRelation($namePart)) { break; } @@ -329,6 +337,10 @@ trait HasCellState $lastPart = array_pop($nameParts); foreach ($nameParts as $namePart) { + if ($record->hasAttribute($namePart)) { + break; + } + if (! $record->isRelation($namePart)) { break; } @@ -352,6 +364,10 @@ trait HasCellState $lastPart = array_pop($nameParts); foreach ($nameParts as $namePart) { + if ($record->hasAttribute($namePart)) { + break; + } + if (! $record->isRelation($namePart)) { break; } @@ -375,6 +391,10 @@ trait HasCellState $inverseRelationshipParts = []; foreach ($nameParts as $namePart) { + if ($record->hasAttribute($namePart)) { + break; + } + if (! $record->isRelation($namePart)) { break; } @@ -391,11 +411,11 @@ trait HasCellState ) ->camel(); - if (! $record->isRelation($inverseNestedRelationshipName)) { + if ($record->hasAttribute($inverseNestedRelationshipName) || (! $record->isRelation($inverseNestedRelationshipName))) { // The conventional relationship doesn't exist, but we can // attempt to use the original relationship name instead. - if (! $record->isRelation($namePart)) { + if ($record->hasAttribute($namePart) || (! $record->isRelation($namePart))) { $recordClass = $record::class; throw new LogicException("When trying to guess the inverse relationship for column [{$this->getName()}], relationship [{$inverseNestedRelationshipName}] was not found on model [{$recordClass}]. Please define a custom [inverseRelationship()] for this column."); @@ -424,6 +444,10 @@ trait HasCellState $relationshipParts = []; foreach ($nameParts as $namePart) { + if ($record->hasAttribute($namePart)) { + break; + } + if (! $record->isRelation($namePart)) { break; } diff --git a/packages/tables/src/Columns/SelectColumn.php b/packages/tables/src/Columns/SelectColumn.php index a33d5c8c24..f9c6738576 100644 --- a/packages/tables/src/Columns/SelectColumn.php +++ b/packages/tables/src/Columns/SelectColumn.php @@ -786,6 +786,12 @@ class SelectColumn extends Column implements Editable, HasEmbeddedView $relationshipName = $this->getOptionsRelationshipName(); foreach (explode('.', $relationshipName) as $nestedRelationshipName) { + if ($record->hasAttribute($nestedRelationshipName)) { + $relationship = null; + + break; + } + if (! $record->isRelation($nestedRelationshipName)) { $relationship = null; diff --git a/packages/tables/src/Filters/Concerns/HasRelationship.php b/packages/tables/src/Filters/Concerns/HasRelationship.php index b3efbbab3a..a8b69d32b4 100644 --- a/packages/tables/src/Filters/Concerns/HasRelationship.php +++ b/packages/tables/src/Filters/Concerns/HasRelationship.php @@ -64,6 +64,12 @@ trait HasRelationship $relationshipName = $this->getRelationshipName(); foreach (explode('.', $relationshipName) as $nestedRelationshipName) { + if ($record->hasAttribute($nestedRelationshipName)) { + $relationship = null; + + break; + } + if (! $record->isRelation($nestedRelationshipName)) { $relationship = null; diff --git a/packages/tables/src/Filters/QueryBuilder/Constraints/RelationshipConstraint/Operators/IsRelatedToOperator.php b/packages/tables/src/Filters/QueryBuilder/Constraints/RelationshipConstraint/Operators/IsRelatedToOperator.php index b04b132705..23a2403b08 100644 --- a/packages/tables/src/Filters/QueryBuilder/Constraints/RelationshipConstraint/Operators/IsRelatedToOperator.php +++ b/packages/tables/src/Filters/QueryBuilder/Constraints/RelationshipConstraint/Operators/IsRelatedToOperator.php @@ -232,6 +232,12 @@ class IsRelatedToOperator extends Operator $relationship = null; foreach (explode('.', $constraint->getRelationshipName()) as $nestedRelationshipName) { + if ($record->hasAttribute($nestedRelationshipName)) { + $relationship = null; + + break; + } + if (! $record->isRelation($nestedRelationshipName)) { $relationship = null; diff --git a/packages/tables/src/Grouping/Group.php b/packages/tables/src/Grouping/Group.php index ae1ba7c5e1..8a5e75ebd1 100644 --- a/packages/tables/src/Grouping/Group.php +++ b/packages/tables/src/Grouping/Group.php @@ -393,6 +393,12 @@ class Group extends Component $relationship = null; foreach (explode('.', $name ?? $this->getRelationshipName()) as $nestedRelationshipName) { + if ($record->hasAttribute($nestedRelationshipName)) { + $relationship = null; + + break; + } + if (! $record->isRelation($nestedRelationshipName)) { $relationship = null; diff --git a/packages/tables/src/Table/Concerns/CanSearchRecords.php b/packages/tables/src/Table/Concerns/CanSearchRecords.php index b8ef14b9a2..0740243505 100644 --- a/packages/tables/src/Table/Concerns/CanSearchRecords.php +++ b/packages/tables/src/Table/Concerns/CanSearchRecords.php @@ -248,6 +248,12 @@ trait CanSearchRecords $relationship = null; foreach (str($name)->beforeLast('.')->explode('.')->all() as $nestedRelationshipName) { + if ($record->hasAttribute($nestedRelationshipName)) { + $relationship = null; + + break; + } + if (! $record->isRelation($nestedRelationshipName)) { $relationship = null; diff --git a/tests/.pest/snapshots/src/Forms/Commands/MakeFormCommandTest/it_can_generate_a_form_schema_class_for_a_model.snap b/tests/.pest/snapshots/src/Forms/Commands/MakeFormCommandTest/it_can_generate_a_form_schema_class_for_a_model.snap index 102311a43c..52f32cf7e6 100644 --- a/tests/.pest/snapshots/src/Forms/Commands/MakeFormCommandTest/it_can_generate_a_form_schema_class_for_a_model.snap +++ b/tests/.pest/snapshots/src/Forms/Commands/MakeFormCommandTest/it_can_generate_a_form_schema_class_for_a_model.snap @@ -31,6 +31,8 @@ class PostForm ->columnSpanFull(), TextInput::make('title') ->required(), + Textarea::make('config') + ->columnSpanFull(), Textarea::make('json') ->columnSpanFull(), Textarea::make('json_array_of_objects') diff --git a/tests/.pest/snapshots/src/Forms/Commands/MakeLivewireFormCommandTest/it_can_generate_a_livewire_form_component_with_generated_fields.snap b/tests/.pest/snapshots/src/Forms/Commands/MakeLivewireFormCommandTest/it_can_generate_a_livewire_form_component_with_generated_fields.snap index b9f43c5483..86fd502ea1 100644 --- a/tests/.pest/snapshots/src/Forms/Commands/MakeLivewireFormCommandTest/it_can_generate_a_livewire_form_component_with_generated_fields.snap +++ b/tests/.pest/snapshots/src/Forms/Commands/MakeLivewireFormCommandTest/it_can_generate_a_livewire_form_component_with_generated_fields.snap @@ -47,6 +47,8 @@ class CreatePostWithFields extends Component implements HasActions, HasSchemas ->columnSpanFull(), TextInput::make('title') ->required(), + Textarea::make('config') + ->columnSpanFull(), Textarea::make('json') ->columnSpanFull(), Textarea::make('json_array_of_objects') diff --git a/tests/.pest/snapshots/src/Panels/Commands/Legacy/LegacyMakeResourceCommandTest/it_can_generate_the_form_and_table_of_a_resource_class.snap b/tests/.pest/snapshots/src/Panels/Commands/Legacy/LegacyMakeResourceCommandTest/it_can_generate_the_form_and_table_of_a_resource_class.snap index 2f2933ca0f..9c003f64ae 100644 --- a/tests/.pest/snapshots/src/Panels/Commands/Legacy/LegacyMakeResourceCommandTest/it_can_generate_the_form_and_table_of_a_resource_class.snap +++ b/tests/.pest/snapshots/src/Panels/Commands/Legacy/LegacyMakeResourceCommandTest/it_can_generate_the_form_and_table_of_a_resource_class.snap @@ -39,6 +39,8 @@ class PostResource extends Resource ->columnSpanFull(), Forms\Components\TextInput::make('title') ->required(), + Forms\Components\Textarea::make('config') + ->columnSpanFull(), Forms\Components\Textarea::make('json') ->columnSpanFull(), Forms\Components\Textarea::make('json_array_of_objects') diff --git a/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_form__infolist__and_table_content_embedded_in_a_resource_class.snap b/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_form__infolist__and_table_content_embedded_in_a_resource_class.snap index 0a99476cae..f75377a26c 100644 --- a/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_form__infolist__and_table_content_embedded_in_a_resource_class.snap +++ b/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_form__infolist__and_table_content_embedded_in_a_resource_class.snap @@ -51,6 +51,8 @@ class PostResource extends Resource ->columnSpanFull(), TextInput::make('title') ->required(), + Textarea::make('config') + ->columnSpanFull(), Textarea::make('json') ->columnSpanFull(), Textarea::make('json_array_of_objects') @@ -77,6 +79,9 @@ class PostResource extends Resource ->placeholder('-') ->columnSpanFull(), TextEntry::make('title'), + TextEntry::make('config') + ->placeholder('-') + ->columnSpanFull(), TextEntry::make('json') ->placeholder('-') ->columnSpanFull(), diff --git a/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_form_content.snap b/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_form_content.snap index 19e4c415f1..aa555dc4e9 100644 --- a/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_form_content.snap +++ b/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_form_content.snap @@ -30,6 +30,8 @@ class PostForm ->columnSpanFull(), TextInput::make('title') ->required(), + Textarea::make('config') + ->columnSpanFull(), Textarea::make('json') ->columnSpanFull(), Textarea::make('json_array_of_objects') diff --git a/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_infolist_content.snap b/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_infolist_content.snap index 4c55c918ea..ac35a13189 100644 --- a/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_infolist_content.snap +++ b/tests/.pest/snapshots/src/Panels/Commands/MakeResourceCommandTest/it_can_generate_the_resource_infolist_content.snap @@ -26,6 +26,9 @@ class PostInfolist ->placeholder('-') ->columnSpanFull(), TextEntry::make('title'), + TextEntry::make('config') + ->placeholder('-') + ->columnSpanFull(), TextEntry::make('json') ->placeholder('-') ->columnSpanFull(), diff --git a/tests/database/migrations/create_posts_table.php b/tests/database/migrations/create_posts_table.php index 8fea890518..ce9592a29c 100644 --- a/tests/database/migrations/create_posts_table.php +++ b/tests/database/migrations/create_posts_table.php @@ -16,6 +16,7 @@ return new class extends Migration $table->unsignedTinyInteger('rating')->default(0); $table->json('tags')->nullable(); $table->string('title'); + $table->json('config')->nullable(); $table->json('json')->nullable(); $table->json('json_array_of_objects')->nullable(); $table->string('string_backed_enum')->nullable(); diff --git a/tests/src/Fixtures/Livewire/PostsTable.php b/tests/src/Fixtures/Livewire/PostsTable.php index 0bcd7e4d60..3665da4939 100644 --- a/tests/src/Fixtures/Livewire/PostsTable.php +++ b/tests/src/Fixtures/Livewire/PostsTable.php @@ -97,6 +97,7 @@ class PostsTable extends Component implements HasActions, HasSchemas, Tables\Con ->searchable() ->sortable(), Tables\Columns\TextColumn::make('json_array_of_objects.*.value'), + Tables\Columns\TextColumn::make('config.setting'), Tables\Columns\TextColumn::make('author.json.foo') ->searchable() ->sortable(), diff --git a/tests/src/Fixtures/Models/Post.php b/tests/src/Fixtures/Models/Post.php index 8e1a1a4c02..6394aacc9b 100644 --- a/tests/src/Fixtures/Models/Post.php +++ b/tests/src/Fixtures/Models/Post.php @@ -25,6 +25,7 @@ class Post extends Model 'tags' => 'array', 'json' => 'array', 'json_array_of_objects' => 'array', + 'config' => 'array', ]; } @@ -35,6 +36,11 @@ class Post extends Model return $this->belongsTo(User::class, 'author_id'); } + public function config(string $key): mixed + { + return $this->config[$key] ?? null; + } + protected static function newFactory() { return PostFactory::new(); diff --git a/tests/src/Tables/ColumnTest.php b/tests/src/Tables/ColumnTest.php index f208fe4f61..961822fb7b 100644 --- a/tests/src/Tables/ColumnTest.php +++ b/tests/src/Tables/ColumnTest.php @@ -320,6 +320,15 @@ it('can output values in a JSON array column of objects', function (): void { ->assertTableColumnStateSet('json_array_of_objects.*.value', ['foo', 'bar', 'baz'], $post); }); +it('can output values in a JSON column with a non-relationship accessor method', function (): void { + $post = Post::factory()->create([ + 'config' => ['setting' => 'foo'], + ]); + + livewire(PostsTable::class) + ->assertTableColumnStateSet('config.setting', 'foo', $post); +}); + it('can state whether a column has extra attributes', function (): void { $post = Post::factory()->create();