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
4 changes: 3 additions & 1 deletion src/Laravel/ApiPlatformDeferredProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
use ApiPlatform\Laravel\Eloquent\Filter\RangeFilter;
use ApiPlatform\Laravel\Eloquent\Filter\StartSearchFilter;
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Resource\EloquentResourceCollectionMetadataFactory;
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
use ApiPlatform\Laravel\Eloquent\State\CollectionProvider;
use ApiPlatform\Laravel\Eloquent\State\ItemProvider;
use ApiPlatform\Laravel\Eloquent\State\LinksHandler;
Expand Down Expand Up @@ -276,7 +277,8 @@ public function register(): void
),
(bool) $config->get('api-platform.partial_patch_validation', false)
),
true === $config->get('app.debug') ? 'array' : $config->get('api-platform.cache', 'file')
true === $config->get('app.debug') ? 'array' : $config->get('api-platform.cache', 'file'),
$app->make(ModelMetadata::class)
);
});

Expand Down
6 changes: 4 additions & 2 deletions src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,8 @@ public function register(): void
$app->make(ResourceClassResolverInterface::class)
)
),
true === $config->get('app.debug') ? 'array' : $config->get('api-platform.cache', 'file')
true === $config->get('app.debug') ? 'array' : $config->get('api-platform.cache', 'file'),
$app->make(ModelMetadata::class)
);
});

Expand All @@ -396,7 +397,8 @@ public function register(): void
),
$nameConverter
),
true === $config->get('app.debug') ? 'array' : $config->get('api-platform.cache', 'file')
true === $config->get('app.debug') ? 'array' : $config->get('api-platform.cache', 'file'),
$app->make(ModelMetadata::class)
);
});

Expand Down
13 changes: 13 additions & 0 deletions src/Laravel/Eloquent/Metadata/ModelMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ final class ModelMetadata
'morphedByMany',
];

private int $missingTableReads = 0;

/**
* @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
Expand Down Expand Up @@ -106,12 +108,23 @@ public function getAttributes(Model $model): array
// Don't cache an empty result for a missing table: the table may be created later
// (e.g. by RefreshDatabase between MCP boot-time discovery and the actual request).
if ([] === $result && !$schema->hasTable($table)) {
++$this->missingTableReads;

return $result;
}

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

/**
* Counts the attribute reads that found no table. Metadata computed while this number
* changes was built from a missing table, and must not be cached beyond the current call.
*/
public function getMissingTableReads(): int
{
return $this->missingTableReads;
}

/**
* @param array<int, array{columns: string[], primary?: bool}> $indexes
*/
Expand Down
14 changes: 8 additions & 6 deletions src/Laravel/Metadata/CachePropertyMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@

namespace ApiPlatform\Laravel\Metadata;

use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use Illuminate\Support\Facades\Cache;

final class CachePropertyMetadataFactory implements PropertyMetadataFactoryInterface
{
use MetadataCacheTrait;

/**
* @var array<string, ApiProperty>
*/
Expand All @@ -27,15 +29,15 @@ final class CachePropertyMetadataFactory implements PropertyMetadataFactoryInter
public function __construct(
private readonly PropertyMetadataFactoryInterface $decorated,
private readonly string $cacheStore,
private readonly ?ModelMetadata $modelMetadata = null,
) {
}

public function create(string $resourceClass, string $property, array $options = []): ApiProperty
{
$key = hash('xxh3', serialize(['resource_class' => $resourceClass, 'property' => $property] + $options));

return $this->localCache[$key] ??= Cache::store($this->cacheStore)->rememberForever($key, function () use ($resourceClass, $property, $options) {
return $this->decorated->create($resourceClass, $property, $options);
});
return $this->cached(
hash('xxh3', serialize(['resource_class' => $resourceClass, 'property' => $property] + $options)),
fn (): ApiProperty => $this->decorated->create($resourceClass, $property, $options),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@

namespace ApiPlatform\Laravel\Metadata;

use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Property\PropertyNameCollection;
use Illuminate\Support\Facades\Cache;

final class CachePropertyNameCollectionMetadataFactory implements PropertyNameCollectionFactoryInterface
{
use MetadataCacheTrait;

/**
* @var array<string, PropertyNameCollection>
*/
Expand All @@ -27,15 +29,15 @@ final class CachePropertyNameCollectionMetadataFactory implements PropertyNameCo
public function __construct(
private readonly PropertyNameCollectionFactoryInterface $decorated,
private readonly string $cacheStore,
private readonly ?ModelMetadata $modelMetadata = null,
) {
}

public function create(string $resourceClass, array $options = []): PropertyNameCollection
{
$key = hash('xxh3', serialize(['resource_class' => $resourceClass] + $options));

return $this->localCache[$key] ??= Cache::store($this->cacheStore)->rememberForever($key, function () use ($resourceClass, $options) {
return $this->decorated->create($resourceClass, $options);
});
return $this->cached(
hash('xxh3', serialize(['resource_class' => $resourceClass] + $options)),
fn (): PropertyNameCollection => $this->decorated->create($resourceClass, $options),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@

namespace ApiPlatform\Laravel\Metadata;

use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use Illuminate\Support\Facades\Cache;

final class CacheResourceCollectionMetadataFactory implements ResourceMetadataCollectionFactoryInterface
{
use MetadataCacheTrait;

/**
* @var array<string, ResourceMetadataCollection>
*/
Expand All @@ -27,13 +29,12 @@ final class CacheResourceCollectionMetadataFactory implements ResourceMetadataCo
public function __construct(
private readonly ResourceMetadataCollectionFactoryInterface $decorated,
private readonly string $cacheStore,
private readonly ?ModelMetadata $modelMetadata = null,
) {
}

public function create(string $resourceClass): ResourceMetadataCollection
{
return $this->localCache[$resourceClass] ??= Cache::store($this->cacheStore)->rememberForever($resourceClass, function () use ($resourceClass) {
return $this->decorated->create($resourceClass);
});
return $this->cached($resourceClass, fn (): ResourceMetadataCollection => $this->decorated->create($resourceClass));
}
}
46 changes: 46 additions & 0 deletions src/Laravel/Metadata/MetadataCacheTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?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\Metadata;

use Illuminate\Support\Facades\Cache;

/**
* @internal
*/
trait MetadataCacheTrait
{
private function cached(string $key, callable $create): mixed
{
if (isset($this->localCache[$key])) {
return $this->localCache[$key];
}

$store = Cache::store($this->cacheStore);
if (null !== $metadata = $store->get($key)) {
return $this->localCache[$key] = $metadata;
}

$missingTableReads = $this->modelMetadata?->getMissingTableReads();
$metadata = $create();

// built from a missing table: the next call, maybe in another process, has to read it again
if ($missingTableReads !== $this->modelMetadata?->getMissingTableReads()) {
return $metadata;
}

$store->forever($key, $metadata);

return $this->localCache[$key] = $metadata;
}
}
151 changes: 151 additions & 0 deletions src/Laravel/Tests/Metadata/CacheMetadataFactoriesMissingTableTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?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\Tests\Metadata;

use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyMetadataFactory;
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyNameCollectionMetadataFactory;
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
use ApiPlatform\Laravel\Metadata\CachePropertyMetadataFactory;
use ApiPlatform\Laravel\Metadata\CachePropertyNameCollectionMetadataFactory;
use ApiPlatform\Laravel\Metadata\CacheResourceCollectionMetadataFactory;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Orchestra\Testbench\Concerns\WithWorkbench;
use Orchestra\Testbench\TestCase;

/**
* Tests for issue #8585.
*
* A worker booting while a table is missing (a deploy running its migrations, for instance)
* must not store the metadata it read from that missing table in the persistent cache store:
* the next worker would otherwise keep serving it after the table is created.
* Each "worker" below is a new factory chain with a new ModelMetadata, sharing the store.
*/
final class CacheMetadataFactoriesMissingTableTest extends TestCase
{
use RefreshDatabase;
use WithWorkbench;

private const TABLE = 'issue8585_books';

public function testPropertyNamesReadFromAMissingTableAreNotCached(): void
{
$model = $this->createModel();

$this->assertSame([], iterator_to_array($this->createPropertyNameCollectionFactory()->create($model::class)));

$this->createTable();

$this->assertSame(['id', 'title'], iterator_to_array($this->createPropertyNameCollectionFactory()->create($model::class)));
}

public function testPropertyMetadataReadFromAMissingTableIsNotCached(): void
{
$model = $this->createModel();

$this->assertNull($this->createPropertyMetadataFactory()->create($model::class, 'title')->getNativeType());

$this->createTable();

$this->assertNotNull($this->createPropertyMetadataFactory()->create($model::class, 'title')->getNativeType());
}

public function testResourceMetadataBuiltFromAMissingTableIsNotCached(): void
{
$model = $this->createModel();

$this->assertNull($this->createResourceMetadataCollectionFactory($model)->create($model::class)[0]->getDescription());

$this->createTable();

$this->assertSame('id, title', $this->createResourceMetadataCollectionFactory($model)->create($model::class)[0]->getDescription());
}

public function testMetadataIsCachedOnceTheTableExists(): void
{
$model = $this->createModel();
$this->createTable();

$this->createPropertyNameCollectionFactory()->create($model::class);
Schema::drop(self::TABLE);

// served from the store: the table is not read again
$this->assertSame(['id', 'title'], iterator_to_array($this->createPropertyNameCollectionFactory()->create($model::class)));
}

private function createModel(): Model
{
Cache::store('array')->clear();

return new class extends Model {
protected $table = 'issue8585_books';
};
}

private function createTable(): void
{
Schema::create(self::TABLE, static function (Blueprint $table): void {
$table->id();
$table->string('title');
});
}

private function createPropertyNameCollectionFactory(): CachePropertyNameCollectionMetadataFactory
{
$modelMetadata = new ModelMetadata();

return new CachePropertyNameCollectionMetadataFactory(
new EloquentPropertyNameCollectionMetadataFactory($modelMetadata, null, $this->app->make(ResourceClassResolverInterface::class)),
'array',
$modelMetadata,
);
}

private function createPropertyMetadataFactory(): CachePropertyMetadataFactory
{
$modelMetadata = new ModelMetadata();

return new CachePropertyMetadataFactory(new EloquentPropertyMetadataFactory($modelMetadata), 'array', $modelMetadata);
}

/**
* The resource metadata depends on the table through the property factories: this one
* describes the resource with the columns it reads.
*/
private function createResourceMetadataCollectionFactory(Model $model): CacheResourceCollectionMetadataFactory
{
$modelMetadata = new ModelMetadata();
$decorated = new class($modelMetadata, $model) implements ResourceMetadataCollectionFactoryInterface {
public function __construct(private readonly ModelMetadata $modelMetadata, private readonly Model $model)
{
}

public function create(string $resourceClass): ResourceMetadataCollection
{
$columns = array_keys($this->modelMetadata->getAttributes($this->model));

return new ResourceMetadataCollection($resourceClass, [new ApiResource(description: $columns ? implode(', ', $columns) : null)]);
}
};

return new CacheResourceCollectionMetadataFactory($decorated, 'array', $modelMetadata);
}
}
Loading