From 368277bf7359737c12eeb13b5071f7ab4cb873b6 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Mon, 5 Oct 2026 17:14:40 +0200 Subject: [PATCH 1/2] feat(meta): typed meta as attributes of the Pollora models HasTypedMeta on Post, Page, Term, User and Comment: typed reads, checked writes stored on save, whereMeta() and one meta cache query per collection. Post models are bound to their $postType at discovery. --- CHANGELOG.md | 1 + rector.php | 6 + .../Application/Services/MetaAccessor.php | 9 +- .../Services/MetaSchemaRepository.php | 34 +++ .../Infrastructure/Services/MetaDiscovery.php | 36 ++- src/Models/Comment.php | 7 +- src/Models/Concerns/HasTypedMeta.php | 279 ++++++++++++++++++ src/Models/Page.php | 7 +- src/Models/Post.php | 3 + src/Models/Term.php | 7 +- src/Models/User.php | 2 + tests/Unit/Meta/Fixtures/EventPostModel.php | 12 + tests/Unit/Meta/HasTypedMetaTest.php | 218 ++++++++++++++ tests/Unit/Meta/MetaDiscoveryTest.php | 13 + tests/Unit/Meta/MetaSchemaRepositoryTest.php | 17 ++ 15 files changed, 644 insertions(+), 7 deletions(-) create mode 100644 src/Models/Concerns/HasTypedMeta.php create mode 100644 tests/Unit/Meta/Fixtures/EventPostModel.php create mode 100644 tests/Unit/Meta/HasTypedMetaTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 07972a9e..aa6c5b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Typed meta for every object (**experimental**): `#[PostMeta('product')]` or `#[PostMeta(['post', 'page'])]` for post types the project does not declare, `#[TermMeta('category')]`, `#[UserMeta]` and `#[CommentMeta]` (every comment type: WordPress has no per-type comment meta). `Meta::of()` reads and writes them by object ID; a key two classes declare for the same objects is refused at discovery +- Typed meta on the Eloquent models (**experimental**): `Pollora\Models\Post`, `Page`, `Term`, `User` and `Comment` read the `#[Meta]` their object carries as attributes, with their PHP type (`$event->capacity`, or by key `$event->sold_out`), check a write at once and store it through `update_metadata()` when the model is saved; `whereMeta('capacity', '>=', 100)` compares numbers as numbers. A post model with `protected $postType = 'event'` is bound to its post type at discovery, so `Post::find()` returns it. A class known to carry typed meta stops eager loading the `meta` relation and primes WordPress's meta cache once per collection (one query instead of the relation); other models and undeclared keys keep Colt's behaviour - Roles in Laravel (**experimental**), a role being named by its slug or by the class of a `#[Role]`: `hasRole()`, `assignRole()`, `removeRole()` and `roles()` on `Pollora\Models\User` (trait `HasRoles`; writes go through `WP_User`, an unknown role is refused); the `role:` route middleware (`role:event_manager,editor`, or `EnsureUserHasRole::using(EventManager::class)`), which refuses with a 403 a user who has none of the roles, an alias the application already uses being kept; `@role` accepts role classes (`@role(EventManager::class)`) and keeps the behaviour of Sage Directives' `@role` for slugs - REST permission `Can` for `#[WpRestRoute]` and `#[Method]`: `permissionCallback: new Can('edit_posts')`, a `#[CapabilitySet]` enum case, or `new Can('edit_post', parameter: 'id')` to check a meta capability on the object in the request. `permissionCallback` now accepts a `Permission` instance as well as a class name; a refusal answers 401 to a guest and 403 to a logged-in user - Translated role labels: `#[Role(…, textDomain: 'my-theme')]` translates the label with that domain wherever WordPress shows role names (`translate_user_role()`: users list, role dropdowns). Translation happens when the admin displays the role, through `gettext_with_context_default`, so no translation is loaded early; a label WordPress already translates is left alone diff --git a/rector.php b/rector.php index 6be75394..a31b6629 100644 --- a/rector.php +++ b/rector.php @@ -4,6 +4,7 @@ use Rector\Config\RectorConfig; use Rector\Php83\Rector\ClassMethod\AddOverrideAttributeToOverriddenMethodsRector; +use Rector\TypeDeclaration\Rector\ClassMethod\ScalarParamTypeByMethodCallTypeRector; use RectorLaravel\Rector\ArrayDimFetch\ServerVariableToRequestFacadeRector; use RectorLaravel\Rector\MethodCall\ContainerBindConcreteWithClosureOnlyRector; use RectorLaravel\Set\LaravelLevelSetList; @@ -16,6 +17,11 @@ ]) ->withSkip([ AddOverrideAttributeToOverriddenMethodsRector::class, + // getAttribute($key) and setAttribute($key) override Eloquent's untyped + // signatures: typing $key would be a fatal incompatible declaration. + ScalarParamTypeByMethodCallTypeRector::class => [ + __DIR__.'/src/Models/Concerns/HasTypedMeta.php', + ], ContainerBindConcreteWithClosureOnlyRector::class => [ __DIR__.'/src/Hook/Infrastructure/Providers/HookServiceProvider.php', ], diff --git a/src/Meta/Application/Services/MetaAccessor.php b/src/Meta/Application/Services/MetaAccessor.php index 4edf0edb..5ae6e6bb 100644 --- a/src/Meta/Application/Services/MetaAccessor.php +++ b/src/Meta/Application/Services/MetaAccessor.php @@ -8,6 +8,7 @@ use Pollora\Meta\Domain\Exceptions\InvalidMetaValueException; use Pollora\Meta\Domain\Models\MetaDefinition; use Pollora\Meta\Domain\Models\MetaRecord; +use Pollora\Meta\Domain\Models\MetaSchema; use Pollora\Meta\Domain\Services\MetaValueCaster; use Psr\Log\LoggerInterface; @@ -35,8 +36,14 @@ public function __construct( */ public function of(string $class, int $objectId): MetaRecord { - $schema = $this->schemas->forClass($class) ?? $this->builder->build($class); + return $this->record($this->schemas->forClass($class) ?? $this->builder->build($class), $objectId); + } + /** + * The typed meta of a schema on one object. + */ + public function record(MetaSchema $schema, int $objectId): MetaRecord + { return new MetaRecord($schema, $objectId, $this->store, $this->caster, $this->handleUnreadable(...)); } diff --git a/src/Meta/Application/Services/MetaSchemaRepository.php b/src/Meta/Application/Services/MetaSchemaRepository.php index b918e369..618acece 100644 --- a/src/Meta/Application/Services/MetaSchemaRepository.php +++ b/src/Meta/Application/Services/MetaSchemaRepository.php @@ -4,6 +4,7 @@ namespace Pollora\Meta\Application\Services; +use Pollora\Meta\Domain\Enums\MetaObjectType; use Pollora\Meta\Domain\Exceptions\InvalidMetaDefinitionException; use Pollora\Meta\Domain\Models\MetaDefinition; use Pollora\Meta\Domain\Models\MetaSchema; @@ -18,6 +19,11 @@ final class MetaSchemaRepository */ private array $schemas = []; + /** + * @var array> Property names and keys, by object type + */ + private array $names = []; + /** * @throws InvalidMetaDefinitionException When another class already declares one of its keys on the same object */ @@ -36,6 +42,11 @@ public function add(MetaSchema $schema): void } $this->schemas[$schema->declaringClass] = $schema; + + foreach ($schema->definitions as $definition) { + $this->names[$schema->objectType->value][$definition->property] = true; + $this->names[$schema->objectType->value][$definition->key] = true; + } } /** @@ -46,6 +57,29 @@ public function forClass(string $class): ?MetaSchema return $this->schemas[$class] ?? null; } + /** + * The schemas whose meta an object can carry: those of its type covering every + * object, and those listing its subtype. + * + * @return list + */ + public function forObject(MetaObjectType $objectType, ?string $subtype): array + { + return array_values(array_filter( + $this->schemas, + static fn (MetaSchema $schema): bool => $schema->objectType === $objectType + && ($schema->subtypes === [] || ($subtype !== null && in_array($subtype, $schema->subtypes, true))) + )); + } + + /** + * Whether a schema of this object type declares a meta under that property name or key. + */ + public function declares(MetaObjectType $objectType, string $propertyOrKey): bool + { + return isset($this->names[$objectType->value][$propertyOrKey]); + } + /** * @return list */ diff --git a/src/Meta/Infrastructure/Services/MetaDiscovery.php b/src/Meta/Infrastructure/Services/MetaDiscovery.php index dda95fde..729c3397 100644 --- a/src/Meta/Infrastructure/Services/MetaDiscovery.php +++ b/src/Meta/Infrastructure/Services/MetaDiscovery.php @@ -10,6 +10,7 @@ use Pollora\Attributes\Taxonomy; use Pollora\Attributes\TermMeta; use Pollora\Attributes\UserMeta; +use Pollora\Colt\Model\Post as ColtPost; use Pollora\Discovery\Domain\Contracts\DiscoveryInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface; use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface; @@ -18,6 +19,7 @@ use Pollora\Meta\Application\Services\MetaSchemaRepository; use Pollora\Meta\Domain\Contracts\MetaRegistryInterface; use Psr\Log\LoggerInterface; +use ReflectionClass; use Spatie\StructureDiscoverer\Data\DiscoveredClass; use Spatie\StructureDiscoverer\Data\DiscoveredStructure; @@ -25,9 +27,10 @@ * Discovers the `#[Meta]` properties of `#[PostType]`, `#[Taxonomy]`, `#[PostMeta]`, * `#[TermMeta]`, `#[UserMeta]` and `#[CommentMeta]` classes. * - * 1. **discover()** — keeps the classes carrying one of those attributes. + * 1. **discover()** — keeps the classes carrying one of those attributes, and the + * post models (classes extending a Colt post model). * 2. **apply()** — builds each class's schema, stores it for `Meta::of()` and - * queues it for `register_meta()`. + * queues it for `register_meta()`; binds each post model to its `$postType`. * * A declaration that cannot be registered is logged with the class and property * named, and the other classes still register. @@ -51,13 +54,19 @@ public function discover(DiscoveryLocationInterface $location, DiscoveredStructu return; } + $class = $structure->namespace.'\\'.$structure->name; + foreach ($structure->attributes as $attribute) { if (in_array($attribute->class, self::OWNER_ATTRIBUTES, true)) { - $this->getItems()->add($location, ['class' => $structure->namespace.'\\'.$structure->name]); + $this->getItems()->add($location, ['class' => $class]); return; } } + + if ($structure->extends !== null && is_subclass_of($class, ColtPost::class)) { + $this->getItems()->add($location, ['class' => $class, 'model' => true]); + } } public function apply(): void @@ -66,6 +75,12 @@ public function apply(): void /** @var class-string $class */ $class = $item['class']; + if ($item['model'] ?? false) { + $this->registerPostModel($class); + + continue; + } + try { $schema = $this->builder->build($class); @@ -81,6 +96,21 @@ public function apply(): void } } + /** + * Makes `Post::find()` return the model bound to the post type + * (`protected $postType = 'event'`), so its typed meta are there. + * + * @param class-string $class + */ + private function registerPostModel(string $class): void + { + $postType = (new ReflectionClass($class))->getDefaultProperties()['postType'] ?? null; + + if (is_string($postType) && $postType !== '') { + ColtPost::registerPostType($postType, $class); + } + } + public function getIdentifier(): string { return 'meta'; diff --git a/src/Models/Comment.php b/src/Models/Comment.php index ec922eaf..e3c4f1a4 100755 --- a/src/Models/Comment.php +++ b/src/Models/Comment.php @@ -4,7 +4,12 @@ namespace Pollora\Models; +use Pollora\Models\Concerns\HasTypedMeta; + /** * Class Comment */ -class Comment extends \Pollora\Colt\Model\Comment {} +class Comment extends \Pollora\Colt\Model\Comment +{ + use HasTypedMeta; +} diff --git a/src/Models/Concerns/HasTypedMeta.php b/src/Models/Concerns/HasTypedMeta.php new file mode 100644 index 00000000..fc657903 --- /dev/null +++ b/src/Models/Concerns/HasTypedMeta.php @@ -0,0 +1,279 @@ +capacity; // int, or by its key: $event->starts_at + * $event->capacity = 250; // checked now, written on save() + * $event->save(); + * Event::whereMeta('capacity', '>=', 100)->get(); + * + * A model whose class is known to carry typed meta (a post model with the + * `$postType` of a declared schema, users and comments with a `#[UserMeta]` or + * `#[CommentMeta]`) reads through the object cache, primed once per collection, + * and no longer eager loads the `meta` relation. Other models, and keys no + * `#[Meta]` declares, keep Colt's behaviour (`$post->some_key` reads the raw + * value): a project without typed meta sees no change. + * + * @experimental The API may still change before it is declared stable. + */ +trait HasTypedMeta +{ + /** + * @var array> Values waiting for save(), by schema and property + */ + private array $pendingTypedMeta = []; + + /** + * @var array Records already read, by schema + */ + private array $typedMetaRecords = []; + + /** + * Whether the class is known to carry typed meta: the object cache then + * replaces the eager loaded `meta` relation. + */ + private bool $carriesTypedMeta = false; + + /** + * Writes the pending typed meta once the model is saved, when its ID is known. + */ + public static function bootHasTypedMeta(): void + { + static::saved(static function (self $model): void { + $model->saveTypedMeta(); + }); + } + + /** + * The object cache replaces the eager loaded `meta` relation, for a class known + * to carry typed meta. + */ + public function initializeHasTypedMeta(): void + { + $staticSubtype = $this->typedMetaObjectType() === MetaObjectType::Post ? $this->declaredPostType() : null; + + $this->carriesTypedMeta = resolve(MetaSchemaRepository::class)->forObject($this->typedMetaObjectType(), $staticSubtype) !== []; + + if ($this->carriesTypedMeta) { + $this->with = array_values(array_diff($this->with, ['meta'])); + } + } + + /** + * A typed meta, read with its PHP type, or the attribute as Eloquent reads it. + */ + public function getAttribute($key): mixed + { + $schema = $this->typedMetaSchemaFor($key); + + if (! $schema instanceof MetaSchema) { + return parent::getAttribute($key); + } + + $property = $schema->find($key)->property; + + if (array_key_exists($property, $this->pendingTypedMeta[$schema->declaringClass] ?? [])) { + return $this->pendingTypedMeta[$schema->declaringClass][$property]; + } + + if (! $this->exists) { + return $schema->definitions[$property]->default; + } + + return ($this->typedMetaRecords[$schema->declaringClass] ??= resolve(MetaAccessor::class)->record($schema, (int) $this->getKey()))->get($property); + } + + /** + * A typed meta, checked now and written on save(), or the attribute as Eloquent sets it. + */ + public function setAttribute($key, $value): mixed + { + $schema = $this->typedMetaSchemaFor($key); + + if (! $schema instanceof MetaSchema) { + return parent::setAttribute($key, $value); + } + + $definition = $schema->find($key); + resolve(MetaValueCaster::class)->toStorage($definition, $value); + $this->pendingTypedMeta[$schema->declaringClass][$definition->property] = $value; + + return $this; + } + + /** + * Whether a typed meta is waiting to be written. + */ + public function hasPendingTypedMeta(): bool + { + return $this->pendingTypedMeta !== []; + } + + /** + * Writes the pending typed meta through WordPress's meta API. + */ + public function saveTypedMeta(): void + { + foreach ($this->pendingTypedMeta as $class => $values) { + $schema = resolve(MetaSchemaRepository::class)->forClass($class); + + if ($schema instanceof MetaSchema) { + $record = $this->typedMetaRecords[$class] ??= resolve(MetaAccessor::class)->record($schema, (int) $this->getKey()); + $record->fill($values)->save(); + } + } + + $this->pendingTypedMeta = []; + } + + /** + * Primes WordPress's meta cache for the whole collection: one query, whatever + * the number of models. + * + * @param array $models + * @return Collection + */ + public function newCollection(array $models = []) + { + if ($models !== [] && $this->carriesTypedMeta && function_exists('update_meta_cache')) { + update_meta_cache($this->typedMetaObjectType()->value, array_map(static fn (self $model): int => (int) $model->getKey(), $models)); + } + + return parent::newCollection($models); + } + + /** + * Filters on a typed meta, compared as stored: numbers as numbers, dates in + * UTC. A model without the meta is not matched, even if its default would be. + * + * @param Builder $query + * + * @throws InvalidArgumentException When the meta is not declared or the operator is not supported + */ + protected function scopeWhereMeta(Builder $query, string $name, mixed $operator, mixed $value = null): Builder + { + if (func_num_args() === 3) { + [$operator, $value] = ['=', $operator]; + } + + if (! in_array($operator, ['=', '!=', '<>', '<', '<=', '>', '>='], true)) { + throw new InvalidArgumentException(sprintf('whereMeta() does not support the operator "%s".', $operator)); + } + + $definition = $this->typedMetaDefinitionForQuery($name); + + if ($value === null) { + return match ($operator) { + '=' => $query->whereDoesntHave('meta', static fn (Builder $meta): Builder => $meta->where('meta_key', $definition->key)), + '!=', '<>' => $query->whereHas('meta', static fn (Builder $meta): Builder => $meta->where('meta_key', $definition->key)), + default => throw new InvalidArgumentException(sprintf('whereMeta() compares null with "=" or "!=" only, not "%s".', $operator)), + }; + } + + $stored = resolve(MetaValueCaster::class)->toStorage($definition, $value); + + return $query->whereHas('meta', static function (Builder $meta) use ($definition, $operator, $stored): void { + $meta->where('meta_key', $definition->key); + + match ($definition->valueType) { + MetaValueType::Integer => $meta->whereRaw('CAST(meta_value AS SIGNED) '.$operator.' ?', [(int) $stored]), + MetaValueType::Number => $meta->whereRaw('CAST(meta_value AS DECIMAL(65, 10)) '.$operator.' ?', [(float) $stored]), + default => $meta->where('meta_value', $operator, $stored), + }; + }); + } + + /** + * The schema declaring a meta under that name for this object, if any. + */ + private function typedMetaSchemaFor(string $key): ?MetaSchema + { + $schemas = resolve(MetaSchemaRepository::class); + $objectType = $this->typedMetaObjectType(); + + // Cheap test first: every attribute read goes through here. + if (! $schemas->declares($objectType, $key)) { + return null; + } + + foreach ($schemas->forObject($objectType, $this->typedMetaSubtype()) as $schema) { + if ($schema->find($key) instanceof MetaDefinition) { + return $schema; + } + } + + return null; + } + + private function typedMetaDefinitionForQuery(string $name): MetaDefinition + { + $schema = $this->typedMetaSchemaFor($name); + + return $schema?->find($name) ?? throw new InvalidArgumentException(sprintf('%s has no typed meta named "%s".', static::class, $name)); + } + + private function typedMetaObjectType(): MetaObjectType + { + return match (true) { + $this instanceof Post => MetaObjectType::Post, + $this instanceof Term => MetaObjectType::Term, + $this instanceof User => MetaObjectType::User, + $this instanceof Comment => MetaObjectType::Comment, + default => throw new LogicException(sprintf('%s uses HasTypedMeta but is not a post, term, user or comment model.', static::class)), + }; + } + + /** + * The post type a post model is bound to (`protected $postType = 'event'`), if any. + */ + private function declaredPostType(): ?string + { + $postType = get_object_vars($this)['postType'] ?? null; + + return is_string($postType) && $postType !== '' ? $postType : null; + } + + /** + * The post type or taxonomy of the object, read without going through + * getAttribute(), which calls this. + */ + private function typedMetaSubtype(): ?string + { + return match ($this->typedMetaObjectType()) { + MetaObjectType::Post => $this->attributes['post_type'] ?? $this->declaredPostType(), + MetaObjectType::Term => $this->exists ? $this->getRelationValue('taxonomy')?->getAttribute('taxonomy') : null, + default => null, + }; + } +} diff --git a/src/Models/Page.php b/src/Models/Page.php index 2fe62212..3928bba8 100755 --- a/src/Models/Page.php +++ b/src/Models/Page.php @@ -4,7 +4,12 @@ namespace Pollora\Models; +use Pollora\Models\Concerns\HasTypedMeta; + /** * Class Page */ -class Page extends \Pollora\Colt\Model\Page {} +class Page extends \Pollora\Colt\Model\Page +{ + use HasTypedMeta; +} diff --git a/src/Models/Post.php b/src/Models/Post.php index d2be8ff5..bf40585f 100755 --- a/src/Models/Post.php +++ b/src/Models/Post.php @@ -5,6 +5,7 @@ namespace Pollora\Models; use Illuminate\Database\Eloquent\Collection; +use Pollora\Models\Concerns\HasTypedMeta; /** * Class Post @@ -45,6 +46,8 @@ */ class Post extends \Pollora\Colt\Model\Post { + use HasTypedMeta; + /** * Convert the Post instance to a WP_Post object. */ diff --git a/src/Models/Term.php b/src/Models/Term.php index c27e67fc..ed54d8d7 100755 --- a/src/Models/Term.php +++ b/src/Models/Term.php @@ -4,7 +4,12 @@ namespace Pollora\Models; +use Pollora\Models\Concerns\HasTypedMeta; + /** * Class Term. */ -class Term extends \Pollora\Colt\Model\Term {} +class Term extends \Pollora\Colt\Model\Term +{ + use HasTypedMeta; +} diff --git a/src/Models/User.php b/src/Models/User.php index f1656665..8cfe7d75 100755 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -9,6 +9,7 @@ use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Foundation\Auth\Access\Authorizable; use Pollora\Models\Concerns\HasRoles; +use Pollora\Models\Concerns\HasTypedMeta; use Watson\Rememberable\Rememberable; /** @@ -34,6 +35,7 @@ class User extends \Pollora\Colt\Model\User implements AuthenticatableContract, use Authenticatable; use Authorizable; use HasRoles; + use HasTypedMeta; use Rememberable; /** diff --git a/tests/Unit/Meta/Fixtures/EventPostModel.php b/tests/Unit/Meta/Fixtures/EventPostModel.php new file mode 100644 index 00000000..483569bc --- /dev/null +++ b/tests/Unit/Meta/Fixtures/EventPostModel.php @@ -0,0 +1,12 @@ + */ + public array $values = []; + + public function get(MetaObjectType $objectType, int $objectId, string $key): mixed + { + return $this->values["{$objectType->value}:{$objectId}:{$key}"] ?? null; + } + + public function update(MetaObjectType $objectType, int $objectId, string $key, string $value): void + { + $this->values["{$objectType->value}:{$objectId}:{$key}"] = $value; + } + + public function delete(MetaObjectType $objectType, int $objectId, string $key): void + { + unset($this->values["{$objectType->value}:{$objectId}:{$key}"]); + } +} + +/** + * An existing model with the given raw attributes. + * + * @template TModel of Model + * + * @param class-string $class + * @param array $attributes + * @return TModel + */ +function existingModel(string $class, array $attributes): Model +{ + $model = new $class; + $model->setRawAttributes($attributes, true); + $model->exists = true; + + return $model; +} + +beforeEach(function (): void { + $container = Container::getInstance(); + $this->repository = new MetaSchemaRepository; + $this->repository->add((new MetaSchemaBuilder)->build(Event::class)); + $this->repository->add((new MetaSchemaBuilder)->build(MemberProfile::class)); + + $this->store = new InMemoryMetaStore; + $container->instance(MetaSchemaRepository::class, $this->repository); + $container->instance(MetaValueCaster::class, new MetaValueCaster); + $container->instance(MetaAccessor::class, new MetaAccessor($this->repository, new MetaSchemaBuilder, $this->store, new MetaValueCaster)); +}); + +afterEach(function (): void { + $container = Container::getInstance(); + + foreach ([MetaSchemaRepository::class, MetaValueCaster::class, MetaAccessor::class] as $abstract) { + $container->forgetInstance($abstract); + } +}); + +it('reads a declared meta with its PHP type, by property name or key', function (): void { + $this->store->values['post:42:capacity'] = '250'; + $this->store->values['post:42:sold_out'] = '1'; + $event = existingModel(EventModel::class, ['ID' => 42, 'post_type' => 'event']); + + expect($event->capacity)->toBe(250) + ->and($event->soldOut)->toBeTrue() + ->and($event->sold_out)->toBeTrue() + ->and($event->price)->toBe(9.5); +}); + +it('finds the schema from the post_type column, on a generic post model too', function (): void { + $this->store->values['post:7:capacity'] = '12'; + + expect(existingModel(Post::class, ['ID' => 7, 'post_type' => 'event'])->capacity)->toBe(12) + ->and(existingModel(Post::class, ['ID' => 8, 'post_type' => 'page', 'capacity' => 'column'])->capacity)->toBe('column'); +}); + +it('leaves undeclared attributes alone', function (): void { + $event = existingModel(EventModel::class, ['ID' => 42, 'post_type' => 'event', 'post_title' => 'Concert']); + + expect($event->post_title)->toBe('Concert'); +}); + +it('checks a write at once, and writes it through the meta API once the model is saved', function (): void { + $event = existingModel(EventModel::class, ['ID' => 42, 'post_type' => 'event']); + + expect(fn (): string => $event->capacity = 'many')->toThrow(InvalidMetaValueException::class); + + $event->capacity = 300; + + expect($event->capacity)->toBe(300) + ->and($event->hasPendingTypedMeta())->toBeTrue() + ->and($event->getAttributes())->not->toHaveKey('capacity') + ->and($this->store->values)->toBe([]); + + $event->saveTypedMeta(); + + expect($this->store->values)->toBe(['post:42:capacity' => '300']) + ->and($event->hasPendingTypedMeta())->toBeFalse() + ->and($event->capacity)->toBe(300); +}); + +it('reads the default of a model not saved yet', function (): void { + expect((new EventModel)->capacity)->toBe(0); +}); + +it('reads user meta on the user model', function (): void { + $this->store->values['user:3:newsletter_opt_in'] = '1'; + + expect(existingModel(User::class, ['ID' => 3])->newsletterOptIn)->toBeTrue(); +}); + +it('stops eager loading the meta relation for a class known to carry typed meta only', function (): void { + $with = new ReflectionProperty(Model::class, 'with'); + + expect($with->getValue(new EventModel))->not->toContain('meta') + ->and($with->getValue(new Post))->toContain('meta') + ->and($with->getValue(new User))->not->toContain('meta'); +}); + +it('primes the meta cache once for a collection of typed models', function (): void { + Functions\expect('update_meta_cache')->once()->with('post', [1, 2]); + + (new EventModel)->newCollection([existingModel(EventModel::class, ['ID' => 1]), existingModel(EventModel::class, ['ID' => 2])]); +}); + +it('does not prime the cache for models without typed meta', function (): void { + Functions\expect('update_meta_cache')->never(); + + (new Post)->newCollection([existingModel(Post::class, ['ID' => 1])]); +}); + +describe('whereMeta()', function (): void { + beforeEach(function (): void { + // Colt asks the container for its version to tell Laravel from Lumen. + $this->previousContainer = Container::getInstance(); + $container = new class extends Container + { + public function version(): string + { + return '13.0.0'; + } + }; + + foreach ([MetaSchemaRepository::class, MetaValueCaster::class, MetaAccessor::class] as $abstract) { + $container->instance($abstract, $this->previousContainer->make($abstract)); + } + + Container::setInstance($container); + + $connection = new MySqlConnection(fn (): never => throw new LogicException('No query should run.'), 'wordpress', 'wp_'); + $resolver = new ConnectionResolver(['wordpress' => $connection]); + $resolver->setDefaultConnection('wordpress'); + Model::setConnectionResolver($resolver); + }); + + afterEach(function (): void { + Model::unsetConnectionResolver(); + Container::setInstance($this->previousContainer); + }); + + it('compares numbers as numbers', function (): void { + $query = EventModel::query()->whereMeta('capacity', '>=', 100); + + expect($query->toSql())->toContain('CAST(meta_value AS SIGNED) >= ?') + ->and($query->getBindings())->toContain('capacity', 100); + }); + + it('compares other types as stored', function (): void { + $query = EventModel::query()->whereMeta('soldOut', true); + + expect($query->toSql())->toContain('`meta_value` = ?') + ->and($query->getBindings())->toContain('sold_out', '1'); + }); + + it('matches an absent meta with null', function (): void { + expect(EventModel::query()->whereMeta('subtitle', null)->toSql())->toContain('not exists'); + }); + + it('refuses an undeclared meta or an unknown operator', function (): void { + expect(fn () => EventModel::query()->whereMeta('nope', 1))->toThrow(InvalidArgumentException::class, 'has no typed meta named "nope"') + ->and(fn () => EventModel::query()->whereMeta('capacity', 'like', 1))->toThrow(InvalidArgumentException::class, 'does not support the operator "like"'); + }); +}); diff --git a/tests/Unit/Meta/MetaDiscoveryTest.php b/tests/Unit/Meta/MetaDiscoveryTest.php index 93ec5ed5..0de3768a 100644 --- a/tests/Unit/Meta/MetaDiscoveryTest.php +++ b/tests/Unit/Meta/MetaDiscoveryTest.php @@ -3,12 +3,14 @@ declare(strict_types=1); use Pollora\Attributes\Meta; +use Pollora\Colt\Model\Post as ColtPost; use Pollora\Discovery\Domain\Models\DiscoveryLocation; use Pollora\Meta\Application\Services\MetaSchemaBuilder; use Pollora\Meta\Application\Services\MetaSchemaRepository; use Pollora\Meta\Domain\Contracts\MetaRegistryInterface; use Pollora\Meta\Domain\Models\MetaSchema; use Pollora\Meta\Infrastructure\Services\MetaDiscovery; +use Pollora\Models\Page; use Psr\Log\LoggerInterface; use Spatie\StructureDiscoverer\Data\DiscoveredClass; use Spatie\StructureDiscoverer\Data\DiscoveredEnum; @@ -17,6 +19,7 @@ use Tests\Unit\Meta\Fixtures\CategoryExtras; use Tests\Unit\Meta\Fixtures\Event; use Tests\Unit\Meta\Fixtures\EventExtras; +use Tests\Unit\Meta\Fixtures\EventPostModel; use Tests\Unit\Meta\Fixtures\EventStatus; use Tests\Unit\Meta\Fixtures\InvalidArray; use Tests\Unit\Meta\Fixtures\MemberProfile; @@ -76,6 +79,16 @@ abstract class AbstractMetaDeclaration $this->discovery->apply(); }); +it('binds the post models of the project to their post type', function (): void { + ($this->discover)(EventPostModel::class, Page::class); + + $this->discovery->apply(); + + expect((new ReflectionProperty(ColtPost::class, 'postTypes'))->getValue())->toHaveKey('fixture_event', EventPostModel::class); + + ColtPost::clearRegisteredPostTypes(); +}); + it('identifies itself as meta', function (): void { expect($this->discovery->getIdentifier())->toBe('meta'); }); diff --git a/tests/Unit/Meta/MetaSchemaRepositoryTest.php b/tests/Unit/Meta/MetaSchemaRepositoryTest.php index cccdbd19..1d7b94b5 100644 --- a/tests/Unit/Meta/MetaSchemaRepositoryTest.php +++ b/tests/Unit/Meta/MetaSchemaRepositoryTest.php @@ -4,6 +4,7 @@ use Pollora\Meta\Application\Services\MetaSchemaBuilder; use Pollora\Meta\Application\Services\MetaSchemaRepository; +use Pollora\Meta\Domain\Enums\MetaObjectType; use Pollora\Meta\Domain\Exceptions\InvalidMetaDefinitionException; use Tests\Unit\Meta\Fixtures\ArticleExtras; use Tests\Unit\Meta\Fixtures\BillingProfile; @@ -58,3 +59,19 @@ expect($repository->all())->toHaveCount(4); }); + +it('finds the schemas an object can carry, and whether a name is declared', function (): void { + $repository = new MetaSchemaRepository; + $repository->add($event = (new MetaSchemaBuilder)->build(Event::class)); + $repository->add($article = (new MetaSchemaBuilder)->build(ArticleExtras::class)); + $repository->add($member = (new MetaSchemaBuilder)->build(MemberProfile::class)); + + expect($repository->forObject(MetaObjectType::Post, 'event'))->toBe([$event]) + ->and($repository->forObject(MetaObjectType::Post, 'page'))->toBe([$article]) + ->and($repository->forObject(MetaObjectType::Post, null))->toBe([]) + ->and($repository->forObject(MetaObjectType::User, null))->toBe([$member]) + ->and($repository->declares(MetaObjectType::Post, 'soldOut'))->toBeTrue() + ->and($repository->declares(MetaObjectType::Post, 'sold_out'))->toBeTrue() + ->and($repository->declares(MetaObjectType::User, 'sold_out'))->toBeFalse() + ->and($repository->declares(MetaObjectType::Post, 'post_title'))->toBeFalse(); +}); From 8a3ffb26a1e9ea9c02357834e627a8ff6b0c5a18 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Mon, 5 Oct 2026 17:24:29 +0200 Subject: [PATCH 2/2] fix(meta): prime the meta cache on the first typed read instead of overriding newCollection() Overriding newCollection() could not keep Eloquent's generic return type (PHPStan in CI). Models loaded together now join a batch on 'retrieved', primed in one update_meta_cache() call on the first typed read. --- src/Models/Concerns/HasTypedMeta.php | 50 ++++++++++++++++--------- tests/Unit/Meta/HasTypedMetaTest.php | 56 +++++++++++++++------------- 2 files changed, 62 insertions(+), 44 deletions(-) diff --git a/src/Models/Concerns/HasTypedMeta.php b/src/Models/Concerns/HasTypedMeta.php index fc657903..e992fe9f 100644 --- a/src/Models/Concerns/HasTypedMeta.php +++ b/src/Models/Concerns/HasTypedMeta.php @@ -40,8 +40,9 @@ * * A model whose class is known to carry typed meta (a post model with the * `$postType` of a declared schema, users and comments with a `#[UserMeta]` or - * `#[CommentMeta]`) reads through the object cache, primed once per collection, - * and no longer eager loads the `meta` relation. Other models, and keys no + * `#[CommentMeta]`) reads through the object cache, primed in one query for + * all the models loaded together, and no longer eager loads the `meta` + * relation. Other models, and keys no * `#[Meta]` declares, keep Colt's behaviour (`$post->some_key` reads the raw * value): a project without typed meta sees no change. * @@ -65,6 +66,11 @@ trait HasTypedMeta */ private bool $carriesTypedMeta = false; + /** + * @var array IDs of the models loaded since the meta cache was last primed + */ + private static array $typedMetaBatch = []; + /** * Writes the pending typed meta once the model is saved, when its ID is known. */ @@ -73,6 +79,13 @@ public static function bootHasTypedMeta(): void static::saved(static function (self $model): void { $model->saveTypedMeta(); }); + + // Models loaded together are primed together, on the first typed read. + static::retrieved(static function (self $model): void { + if ($model->carriesTypedMeta) { + self::$typedMetaBatch[(int) $model->getKey()] = true; + } + }); } /** @@ -111,6 +124,8 @@ public function getAttribute($key): mixed return $schema->definitions[$property]->default; } + $this->primeTypedMetaCache(); + return ($this->typedMetaRecords[$schema->declaringClass] ??= resolve(MetaAccessor::class)->record($schema, (int) $this->getKey()))->get($property); } @@ -157,22 +172,6 @@ public function saveTypedMeta(): void $this->pendingTypedMeta = []; } - /** - * Primes WordPress's meta cache for the whole collection: one query, whatever - * the number of models. - * - * @param array $models - * @return Collection - */ - public function newCollection(array $models = []) - { - if ($models !== [] && $this->carriesTypedMeta && function_exists('update_meta_cache')) { - update_meta_cache($this->typedMetaObjectType()->value, array_map(static fn (self $model): int => (int) $model->getKey(), $models)); - } - - return parent::newCollection($models); - } - /** * Filters on a typed meta, compared as stored: numbers as numbers, dates in * UTC. A model without the meta is not matched, even if its default would be. @@ -214,6 +213,21 @@ protected function scopeWhereMeta(Builder $query, string $name, mixed $operator, }); } + /** + * Loads the meta of every model loaded since the last typed read in one query, + * so reading a meta across a collection costs one query, not one per model. + */ + private function primeTypedMetaCache(): void + { + if (self::$typedMetaBatch === [] || ! function_exists('update_meta_cache')) { + return; + } + + $ids = array_keys(self::$typedMetaBatch); + self::$typedMetaBatch = []; + update_meta_cache($this->typedMetaObjectType()->value, $ids); + } + /** * The schema declaring a meta under that name for this object, if any. */ diff --git a/tests/Unit/Meta/HasTypedMetaTest.php b/tests/Unit/Meta/HasTypedMetaTest.php index 1f5d5744..721665ba 100644 --- a/tests/Unit/Meta/HasTypedMetaTest.php +++ b/tests/Unit/Meta/HasTypedMetaTest.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Attributes\Connection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\MySqlConnection; +use Illuminate\Events\Dispatcher; use Pollora\Meta\Application\Services\MetaAccessor; use Pollora\Meta\Application\Services\MetaSchemaBuilder; use Pollora\Meta\Application\Services\MetaSchemaRepository; @@ -72,7 +73,17 @@ function existingModel(string $class, array $attributes): Model } beforeEach(function (): void { - $container = Container::getInstance(); + // Colt asks the container for its version to tell Laravel from Lumen. + $this->previousContainer = Container::getInstance(); + $container = new class extends Container + { + public function version(): string + { + return '13.0.0'; + } + }; + Container::setInstance($container); + $this->repository = new MetaSchemaRepository; $this->repository->add((new MetaSchemaBuilder)->build(Event::class)); $this->repository->add((new MetaSchemaBuilder)->build(MemberProfile::class)); @@ -84,11 +95,7 @@ function existingModel(string $class, array $attributes): Model }); afterEach(function (): void { - $container = Container::getInstance(); - - foreach ([MetaSchemaRepository::class, MetaValueCaster::class, MetaAccessor::class] as $abstract) { - $container->forgetInstance($abstract); - } + Container::setInstance($this->previousContainer); }); it('reads a declared meta with its PHP type, by property name or key', function (): void { @@ -152,36 +159,34 @@ function existingModel(string $class, array $attributes): Model ->and($with->getValue(new User))->not->toContain('meta'); }); -it('primes the meta cache once for a collection of typed models', function (): void { +it('primes the meta cache once for the models loaded together, on the first typed read', function (): void { + Model::clearBootedModels(); + Model::setEventDispatcher(new Dispatcher); + $this->store->values['post:1:capacity'] = '10'; + $this->store->values['post:2:capacity'] = '20'; Functions\expect('update_meta_cache')->once()->with('post', [1, 2]); - (new EventModel)->newCollection([existingModel(EventModel::class, ['ID' => 1]), existingModel(EventModel::class, ['ID' => 2])]); + [$first, $second] = [(new EventModel)->newFromBuilder(['ID' => 1, 'post_type' => 'event']), (new EventModel)->newFromBuilder(['ID' => 2, 'post_type' => 'event'])]; + + expect($first->capacity + $second->capacity)->toBe(30); + + Model::unsetEventDispatcher(); + Model::clearBootedModels(); }); it('does not prime the cache for models without typed meta', function (): void { + Model::clearBootedModels(); + Model::setEventDispatcher(new Dispatcher); Functions\expect('update_meta_cache')->never(); - (new Post)->newCollection([existingModel(Post::class, ['ID' => 1])]); + (new Post)->newFromBuilder(['ID' => 1, 'post_type' => 'page', 'post_title' => 'About'])->post_title; + + Model::unsetEventDispatcher(); + Model::clearBootedModels(); }); describe('whereMeta()', function (): void { beforeEach(function (): void { - // Colt asks the container for its version to tell Laravel from Lumen. - $this->previousContainer = Container::getInstance(); - $container = new class extends Container - { - public function version(): string - { - return '13.0.0'; - } - }; - - foreach ([MetaSchemaRepository::class, MetaValueCaster::class, MetaAccessor::class] as $abstract) { - $container->instance($abstract, $this->previousContainer->make($abstract)); - } - - Container::setInstance($container); - $connection = new MySqlConnection(fn (): never => throw new LogicException('No query should run.'), 'wordpress', 'wp_'); $resolver = new ConnectionResolver(['wordpress' => $connection]); $resolver->setDefaultConnection('wordpress'); @@ -190,7 +195,6 @@ public function version(): string afterEach(function (): void { Model::unsetConnectionResolver(); - Container::setInstance($this->previousContainer); }); it('compares numbers as numbers', function (): void {