• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

api-platform / core / 15255731762

26 May 2025 01:55PM UTC coverage: 0.0% (-26.5%) from 26.526%
15255731762

Pull #7176

github

web-flow
Merge 66f6cf4d2 into 79edced67
Pull Request #7176: Merge 4.1

0 of 387 new or added lines in 28 files covered. (0.0%)

11394 existing lines in 372 files now uncovered.

0 of 51334 relevant lines covered (0.0%)

0.0 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/Laravel/Eloquent/Metadata/ModelMetadata.php
1
<?php
2

3
/*
4
 * This file is part of the API Platform project.
5
 *
6
 * (c) Kévin Dunglas <dunglas@gmail.com>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11

12
declare(strict_types=1);
13

14
namespace ApiPlatform\Laravel\Eloquent\Metadata;
15

16
use Illuminate\Database\Eloquent\Model;
17
use Illuminate\Database\Eloquent\Relations\Relation;
18
use Illuminate\Support\Collection;
19
use Illuminate\Support\Str;
20
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
21
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
22

23
/**
24
 * Inspired from Illuminate\Database\Console\ShowModelCommand.
25
 *
26
 * @internal
27
 */
28
final class ModelMetadata
29
{
30
    /**
31
     * @var array<class-string, Collection<string, mixed>>
32
     */
33
    private $attributesLocalCache = [];
34

35
    /**
36
     * @var array<class-string, Collection<int, mixed>>
37
     */
38
    private $relationsLocalCache = [];
39

40
    /**
41
     * The methods that can be called in a model to indicate a relation.
42
     *
43
     * @var string[]
44
     */
45
    public const RELATION_METHODS = [
46
        'hasMany',
47
        'hasManyThrough',
48
        'hasOneThrough',
49
        'belongsToMany',
50
        'hasOne',
51
        'belongsTo',
52
        'morphOne',
53
        'morphTo',
54
        'morphMany',
55
        'morphToMany',
56
        'morphedByMany',
57
    ];
58

59
    public function __construct(private NameConverterInterface $relationNameConverter = new CamelCaseToSnakeCaseNameConverter())
60
    {
NEW
61
    }
×
62

63
    /**
64
     * Gets the column attributes for the given model.
65
     *
66
     * @return Collection<string, mixed>
67
     */
68
    public function getAttributes(Model $model): Collection
69
    {
70
        if (isset($this->attributesLocalCache[$model::class])) {
×
71
            return $this->attributesLocalCache[$model::class];
×
72
        }
73

74
        $connection = $model->getConnection();
×
75
        $schema = $connection->getSchemaBuilder();
×
76
        $table = $model->getTable();
×
77
        $columns = $schema->getColumns($table);
×
78
        $indexes = $schema->getIndexes($table);
×
79
        $relations = $this->getRelations($model);
×
80

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) => [
×
88
                'name' => $column['name'],
×
89
                'type' => $column['type'],
×
90
                'increments' => $column['auto_increment'],
×
91
                'nullable' => $column['nullable'],
×
92
                'default' => $this->getColumnDefault($column, $model),
×
93
                'unique' => $this->columnIsUnique($column['name'], $indexes),
×
94
                'fillable' => $model->isFillable($column['name']),
×
95
                'hidden' => $this->attributeIsHidden($column['name'], $model),
×
96
                'appended' => null,
×
97
                'cast' => $this->getCastType($column['name'], $model),
×
98
                'primary' => $this->isColumnPrimaryKey($indexes, $column['name']),
×
99
            ])
×
100
            ->merge($this->getVirtualAttributes($model, $columns));
×
101
    }
102

103
    /**
104
     * @param array<int, array{columns: string[]}> $indexes
105
     */
106
    private function isColumnPrimaryKey(array $indexes, string $column): bool
107
    {
108
        foreach ($indexes as $index) {
×
109
            if (\in_array($column, $index['columns'], true)) {
×
110
                return true;
×
111
            }
112
        }
113

114
        return false;
×
115
    }
116

117
    /**
118
     * Get the virtual (non-column) attributes for the given model.
119
     *
120
     * @param array<string, mixed> $columns
121
     *
122
     * @return Collection<int, mixed>
123
     */
124
    private function getVirtualAttributes(Model $model, array $columns): Collection
125
    {
126
        $class = new \ReflectionClass($model);
×
127

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
                }
141

142
                return [];
