Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyMetadataFactory;
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyNameCollectionMetadataFactory;
use ApiPlatform\Laravel\Eloquent\Metadata\IdentifiersExtractor as EloquentIdentifiersExtractor;
use ApiPlatform\Laravel\Eloquent\Metadata\MetadataDumpFingerprint;
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
use ApiPlatform\Laravel\Eloquent\Metadata\ResourceClassResolver as EloquentResourceClassResolver;
use ApiPlatform\Laravel\Eloquent\PropertyAccess\PropertyAccessor as EloquentPropertyAccessor;
Expand Down Expand Up @@ -241,10 +242,38 @@ public function register(): void
);
});

$this->app->singleton(ModelMetadata::class, static function () {
// Serve the Eloquent model metadata from a dumped file so the app can boot without a live
// database. Skipped when APP_DEBUG is true so local development always introspects fresh.
// The dump holds only attribute/relation arrays (plain scalars and class-strings), so it is
// read back with allowed_classes disabled. The dump command gets a live, unseeded instance
// (contextual binding below) so it never reads back its own dump.
$this->app->singleton(ModelMetadata::class, static function (Application $app) {
/** @var ConfigRepository $config */
$config = $app['config'];
$dumpPath = $config->get('api-platform.metadata_dump');

if (true !== $config->get('app.debug') && \is_string($dumpPath) && is_file($dumpPath)) {
$contents = file_get_contents($dumpPath);
if (false !== $contents) {
$data = @unserialize($contents, ['allowed_classes' => false]);
if (\is_array($data) && \is_array($data['attributes'] ?? null) && \is_array($data['relations'] ?? null)) {
$fingerprint = MetadataDumpFingerprint::fromMigrations($app->databasePath('migrations'));
if (($data['fingerprint'] ?? null) !== $fingerprint) {
$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".');
}

return new ModelMetadata(attributes: $data['attributes'], relations: $data['relations']);
}
}
}

return new ModelMetadata();
});

$this->app->when(Console\DumpMetadataCommand::class)
->needs(ModelMetadata::class)
->give(static fn () => new ModelMetadata());

$this->app->bind(ClassMetadataFactoryInterface::class, ClassMetadataFactory::class);
$this->app->singleton(ClassMetadataFactory::class, static function (Application $app) {
/** @var ConfigRepository */
Expand Down Expand Up @@ -1110,6 +1139,7 @@ public function register(): void
if ($this->app->runningInConsole()) {
$this->commands([
Console\InstallCommand::class,
Console\DumpMetadataCommand::class,
Console\Maker\MakeStateProcessorCommand::class,
Console\Maker\MakeStateProviderCommand::class,
Console\Maker\MakeFilterCommand::class,
Expand Down
100 changes: 100 additions & 0 deletions src/Laravel/Console/DumpMetadataCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

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

declare(strict_types=1);

namespace ApiPlatform\Laravel\Console;

use ApiPlatform\Laravel\Eloquent\Metadata\MetadataDumpFingerprint;
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\Console\Attribute\AsCommand;

#[AsCommand(name: 'api-platform:metadata:dump')]
final class DumpMetadataCommand extends Command
{
/**
* @var string
*/
protected $signature = 'api-platform:metadata:dump {--path= : Where to write the dumped metadata file (defaults to the api-platform.metadata_dump config value)}';

/**
* @var string
*/
protected $description = 'Dump the Eloquent model metadata to a file so the app can boot without hitting the database';

public function __construct(
private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory,
private readonly ModelMetadata $modelMetadata,
) {
parent::__construct();
}

public function handle(): int
{
$path = $this->option('path');
if (null === $path || '' === $path) {
$path = config('api-platform.metadata_dump');
}

if (!\is_string($path) || '' === $path) {
$this->error('No dump path configured. Pass --path or set the "api-platform.metadata_dump" config value.');

return self::FAILURE;
}

// This command is bound to a live ModelMetadata (contextual binding) so introspection reads
// the database rather than a previously dumped, possibly stale, cache.
$attributes = [];
$relations = [];
foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
try {
$model = (new \ReflectionClass($resourceClass))->newInstanceWithoutConstructor();
} catch (\ReflectionException) {
continue;
}

if (!$model instanceof Model) {
continue;
}

$attributes[$resourceClass] = $this->modelMetadata->getAttributes($model);
$relations[$resourceClass] = $this->modelMetadata->getRelations($model);
}

$directory = \dirname($path);
if (!is_dir($directory) && !mkdir($directory, 0o755, true) && !is_dir($directory)) {
$this->error(\sprintf('Unable to create directory "%s".', $directory));

return self::FAILURE;
}

// Write to a temporary file then rename so a concurrent boot never reads a half-written dump.
$payload = serialize([
'fingerprint' => MetadataDumpFingerprint::fromMigrations($this->laravel->databasePath('migrations')),
'attributes' => $attributes,
'relations' => $relations,
]);
$temporaryPath = $path.'.'.getmypid().'.tmp';
if (false === file_put_contents($temporaryPath, $payload) || !rename($temporaryPath, $path)) {
@unlink($temporaryPath);
$this->error(\sprintf('Unable to write the metadata dump to "%s".', $path));

return self::FAILURE;
}

$this->info(\sprintf('Dumped metadata for %d model(s) to "%s".', \count($attributes), $path));

return self::SUCCESS;
}
}
39 changes: 39 additions & 0 deletions src/Laravel/Eloquent/Metadata/MetadataDumpFingerprint.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

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

declare(strict_types=1);

namespace ApiPlatform\Laravel\Eloquent\Metadata;

/**
* Fingerprints the migration files so a stale metadata dump (migrations changed since it was
* generated) can be detected at boot without reading the database, which would defeat the dump.
*
* It hashes file names, modification times and sizes only: a manual schema change made outside the
* migration files is not detected.
*
* @internal
*/
final class MetadataDumpFingerprint
{
public static function fromMigrations(string $migrationsPath): string
{
$files = glob($migrationsPath.'/*.php') ?: [];
sort($files);

$hash = hash_init('xxh128');
foreach ($files as $file) {
hash_update($hash, $file.'|'.filemtime($file).'|'.filesize($file));
}

return hash_final($hash);
}
}
33 changes: 15 additions & 18 deletions src/Laravel/Eloquent/Metadata/ModelMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,6 @@
*/
final class ModelMetadata
{
/**
* @var array<class-string, array<string, mixed>>
*/
private $attributesLocalCache = [];

/**
* @var array<class-string, array<string, mixed>>
*/
private $relationsLocalCache = [];

/**
* The methods that can be called in a model to indicate a relation.
*
Expand All @@ -56,8 +46,15 @@ final class ModelMetadata
'morphedByMany',
];

public function __construct(private NameConverterInterface $relationNameConverter = new CamelCaseToSnakeCaseNameConverter())
{
/**
* @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
* @param array<class-string, array<string, mixed>> $relations seeds the relation cache for the same reason
*/
public function __construct(
private NameConverterInterface $relationNameConverter = new CamelCaseToSnakeCaseNameConverter(),
private array $attributes = [],
private array $relations = [],
) {
}

/**
Expand All @@ -67,8 +64,8 @@ public function __construct(private NameConverterInterface $relationNameConverte
*/
public function getAttributes(Model $model): array
{
if (isset($this->attributesLocalCache[$model::class])) {
return $this->attributesLocalCache[$model::class];
if (isset($this->attributes[$model::class])) {
return $this->attributes[$model::class];
}

$connection = $model->getConnection();
Expand Down Expand Up @@ -112,7 +109,7 @@ public function getAttributes(Model $model): array
return $result;
}

return $this->attributesLocalCache[$model::class] = $result;
return $this->attributes[$model::class] = $result;
}

/**
Expand Down Expand Up @@ -194,8 +191,8 @@ private function getVirtualAttributes(Model $model, array $columns): array
*/
public function getRelations(Model $model): array
{
if (isset($this->relationsLocalCache[$model::class])) {
return $this->relationsLocalCache[$model::class];
if (isset($this->relations[$model::class])) {
return $this->relations[$model::class];
}

$relations = [];
Expand Down Expand Up @@ -254,7 +251,7 @@ public function getRelations(Model $model): array
];
}

return $this->relationsLocalCache[$model::class] = $relations;
return $this->relations[$model::class] = $relations;
}

/**
Expand Down
Loading
Loading