@@ -143,41 +143,117 @@ public function renderCell(Column $column, mixed $record): mixed
143143 return $ value ;
144144 }
145145
146+ /**
147+ * Cache for attribute detection to avoid repeated reflection calls.
148+ *
149+ * @var array<string, array<string, bool>>
150+ */
151+ protected static array $ attributeDetectionCache = [];
152+
146153 /**
147154 * Check if a field is a model attribute (accessor) rather than a database column.
155+ * Enhanced version with improved detection and caching for performance.
148156 */
149157 protected function isModelAttribute (\Illuminate \Database \Eloquent \Model $ model , string $ field ): bool
150158 {
151- // Check if it's an accessor method (old Laravel syntax)
152- $ accessorMethod = 'get ' . \Illuminate \Support \Str::studly ($ field ) . 'Attribute ' ;
153- if (method_exists ($ model , $ accessorMethod )) {
154- return true ;
155- }
159+ $ modelClass = get_class ($ model );
160+ $ cacheKey = $ modelClass . ':: ' . $ field ;
156161
157- // Check if it's defined in the model's $appends array
158- if (in_array ( $ field , $ model -> getAppends () )) {
159- return true ;
162+ // Return cached result if available
163+ if (isset ( static :: $ attributeDetectionCache [ $ modelClass ][ $ field ] )) {
164+ return static :: $ attributeDetectionCache [ $ modelClass ][ $ field ] ;
160165 }
161166
162- // Check if it's a cast attribute
163- if (array_key_exists ($ field , $ model ->getCasts ())) {
164- return true ;
165- }
167+ $ isAttribute = false ;
166168
167- // Check if it's a Laravel 9+ Attribute (new syntax)
168- if ( method_exists ( $ model , $ field )) {
169- $ reflection = new \ ReflectionClass ( $ model ) ;
170- if ($ reflection -> hasMethod ( $ field )) {
171- $ method = $ reflection -> getMethod ( $ field ) ;
172- $ returnType = $ method -> getReturnType ();
169+ try {
170+ // 1. Check if it's an accessor method (old Laravel syntax)
171+ $ accessorMethod = ' get ' . \ Illuminate \ Support \Str:: studly ( $ field ) . ' Attribute ' ;
172+ if (method_exists ( $ model , $ accessorMethod )) {
173+ $ isAttribute = true ;
174+ }
173175
174- if ($ returnType instanceof \ReflectionNamedType && $ returnType ->getName () === 'Illuminate\Database\Eloquent\Casts\Attribute ' ) {
175- return true ;
176+ // 2. Check if it's defined in the model's $appends array
177+ if (!$ isAttribute && in_array ($ field , $ model ->getAppends ())) {
178+ $ isAttribute = true ;
179+ }
180+
181+ // 3. Check if it's a cast attribute but NOT a database column
182+ if (!$ isAttribute && array_key_exists ($ field , $ model ->getCasts ())) {
183+ // Cast attributes can be both database columns AND computed attributes
184+ // We need to check if it's actually a database column
185+ try {
186+ if (!$ this ->isDatabaseColumn ($ model , $ field )) {
187+ $ isAttribute = true ;
188+ }
189+ } catch (\Throwable $ e ) {
190+ // If we can't determine database columns, assume it's an attribute if it's cast
191+ $ isAttribute = true ;
192+ }
193+ }
194+
195+ // 4. Check if it's a Laravel 9+ Attribute (new syntax)
196+ if (!$ isAttribute && method_exists ($ model , $ field )) {
197+ try {
198+ $ reflection = new \ReflectionClass ($ model );
199+ if ($ reflection ->hasMethod ($ field )) {
200+ $ method = $ reflection ->getMethod ($ field );
201+ $ returnType = $ method ->getReturnType ();
202+
203+ if ($ returnType instanceof \ReflectionNamedType &&
204+ $ returnType ->getName () === 'Illuminate\Database\Eloquent\Casts\Attribute ' ) {
205+ $ isAttribute = true ;
206+ }
207+ }
208+ } catch (\ReflectionException $ e ) {
209+ // Log the error but don't fail
210+ \Illuminate \Support \Facades \Log::debug ('Reflection error in attribute detection ' , [
211+ 'model ' => $ modelClass ,
212+ 'field ' => $ field ,
213+ 'error ' => $ e ->getMessage ()
214+ ]);
176215 }
177216 }
217+
218+ // 5. Check for computed attributes that use getAttribute() with custom logic
219+ // Disabled for now to avoid false positives - the above checks should be sufficient
220+ // if (!$isAttribute) {
221+ // try {
222+ // // First check if it's a database column - if so, it's not a computed attribute
223+ // if ($this->isDatabaseColumn($model, $field)) {
224+ // $isAttribute = false;
225+ // } else {
226+ // // Create a fresh model instance to test attribute access
227+ // $testModel = new $modelClass;
228+ // $testModel->exists = true; // Prevent save operations
229+ //
230+ // // Try to access the attribute - if it exists and isn't a database column, it's computed
231+ // if ($testModel->hasGetMutator($field) || $testModel->hasAttributeMutator($field)) {
232+ // $isAttribute = true;
233+ // }
234+ // }
235+ // } catch (\Throwable $e) {
236+ // // Ignore errors in this detection method - it's a fallback
237+ // }
238+ // }
239+
240+ } catch (\Throwable $ e ) {
241+ // Log error and return false as fallback
242+ \Illuminate \Support \Facades \Log::warning ('Error in attribute detection ' , [
243+ 'model ' => $ modelClass ,
244+ 'field ' => $ field ,
245+ 'error ' => $ e ->getMessage ()
246+ ]);
247+ $ isAttribute = false ;
178248 }
179249
180- return false ;
250+ // Cache the result
251+ if (!isset (static ::$ attributeDetectionCache [$ modelClass ])) {
252+ static ::$ attributeDetectionCache [$ modelClass ] = [];
253+ }
254+ static ::$ attributeDetectionCache [$ modelClass ][$ field ] = $ isAttribute ;
255+
256+ return $ isAttribute ;
181257 }
182258
183259 /**
@@ -229,9 +305,95 @@ protected function getModelAttributeValue(\Illuminate\Database\Eloquent\Model $m
229305 */
230306 protected function isDatabaseColumn (\Illuminate \Database \Eloquent \Model $ model , string $ field ): bool
231307 {
232- $ schema = \Illuminate \Support \Facades \Schema::connection ($ model ->getConnectionName ());
233- $ tableColumns = $ schema ->getColumnListing ($ model ->getTable ());
308+ try {
309+ $ connectionName = $ model ->getConnectionName ();
310+ $ tableName = $ model ->getTable ();
311+
312+ $ schema = \Illuminate \Support \Facades \Schema::connection ($ connectionName );
313+ $ tableColumns = $ schema ->getColumnListing ($ tableName );
314+
315+ return in_array ($ field , $ tableColumns );
316+ } catch (\Throwable $ e ) {
317+ // Log error and return false as fallback
318+ \Illuminate \Support \Facades \Log::warning ('Error checking database column ' , [
319+ 'model ' => get_class ($ model ),
320+ 'field ' => $ field ,
321+ 'error ' => $ e ->getMessage ()
322+ ]);
323+ return false ;
324+ }
325+ }
326+
327+ /**
328+ * Check if a relationship field (dot notation) contains a model attribute.
329+ * For example: 'user.full_name' where 'full_name' is an accessor on the User model.
330+ */
331+ protected function isRelationshipAttribute (string $ relationshipPath ): bool
332+ {
333+ $ parts = explode ('. ' , $ relationshipPath );
334+ if (count ($ parts ) < 2 ) {
335+ return false ;
336+ }
337+
338+ try {
339+ $ model = $ this ->getModel ();
340+ $ relationName = $ parts [0 ];
341+ $ relationField = $ parts [1 ];
342+
343+ // Check if the relation method exists
344+ if (!method_exists ($ model , $ relationName )) {
345+ return false ;
346+ }
347+
348+ // Get the related model
349+ $ relatedModel = $ this ->getRelatedModel ($ relationshipPath );
350+ if (!$ relatedModel ) {
351+ return false ;
352+ }
353+
354+ // Check if the field is an attribute on the related model
355+ return $ this ->isModelAttribute ($ relatedModel , $ relationField );
356+
357+ } catch (\Throwable $ e ) {
358+ // Log error and return false as fallback
359+ \Illuminate \Support \Facades \Log::warning ('Error in relationship attribute detection ' , [
360+ 'relationshipPath ' => $ relationshipPath ,
361+ 'error ' => $ e ->getMessage ()
362+ ]);
363+ return false ;
364+ }
365+ }
234366
235- return in_array ($ field , $ tableColumns );
367+ /**
368+ * Enhanced method to check if a field (with or without dot notation) is a model attribute.
369+ * This is the main entry point for attribute detection.
370+ */
371+ public function isFieldAttribute (string $ field ): bool
372+ {
373+ // Handle relationship fields (dot notation)
374+ if (str_contains ($ field , '. ' )) {
375+ return $ this ->isRelationshipAttribute ($ field );
376+ }
377+
378+ // Handle direct model attributes
379+ try {
380+ $ model = $ this ->getModel ();
381+ return $ this ->isModelAttribute ($ model , $ field );
382+ } catch (\Throwable $ e ) {
383+ \Illuminate \Support \Facades \Log::warning ('Error in field attribute detection ' , [
384+ 'field ' => $ field ,
385+ 'error ' => $ e ->getMessage ()
386+ ]);
387+ return false ;
388+ }
389+ }
390+
391+ /**
392+ * Clear the attribute detection cache.
393+ * Useful for testing or when model definitions change at runtime.
394+ */
395+ public static function clearAttributeDetectionCache (): void
396+ {
397+ static ::$ attributeDetectionCache = [];
236398 }
237399}
0 commit comments