×
143
            })
×
144
            ->reject(fn ($cast, $name) => collect($columns)->contains('name', $name))
×
145
            ->map(fn ($cast, $name) => [
×
146
                'name' => $name,
×
147
                'type' => null,
×
148
                'increments' => false,
×
149
                'nullable' => null,
×
150
                'default' => null,
×
151
                'unique' => null,
×
152
                'fillable' => $model->isFillable($name),
×
153
                'hidden' => $this->attributeIsHidden($name, $model),
×
154
                'appended' => $model->hasAppended($name),
×
155
                'cast' => $cast,
×
156
            ])
×
157
            ->values();
×
158
    }
159

160
    /**
161
     * Gets the relations from the given model.
162
     *
163
     * @return Collection<int, mixed>
164
     */
165
    public function getRelations(Model $model): Collection
166
    {
167
        if (isset($this->relationsLocalCache[$model::class])) {
×
168
            return $this->relationsLocalCache[$model::class];
×
169
        }
170

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 (
NEW
182
                    $method->getReturnType() instanceof \ReflectionNamedType
×
NEW
183
                    && is_subclass_of($method->getReturnType()->getName(), Relation::class)
×
184
                ) {
UNCOV
185
                    return true;
×
186
                }
187

188
                if (false === $method->getFileName()) {
×
189
                    return false;
×
190
                }
191

192
                $file = new \SplFileObject($method->getFileName());
×
193
                $file->seek($method->getStartLine() - 1);
×
194
                $code = '';
×
195
                while ($file->key() < $method->getEndLine()) {
×
196
                    $current = $file->current();
×
197
                    if (\is_string($current)) {
×
198
                        $code .= trim($current);
×
199
                    }
200

201
                    $file->next();
×
202
                }
203

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;
×
212
                }
213

214
                return [
×
NEW
215
                    'name' => $this->relationNameConverter->normalize($method->getName()),
×
NEW
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();
×
224
    }
225

226
    /**
227
     * Gets the cast type for the given column.
228
     */
229
    private function getCastType(string $column, Model $model): ?string
230
    {
231
        if ($model->hasGetMutator($column) || $model->hasSetMutator($column)) {
×
232
            return 'accessor';
×
233
        }
234

235
        if ($model->hasAttributeMutator($column)) {
×
236
            return 'attribute';
×
237
        }
238

239
        return $this->getCastsWithDates($model)->get($column) ?? null;
×
240
    }
241

242
    /**
243
     * Gets the model casts, including any date casts.
244
     *
245
     * @return Collection<string, mixed>
246
     */
247
    private function getCastsWithDates(Model $model): Collection
248
    {
249
        return collect($model->getDates())
×
250
            ->filter()
×
251
            ->flip()
×
252
            ->map(fn () => 'datetime')
×
253
            ->merge($model->getCasts());
×
254
    }
255

256
    /**
257
     * Gets the default value for the given column.
258
     *
259
     * @param array<string, mixed>&array{name: string, default: string} $column
260
     */
261
    private function getColumnDefault(array $column, Model $model): mixed
262
    {
263
        $attributeDefault = $model->getAttributes()[$column['name']] ?? null;
×
264

265
        return match (true) {
×
266
            $attributeDefault instanceof \BackedEnum => $attributeDefault->value,
×
267
            $attributeDefault instanceof \UnitEnum => $attributeDefault->name,
×
268
            default => $attributeDefault ?? $column['default'],
×
269
        };
×
270
    }
271

272
    /**
273
     * Determines if the given attribute is hidden.
274
     */
275
    private function attributeIsHidden(string $attribute, Model $model): bool
276
    {
277
        if ($visible = $model->getVisible()) {
×
278
            return !\in_array($attribute, $visible, true);
×
279
        }
280

281
        if ($hidden = $model->getHidden()) {
×
282
            return \in_array($attribute, $hidden, true);
×
283
        }
284

285
        return false;
×
286
    }
287

288
    /**
289
     * Determines if the given attribute is unique.
290
     *
291
     * @param array<int, array{columns: string[], unique: bool}> $indexes
292
     */
293
    private function columnIsUnique(string $column, array $indexes): bool
294
    {
295
        return collect($indexes)->contains(
×
296
            fn ($index) => 1 === \count($index['columns']) && $index['columns'][0] === $column && $index['unique']
×
297
        );
×
298
    }
299
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc