Skip to content

Commit 84e7818

Browse files
authored
feat(laravel): boot without a database via dumped metadata (#8290)
1 parent 3948362 commit 84e7818

9 files changed

Lines changed: 608 additions & 19 deletions

File tree

src/Laravel/ApiPlatformProvider.php

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyMetadataFactory;
8686
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyNameCollectionMetadataFactory;
8787
use ApiPlatform\Laravel\Eloquent\Metadata\IdentifiersExtractor as EloquentIdentifiersExtractor;
88+
use ApiPlatform\Laravel\Eloquent\Metadata\MetadataDumpFingerprint;
8889
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
8990
use ApiPlatform\Laravel\Eloquent\Metadata\ResourceClassResolver as EloquentResourceClassResolver;
9091
use ApiPlatform\Laravel\Eloquent\PropertyAccess\PropertyAccessor as EloquentPropertyAccessor;
@@ -241,10 +242,38 @@ public function register(): void
241242
);
242243
});
243244

244-
$this->app->singleton(ModelMetadata::class, static function () {
245+
// Serve the Eloquent model metadata from a dumped file so the app can boot without a live
246+
// database. Skipped when APP_DEBUG is true so local development always introspects fresh.
247+
// The dump holds only attribute/relation arrays (plain scalars and class-strings), so it is
248+
// read back with allowed_classes disabled. The dump command gets a live, unseeded instance
249+
// (contextual binding below) so it never reads back its own dump.
250+
$this->app->singleton(ModelMetadata::class, static function (Application $app) {
251+
/** @var ConfigRepository $config */
252+
$config = $app['config'];
253+
$dumpPath = $config->get('api-platform.metadata_dump');
254+
255+
if (true !== $config->get('app.debug') && \is_string($dumpPath) && is_file($dumpPath)) {
256+
$contents = file_get_contents($dumpPath);
257+
if (false !== $contents) {
258+
$data = @unserialize($contents, ['allowed_classes' => false]);
259+
if (\is_array($data) && \is_array($data['attributes'] ?? null) && \is_array($data['relations'] ?? null)) {
260+
$fingerprint = MetadataDumpFingerprint::fromMigrations($app->databasePath('migrations'));
261+
if (($data['fingerprint'] ?? null) !== $fingerprint) {
262+
$app['log']->warning('The API Platform metadata dump is stale: migrations have changed since it was generated. Re-run "php artisan api-platform:metadata:dump".');
263+
}
264+
265+
return new ModelMetadata(attributes: $data['attributes'], relations: $data['relations']);
266+
}
267+
}
268+
}
269+
245270
return new ModelMetadata();
246271
});
247272

273+
$this->app->when(Console\DumpMetadataCommand::class)
274+
->needs(ModelMetadata::class)
275+
->give(static fn () => new ModelMetadata());
276+
248277
$this->app->bind(ClassMetadataFactoryInterface::class, ClassMetadataFactory::class);
249278
$this->app->singleton(ClassMetadataFactory::class, static function (Application $app) {
250279
/** @var ConfigRepository */
@@ -1110,6 +1139,7 @@ public function register(): void
11101139
if ($this->app->runningInConsole()) {
11111140
$this->commands([
11121141
Console\InstallCommand::class,
1142+
Console\DumpMetadataCommand::class,
11131143
Console\Maker\MakeStateProcessorCommand::class,
11141144
Console\Maker\MakeStateProviderCommand::class,
11151145
Console\Maker\MakeFilterCommand::class,
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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\Console;
15+
16+
use ApiPlatform\Laravel\Eloquent\Metadata\MetadataDumpFingerprint;
17+
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
18+
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
19+
use Illuminate\Console\Command;
20+
use Illuminate\Database\Eloquent\Model;
21+
use Symfony\Component\Console\Attribute\AsCommand;
22+
23+
#[AsCommand(name: 'api-platform:metadata:dump')]
24+
final class DumpMetadataCommand extends Command
25+
{
26+
/**
27+
* @var string
28+
*/
29+
protected $signature = 'api-platform:metadata:dump {--path= : Where to write the dumped metadata file (defaults to the api-platform.metadata_dump config value)}';
30+
31+
/**
32+
* @var string
33+
*/
34+
protected $description = 'Dump the Eloquent model metadata to a file so the app can boot without hitting the database';
35+
36+
public function __construct(
37+
private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory,
38+
private readonly ModelMetadata $modelMetadata,
39+
) {
40+
parent::__construct();
41+
}
42+
43+
public function handle(): int
44+
{
45+
$path = $this->option('path');
46+
if (null === $path || '' === $path) {
47+
$path = config('api-platform.metadata_dump');
48+
}
49+
50+
if (!\is_string($path) || '' === $path) {
51+
$this->error('No dump path configured. Pass --path or set the "api-platform.metadata_dump" config value.');
52+
53+
return self::FAILURE;
54+
}
55+
56+
// This command is bound to a live ModelMetadata (contextual binding) so introspection reads
57+
// the database rather than a previously dumped, possibly stale, cache.
58+
$attributes = [];
59+
$relations = [];
60+
foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
61+
try {
62+
$model = (new \ReflectionClass($resourceClass))->newInstanceWithoutConstructor();
63+
} catch (\ReflectionException) {
64+
continue;
65+
}
66+
67+
if (!$model instanceof Model) {
68+
continue;
69+
}
70+
71+
$attributes[$resourceClass] = $this->modelMetadata->getAttributes($model);
72+
$relations[$resourceClass] = $this->modelMetadata->getRelations($model);
73+
}
74+
75+
$directory = \dirname($path);
76+
if (!is_dir($directory) && !mkdir($directory, 0o755, true) && !is_dir($directory)) {
77+
$this->error(\sprintf('Unable to create directory "%s".', $directory));
78+
79+
return self::FAILURE;
80+
}
81+
82+
// Write to a temporary file then rename so a concurrent boot never reads a half-written dump.
83+
$payload = serialize([
84+
'fingerprint' => MetadataDumpFingerprint::fromMigrations($this->laravel->databasePath('migrations')),
85+
'attributes' => $attributes,
86+
'relations' => $relations,
87+
]);
88+
$temporaryPath = $path.'.'.getmypid().'.tmp';
89+
if (false === file_put_contents($temporaryPath, $payload) || !rename($temporaryPath, $path)) {
90+
@unlink($temporaryPath);
91+
$this->error(\sprintf('Unable to write the metadata dump to "%s".', $path));
92+
93+
return self::FAILURE;
94+
}
95+
96+
$this->info(\sprintf('Dumped metadata for %d model(s) to "%s".', \count($attributes), $path));
97+
98+
return self::SUCCESS;
99+
}
100+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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+
/**
17+
* Fingerprints the migration files so a stale metadata dump (migrations changed since it was
18+
* generated) can be detected at boot without reading the database, which would defeat the dump.
19+
*
20+
* It hashes file names, modification times and sizes only: a manual schema change made outside the
21+
* migration files is not detected.
22+
*
23+
* @internal
24+
*/
25+
final class MetadataDumpFingerprint
26+
{
27+
public static function fromMigrations(string $migrationsPath): string
28+
{
29+
$files = glob($migrationsPath.'/*.php') ?: [];
30+
sort($files);
31+
32+
$hash = hash_init('xxh128');
33+
foreach ($files as $file) {
34+
hash_update($hash, $file.'|'.filemtime($file).'|'.filesize($file));
35+
}
36+
37+
return hash_final($hash);
38+
}
39+
}

src/Laravel/Eloquent/Metadata/ModelMetadata.php

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,6 @@
2727
*/
2828
final class ModelMetadata
2929
{
30-
/**
31-
* @var array<class-string, array<string, mixed>>
32-
*/
33-
private $attributesLocalCache = [];
34-
35-
/**
36-
* @var array<class-string, array<string, mixed>>
37-
*/
38-
private $relationsLocalCache = [];
39-
4030
/**
4131
* The methods that can be called in a model to indicate a relation.
4232
*
@@ -56,8 +46,15 @@ final class ModelMetadata
5646
'morphedByMany',
5747
];
5848

59-
public function __construct(private NameConverterInterface $relationNameConverter = new CamelCaseToSnakeCaseNameConverter())
60-
{
49+
/**
50+
* @param array<class-string, array<string, mixed>> $attributes seeds the attribute cache, e.g. from a dump produced by api-platform:metadata:dump, so the app can boot without a database
51+
* @param array<class-string, array<string, mixed>> $relations seeds the relation cache for the same reason
52+
*/
53+
public function __construct(
54+
private NameConverterInterface $relationNameConverter = new CamelCaseToSnakeCaseNameConverter(),
55+
private array $attributes = [],
56+
private array $relations = [],
57+
) {
6158
}
6259

6360
/**
@@ -67,8 +64,8 @@ public function __construct(private NameConverterInterface $relationNameConverte
6764
*/
6865
public function getAttributes(Model $model): array
6966
{
70-
if (isset($this->attributesLocalCache[$model::class])) {
71-
return $this->attributesLocalCache[$model::class];
67+
if (isset($this->attributes[$model::class])) {
68+
return $this->attributes[$model::class];
7269
}
7370

7471
$connection = $model->getConnection();
@@ -112,7 +109,7 @@ public function getAttributes(Model $model): array
112109
return $result;
113110
}
114111

115-
return $this->attributesLocalCache[$model::class] = $result;
112+
return $this->attributes[$model::class] = $result;
116113
}
117114

118115
/**
@@ -194,8 +191,8 @@ private function getVirtualAttributes(Model $model, array $columns): array
194191
*/
195192
public function getRelations(Model $model): array
196193
{
197-
if (isset($this->relationsLocalCache[$model::class])) {
198-
return $this->relationsLocalCache[$model::class];
194+
if (isset($this->relations[$model::class])) {
195+
return $this->relations[$model::class];
199196
}
200197

201198
$relations = [];
@@ -254,7 +251,7 @@ public function getRelations(Model $model): array
254251
];
255252
}
256253

257-
return $this->relationsLocalCache[$model::class] = $relations;
254+
return $this->relations[$model::class] = $relations;
258255
}
259256

260257
/**

0 commit comments

Comments
 (0)