Skip to content

Commit a2867e9

Browse files
authored
fix(laravel): serializer attributes on Eloquent methods (#7416)
fixes #7289
1 parent 775f2c1 commit a2867e9

File tree

13 files changed

+486
-90
lines changed

13 files changed

+486
-90
lines changed

src/GraphQl/Type/FieldsBuilder.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ public function getItemQueryFields(string $resourceClass, Operation $operation,
7878
return [];
7979
}
8080

81-
$fieldName = lcfirst('item_query' === $operation->getName() ? $operation->getShortName() : $operation->getName().$operation->getShortName());
81+
$fieldName = lcfirst('item_query' === $operation->getName() ? ($operation->getShortName() ?? $operation->getName()) : $operation->getName().$operation->getShortName());
8282

8383
if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, $operation->getDescription(), $operation->getDeprecationReason(), new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass), $resourceClass, false, $operation)) {
8484
$args = $this->resolveResourceArgs($configuration['args'] ?? [], $operation);

src/Laravel/ApiPlatformProvider.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@
8989
use ApiPlatform\Laravel\Eloquent\PropertyAccess\PropertyAccessor as EloquentPropertyAccessor;
9090
use ApiPlatform\Laravel\Eloquent\PropertyInfo\EloquentExtractor;
9191
use ApiPlatform\Laravel\Eloquent\Serializer\EloquentNameConverter;
92+
use ApiPlatform\Laravel\Eloquent\Serializer\Mapping\Loader\AttributeLoader as EloquentAttributeLoader;
93+
use ApiPlatform\Laravel\Eloquent\Serializer\Mapping\Loader\RelationMetadataLoader;
9294
use ApiPlatform\Laravel\Eloquent\Serializer\SerializerContextBuilder as EloquentSerializerContextBuilder;
9395
use ApiPlatform\Laravel\GraphQl\Controller\EntrypointController as GraphQlEntrypointController;
9496
use ApiPlatform\Laravel\GraphQl\Controller\GraphiQlController;
@@ -174,7 +176,6 @@
174176
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
175177
use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader;
176178
use Symfony\Component\Serializer\Mapping\Loader\LoaderChain;
177-
use Symfony\Component\Serializer\Mapping\Loader\LoaderInterface;
178179
use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter;
179180
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
180181
use Symfony\Component\Serializer\NameConverter\SnakeCaseToCamelCaseNameConverter;
@@ -216,7 +217,6 @@ public function register(): void
216217
return new ModelMetadata();
217218
});
218219

219-
$this->app->bind(LoaderInterface::class, AttributeLoader::class);
220220
$this->app->bind(ClassMetadataFactoryInterface::class, ClassMetadataFactory::class);
221221
$this->app->singleton(ClassMetadataFactory::class, function (Application $app) {
222222
/** @var ConfigRepository */
@@ -232,8 +232,8 @@ public function register(): void
232232
$app->make(PropertyNameCollectionFactoryInterface::class),
233233
$nameConverter
234234
),
235-
new AttributeLoader(),
236-
// new RelationMetadataLoader($app->make(ModelMetadata::class)),
235+
new EloquentAttributeLoader(new AttributeLoader()),
236+
new RelationMetadataLoader($app->make(ModelMetadata::class)),
237237
])
238238
);
239239
});
@@ -330,8 +330,8 @@ public function register(): void
330330
);
331331
});
332332

333-
$this->app->bind(PropertyAccessorInterface::class, function () {
334-
return new EloquentPropertyAccessor();
333+
$this->app->bind(PropertyAccessorInterface::class, function (Application $app) {
334+
return new EloquentPropertyAccessor(null, $app->make(ModelMetadata::class));
335335
});
336336

337337
$this->app->bind(NameConverterInterface::class, function (Application $app) {

src/Laravel/Eloquent/Metadata/ModelMetadata.php

Lines changed: 109 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515

1616
use Illuminate\Database\Eloquent\Model;
1717
use Illuminate\Database\Eloquent\Relations\Relation;
18-
use Illuminate\Support\Collection;
1918
use Illuminate\Support\Str;
2019
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
2120
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
@@ -28,12 +27,12 @@
2827
final class ModelMetadata
2928
{
3029
/**
31-
* @var array<class-string, Collection<string, mixed>>
30+
* @var array<class-string, array<string, mixed>>
3231
*/
3332
private $attributesLocalCache = [];
3433

3534
/**
36-
* @var array<class-string, Collection<int, mixed>>
35+
* @var array<class-string, array<string, mixed>>
3736
*/
3837
private $relationsLocalCache = [];
3938

@@ -63,9 +62,9 @@ public function __construct(private NameConverterInterface $relationNameConverte
6362
/**
6463
* Gets the column attributes for the given model.
6564
*
66-
* @return Collection<string, mixed>
65+
* @return array<string, mixed>
6766
*/
68-
public function getAttributes(Model $model): Collection
67+
public function getAttributes(Model $model): array
6968
{
7069
if (isset($this->attributesLocalCache[$model::class])) {
7170
return $this->attributesLocalCache[$model::class];
@@ -78,13 +77,15 @@ public function getAttributes(Model $model): Collection
7877
$indexes = $schema->getIndexes($table);
7978
$relations = $this->getRelations($model);
8079

81-
return $this->attributesLocalCache[$model::class] = collect($columns)
82-
->reject(
83-
fn ($column) => $relations->contains(
84-
fn ($relation) => $relation['foreign_key'] === $column['name']
85-
)
86-
)
87-
->map(fn ($column) => [
80+
$foreignKeys = array_flip(array_column($relations, 'foreign_key'));
81+
$attributes = [];
82+
83+
foreach ($columns as $column) {
84+
if (isset($foreignKeys[$column['name']])) {
85+
continue;
86+
}
87+
88+
$attributes[$column['name']] = [
8889
'name' => $column['name'],
8990
'type' => $column['type'],
9091
'increments' => $column['auto_increment'],
@@ -96,8 +97,10 @@ public function getAttributes(Model $model): Collection
9697
'appended' => null,
9798
'cast' => $this->getCastType($column['name'], $model),
9899
'primary' => $this->isColumnPrimaryKey($indexes, $column['name']),
99-
])
100-
->merge($this->getVirtualAttributes($model, $columns));
100+
];
101+
}
102+
103+
return $this->attributesLocalCache[$model::class] = array_merge($attributes, $this->getVirtualAttributes($model, $columns));
101104
}
102105

103106
/**
@@ -119,30 +122,43 @@ private function isColumnPrimaryKey(array $indexes, string $column): bool
119122
*
120123
* @param array<string, mixed> $columns
121124
*
122-
* @return Collection<int, mixed>
125+
* @return array<string, mixed>
123126
*/
124-
private function getVirtualAttributes(Model $model, array $columns): Collection
127+
private function getVirtualAttributes(Model $model, array $columns): array
125128
{
126129
$class = new \ReflectionClass($model);
130+
$virtualAttributes = [];
131+
132+
$columnNames = array_flip(array_column($columns, 'name'));
133+
134+
foreach ($class->getMethods() as $method) {
135+
if (
136+
$method->isStatic()
137+
|| $method->isAbstract()
138+
// Skips methods from the base Eloquent Model class
139+
|| Model::class === $method->getDeclaringClass()->getName()
140+
) {
141+
continue;
142+
}
127143

128-
return collect($class->getMethods())
129-
->reject(
130-
fn (\ReflectionMethod $method) => $method->isStatic()
131-
|| $method->isAbstract()
132-
|| Model::class === $method->getDeclaringClass()->getName()
133-
)
134-
->mapWithKeys(function (\ReflectionMethod $method) use ($model) {
135-
if (1 === preg_match('/^get(.+)Attribute$/', $method->getName(), $matches)) {
136-
return [Str::snake($matches[1]) => 'accessor'];
137-
}
138-
if ($model->hasAttributeMutator($method->getName())) {
139-
return [Str::snake($method->getName()) => 'attribute'];
140-
}
144+
$methodName = $method->getName();
145+
$name = null;
146+
$cast = null;
141147

142-
return [];
143-
})
144-
->reject(fn ($cast, $name) => collect($columns)->contains('name', $name))
145-
->map(fn ($cast, $name) => [
148+
if (1 === preg_match('/^get(.+)Attribute$/', $methodName, $matches)) {
149+
$name = Str::snake($matches[1]);
150+
$cast = 'accessor';
151+
} elseif ($model->hasAttributeMutator($methodName)) {
152+
$name = Str::snake($methodName);
153+
$cast = 'attribute';
154+
}
155+
156+
// If the method is not a virtual attribute, or if it conflicts with a real column, skip it.
157+
if (null === $name || isset($columnNames[$name])) {
158+
continue;
159+
}
160+
161+
$virtualAttributes[$name] = [
146162
'name' => $name,
147163
'type' => null,
148164
'increments' => false,
@@ -153,42 +169,41 @@ private function getVirtualAttributes(Model $model, array $columns): Collection
153169
'hidden' => $this->attributeIsHidden($name, $model),
154170
'appended' => $model->hasAppended($name),
155171
'cast' => $cast,
156-
])
157-
->values();
172+
];
173+
}
174+
175+
return $virtualAttributes;
158176
}
159177

160178
/**
161179
* Gets the relations from the given model.
162180
*
163-
* @return Collection<int, mixed>
181+
* @return array<string, mixed>
164182
*/
165-
public function getRelations(Model $model): Collection
183+
public function getRelations(Model $model): array
166184
{
167185
if (isset($this->relationsLocalCache[$model::class])) {
168186
return $this->relationsLocalCache[$model::class];
169187
}
170188

171-
return $this->relationsLocalCache[$model::class] = collect(get_class_methods($model))
172-
->map(fn ($method) => new \ReflectionMethod($model, $method))
173-
->reject(
174-
fn (\ReflectionMethod $method) => $method->isStatic()
175-
|| $method->isAbstract()
176-
|| Model::class === $method->getDeclaringClass()->getName()
177-
|| $method->getNumberOfParameters() > 0
178-
|| $this->attributeIsHidden($method->getName(), $model)
179-
)
180-
->filter(function (\ReflectionMethod $method) {
181-
if (
182-
$method->getReturnType() instanceof \ReflectionNamedType
183-
&& is_subclass_of($method->getReturnType()->getName(), Relation::class)
184-
) {
185-
return true;
186-
}
189+
$relations = [];
190+
$class = new \ReflectionClass($model);
187191

188-
if (false === $method->getFileName()) {
189-
return false;
190-
}
192+
foreach ($class->getMethods() as $method) {
193+
if (
194+
$method->isStatic()
195+
|| $method->isAbstract()
196+
|| $method->getNumberOfParameters() > 0
197+
|| Model::class === $method->getDeclaringClass()->getName()
198+
|| $this->attributeIsHidden($method->getName(), $model)
199+
) {
200+
continue;
201+
}
191202

203+
$isRelation = false;
204+
if ($method->getReturnType() instanceof \ReflectionNamedType && is_subclass_of($method->getReturnType()->getName(), Relation::class)) {
205+
$isRelation = true;
206+
} elseif (false !== $method->getFileName()) {
192207
$file = new \SplFileObject($method->getFileName());
193208
$file->seek($method->getStartLine() - 1);
194209
$code = '';
@@ -197,30 +212,37 @@ public function getRelations(Model $model): Collection
197212
if (\is_string($current)) {
198213
$code .= trim($current);
199214
}
200-
201215
$file->next();
202216
}
203217

204-
return collect(self::RELATION_METHODS)
205-
->contains(fn ($relationMethod) => str_contains($code, '$this->'.$relationMethod.'('));
206-
})
207-
->map(function (\ReflectionMethod $method) use ($model) {
208-
$relation = $method->invoke($model);
209-
210-
if (!$relation instanceof Relation) {
211-
return null;
218+
foreach (self::RELATION_METHODS as $relationMethod) {
219+
if (str_contains($code, '$this->'.$relationMethod.'(')) {
220+
$isRelation = true;
221+
break;
222+
}
212223
}
224+
}
225+
226+
if (!$isRelation) {
227+
continue;
228+
}
213229

214-
return [
215-
'name' => $this->relationNameConverter->normalize($method->getName()),
216-
'method_name' => $method->getName(),
217-
'type' => $relation::class,
218-
'related' => \get_class($relation->getRelated()),
219-
'foreign_key' => method_exists($relation, 'getForeignKeyName') ? $relation->getForeignKeyName() : null,
220-
];
221-
})
222-
->filter()
223-
->values();
230+
$relation = $method->invoke($model);
231+
if (!$relation instanceof Relation) {
232+
continue;
233+
}
234+
235+
$relationName = $this->relationNameConverter->normalize($method->getName());
236+
$relations[$relationName] = [
237+
'name' => $relationName,
238+
'method_name' => $method->getName(),
239+
'type' => $relation::class,
240+
'related' => \get_class($relation->getRelated()),
241+
'foreign_key' => method_exists($relation, 'getForeignKeyName') ? $relation->getForeignKeyName() : null,
242+
];
243+
}
244+
245+
return $this->relationsLocalCache[$model::class] = $relations;
224246
}
225247

226248
/**
@@ -236,21 +258,25 @@ private function getCastType(string $column, Model $model): ?string
236258
return 'attribute';
237259
}
238260

239-
return $this->getCastsWithDates($model)->get($column) ?? null;
261+
return $this->getCastsWithDates($model)[$column] ?? null;
240262
}
241263

242264
/**
243265
* Gets the model casts, including any date casts.
244266
*
245-
* @return Collection<string, mixed>
267+
* @return array<string, mixed>
246268
*/
247-
private function getCastsWithDates(Model $model): Collection
269+
private function getCastsWithDates(Model $model): array
248270
{
249-
return collect($model->getDates())
250-
->filter()
251-
->flip()
252-
->map(fn () => 'datetime')
253-
->merge($model->getCasts());
271+
$dateCasts = [];
272+
273+
foreach ($model->getDates() as $date) {
274+
if (!empty($date)) {
275+
$dateCasts[$date] = 'datetime';
276+
}
277+
}
278+
279+
return array_merge($dateCasts, $model->getCasts());
254280
}
255281

256282
/**

src/Laravel/Eloquent/PropertyAccess/PropertyAccessor.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Laravel\Eloquent\PropertyAccess;
1515

16+
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
1617
use Illuminate\Database\Eloquent\Model;
1718
use Symfony\Component\PropertyAccess\PropertyAccess;
1819
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
@@ -27,6 +28,7 @@ final class PropertyAccessor implements PropertyAccessorInterface
2728

2829
public function __construct(
2930
?PropertyAccessorInterface $inner = null,
31+
private readonly ?ModelMetadata $modelMetadata = null,
3032
) {
3133
$this->inner = $inner ?? PropertyAccess::createPropertyAccessor();
3234
}
@@ -53,6 +55,11 @@ public function setValue(object|array &$objectOrArray, string|PropertyPathInterf
5355
public function getValue(object|array $objectOrArray, string|PropertyPathInterface $propertyPath): mixed
5456
{
5557
if ($objectOrArray instanceof Model) {
58+
$key = (string) $propertyPath;
59+
if (\array_key_exists($key, $relations = $this->modelMetadata->getRelations($objectOrArray))) {
60+
return $objectOrArray->{$relations[$key]['method_name']};
61+
}
62+
5663
return $objectOrArray->{$propertyPath};
5764
}
5865

0 commit comments

Comments
 (0)