From ef33f8de5d57f320bca023007ed1e1c45e889975 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Mon, 5 Oct 2026 16:45:45 +0200 Subject: [PATCH 01/13] feat(roles): check roles in Laravel and capabilities in REST routes HasRoles on Pollora\Models\User (roles, hasRole, assignRole, removeRole), the role: middleware, @role accepting #[Role] classes in place of Sage's, and the Can permission for #[WpRestRoute], which now accepts Permission instances. --- CHANGELOG.md | 4 + src/Attributes/Attributable.php | 4 +- src/Attributes/WpRestRoute.php | 5 +- src/Attributes/WpRestRoute/Method.php | 12 +-- src/Models/Concerns/HasRoles.php | 78 +++++++++++++++++++ src/Models/User.php | 5 +- .../Application/Services/RoleReference.php | 68 ++++++++++++++++ .../Middleware/EnsureUserHasRole.php | 46 +++++++++++ .../Providers/RoleServiceProvider.php | 22 +++++- src/Role/UI/View/RoleDirective.php | 26 +++++++ .../Services/WpRestAttributableWrapper.php | 3 +- src/WpRest/Permissions/Can.php | 43 ++++++++++ tests/Unit/Role/EnsureUserHasRoleTest.php | 46 +++++++++++ tests/Unit/Role/Fixtures/role-users.php | 54 +++++++++++++ tests/Unit/Role/HasRolesTest.php | 53 +++++++++++++ tests/Unit/Role/RoleDirectiveTest.php | 37 +++++++++ tests/Unit/Role/RoleReferenceTest.php | 29 +++++++ tests/Unit/Role/RoleServiceProviderTest.php | 37 +++++++++ tests/Unit/WpRest/CanPermissionTest.php | 45 +++++++++++ 19 files changed, 605 insertions(+), 12 deletions(-) create mode 100644 src/Models/Concerns/HasRoles.php create mode 100644 src/Role/Application/Services/RoleReference.php create mode 100644 src/Role/Infrastructure/Middleware/EnsureUserHasRole.php create mode 100644 src/Role/UI/View/RoleDirective.php create mode 100644 src/WpRest/Permissions/Can.php create mode 100644 tests/Unit/Role/EnsureUserHasRoleTest.php create mode 100644 tests/Unit/Role/Fixtures/role-users.php create mode 100644 tests/Unit/Role/HasRolesTest.php create mode 100644 tests/Unit/Role/RoleDirectiveTest.php create mode 100644 tests/Unit/Role/RoleReferenceTest.php create mode 100644 tests/Unit/WpRest/CanPermissionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 62def616..7f71f495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.4...develop) +### Added +- 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 + ## [v13.34.4](https://github.com/Pollora/framework/compare/v13.34.3...v13.34.4) - 2026-10-05 ### Added diff --git a/src/Attributes/Attributable.php b/src/Attributes/Attributable.php index 19ccd998..942df62d 100755 --- a/src/Attributes/Attributable.php +++ b/src/Attributes/Attributable.php @@ -4,13 +4,15 @@ namespace Pollora\Attributes; +use Pollora\Attributes\WpRestRoute\Permission; + /** * Marker interface that allows a class to be interpreted for PHP attributes. * * Classes implementing this interface can be processed by discovery services * to analyze and handle their attributes dynamically. * - * @property string|null $classPermission + * @property class-string|Permission|null $classPermission * @property string $namespace * @property string $route */ diff --git a/src/Attributes/WpRestRoute.php b/src/Attributes/WpRestRoute.php index da7d3abc..d3836ceb 100755 --- a/src/Attributes/WpRestRoute.php +++ b/src/Attributes/WpRestRoute.php @@ -6,6 +6,7 @@ use Attribute; use Pollora\Attributes\Contracts\HandlesAttributes; +use Pollora\Attributes\WpRestRoute\Permission; use ReflectionClass; use ReflectionMethod; @@ -20,12 +21,12 @@ class WpRestRoute implements HandlesAttributes * * @param string $namespace The namespace for the REST API route (e.g., "my-plugin/v1"). * @param string $route The specific route within the namespace (e.g., "/items"). - * @param string|null $permissionCallback Optional callback method name to check permissions for the route. + * @param class-string|Permission|null $permissionCallback The permission for the route: a Permission class, or an instance such as `new Can('edit_posts')`. */ public function __construct( public readonly string $namespace, public readonly string $route, - public readonly ?string $permissionCallback = null + public readonly string|Permission|null $permissionCallback = null ) {} /** diff --git a/src/Attributes/WpRestRoute/Method.php b/src/Attributes/WpRestRoute/Method.php index c72d1181..a43417bb 100755 --- a/src/Attributes/WpRestRoute/Method.php +++ b/src/Attributes/WpRestRoute/Method.php @@ -23,13 +23,13 @@ class Method implements HandlesAttributes * Constructor for the Method attribute. * * @param array|string $methods The HTTP methods allowed for this route. - * @param string|null $permissionCallback The callback function to check permissions for the route. + * @param class-string|Permission|null $permissionCallback The permission for this method, replacing the route's: a Permission class, or an instance such as `new Can('edit_posts')`. * * @throws InvalidArgumentException If an invalid HTTP method is provided. */ public function __construct( public array|string $methods, - public ?string $permissionCallback = null + public string|Permission|null $permissionCallback = null ) { $this->methods = is_array($methods) ? $methods : [$methods]; $this->validateMethods(); @@ -161,21 +161,21 @@ private function extractArgsFromRoute(string $route): array /** * Resolves and executes the permission callback. * - * @param string|null $permissionCallback The permission class to use + * @param class-string|Permission|null $permissionCallback The permission class or instance to use * @return callable The permission function */ - private function resolvePermissionCallback(?string $permissionCallback): callable + private function resolvePermissionCallback(string|Permission|null $permissionCallback): callable { if ($permissionCallback === null) { return '__return_true'; } - if (! class_exists($permissionCallback) || ! is_subclass_of($permissionCallback, Permission::class)) { + if (is_string($permissionCallback) && (! class_exists($permissionCallback) || ! is_subclass_of($permissionCallback, Permission::class))) { return fn (): WP_Error => new WP_Error('rest_forbidden', __('Invalid permission handler.'), ['status' => 403]); } return WpGlobals::wrap(function (WP_REST_Request $request) use ($permissionCallback): bool|WP_Error { - $permissionInstance = new $permissionCallback; + $permissionInstance = is_string($permissionCallback) ? new $permissionCallback : $permissionCallback; return $permissionInstance->allow($request); }); diff --git a/src/Models/Concerns/HasRoles.php b/src/Models/Concerns/HasRoles.php new file mode 100644 index 00000000..5904b282 --- /dev/null +++ b/src/Models/Concerns/HasRoles.php @@ -0,0 +1,78 @@ + + */ + public function roles(): array + { + return array_values($this->toWpUser()->roles); + } + + /** + * `$user->roles`, which Eloquent would otherwise read as a relation. + * + * @return list + */ + protected function getRolesAttribute(): array + { + return $this->roles(); + } + + /** + * Whether the user has at least one of the roles. + */ + public function hasRole(string ...$roles): bool + { + return RoleReference::matchesAny($this->roles(), $roles); + } + + /** + * Adds a role, keeping the ones the user already has. + * + * @throws InvalidArgumentException When WordPress does not know the role + */ + public function assignRole(string $role): static + { + $slug = RoleReference::slug($role); + + if (! wp_roles()->is_role($slug)) { + throw new InvalidArgumentException(sprintf('The role "%s" does not exist.', $slug)); + } + + $this->toWpUser()->add_role($slug); + + return $this; + } + + /** + * Removes a role, including one that no longer exists. + */ + public function removeRole(string $role): static + { + $this->toWpUser()->remove_role(RoleReference::slug($role)); + + return $this; + } +} diff --git a/src/Models/User.php b/src/Models/User.php index 181c6940..f1656665 100755 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -8,6 +8,7 @@ use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Foundation\Auth\Access\Authorizable; +use Pollora\Models\Concerns\HasRoles; use Watson\Rememberable\Rememberable; /** @@ -25,12 +26,14 @@ * @property string $display_name * * `can()` and `cannot()` answer with WordPress capabilities through the Gate: - * `$user->can('edit_posts')`, `$user->can('edit_post', $post)`. + * `$user->can('edit_posts')`, `$user->can('edit_post', $post)`. Roles: + * `hasRole()`, `assignRole()`, `removeRole()`, `roles()`. */ class User extends \Pollora\Colt\Model\User implements AuthenticatableContract, AuthorizableContract { use Authenticatable; use Authorizable; + use HasRoles; use Rememberable; /** diff --git a/src/Role/Application/Services/RoleReference.php b/src/Role/Application/Services/RoleReference.php new file mode 100644 index 00000000..369f085f --- /dev/null +++ b/src/Role/Application/Services/RoleReference.php @@ -0,0 +1,68 @@ + */ + private static array $slugs = []; + + /** + * @throws InvalidArgumentException When a class is given that carries neither #[Role] nor #[ModifyRole] + */ + public static function slug(string $role): string + { + if (! class_exists($role)) { + return $role; + } + + return self::$slugs[$role] ??= self::slugOfClass($role); + } + + /** + * Whether one of the roles a user has is among the given ones, ignoring case + * as `@role` always has. + * + * @param array $userRoles The slugs the user has + * @param array $roles Slugs or role classes + */ + public static function matchesAny(array $userRoles, array $roles): bool + { + $userRoles = array_map(strtolower(...), $userRoles); + + foreach ($roles as $role) { + if (in_array(strtolower(self::slug($role)), $userRoles, true)) { + return true; + } + } + + return false; + } + + /** + * @param class-string $class + */ + private static function slugOfClass(string $class): string + { + $reflection = new ReflectionClass($class); + $attribute = ($reflection->getAttributes(Role::class)[0] ?? $reflection->getAttributes(ModifyRole::class)[0] ?? null)?->newInstance(); + + if (! $attribute instanceof Role && ! $attribute instanceof ModifyRole) { + throw new InvalidArgumentException(sprintf('%s does not name a role: it carries neither #[Role] nor #[ModifyRole].', $class)); + } + + return $attribute->slug; + } +} diff --git a/src/Role/Infrastructure/Middleware/EnsureUserHasRole.php b/src/Role/Infrastructure/Middleware/EnsureUserHasRole.php new file mode 100644 index 00000000..9a512e8d --- /dev/null +++ b/src/Role/Infrastructure/Middleware/EnsureUserHasRole.php @@ -0,0 +1,46 @@ +group(…); + * Route::middleware(EnsureUserHasRole::using(EventManager::class))->group(…); + * + * Prefer `can:` with a capability: it stays right when a second role is given + * that capability. + */ +class EnsureUserHasRole +{ + /** + * The middleware string for the given roles, slugs or `#[Role]` classes. + */ + public static function using(string ...$roles): string + { + return static::class.':'.implode(',', $roles); + } + + /** + * @param Closure(Request): Response $next + * + * @throws AuthorizationException When the user is a guest or has none of the roles + */ + public function handle(Request $request, Closure $next, string ...$roles): Response + { + $user = $request->user(); + + if (! is_object($user) || ! method_exists($user, 'hasRole') || ! $user->hasRole(...$roles)) { + throw new AuthorizationException; + } + + return $next($request); + } +} diff --git a/src/Role/Infrastructure/Providers/RoleServiceProvider.php b/src/Role/Infrastructure/Providers/RoleServiceProvider.php index 2e868963..ba652ce3 100644 --- a/src/Role/Infrastructure/Providers/RoleServiceProvider.php +++ b/src/Role/Infrastructure/Providers/RoleServiceProvider.php @@ -5,7 +5,9 @@ namespace Pollora\Role\Infrastructure\Providers; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Routing\Router; use Illuminate\Support\ServiceProvider; +use Illuminate\View\Compilers\BladeCompiler; use Pollora\Hook\Domain\Contract\Action; use Pollora\Role\Application\Services\CapabilityOwnerReader; use Pollora\Role\Application\Services\RoleDefinitionBuilder; @@ -13,13 +15,16 @@ use Pollora\Role\Domain\Services\PostTypeCapabilityMap; use Pollora\Role\Domain\Services\RoleCompiler; use Pollora\Role\Infrastructure\Adapters\WordPressRoleInjector; +use Pollora\Role\Infrastructure\Middleware\EnsureUserHasRole; use Pollora\Role\Infrastructure\Services\RoleDiscovery; use Pollora\Role\UI\Console\RoleMakeCommand; +use Pollora\Role\UI\View\RoleDirective; use Psr\Log\LoggerInterface; /** * Roles declared in code: `#[Role]`, `#[ModifyRole]`, `#[CapabilitySet]`, - * injected into WordPress on `wp_roles_init`. + * injected into WordPress on `wp_roles_init`; the `role:` middleware and the + * `@role` directive. * * Must boot before WordPress loads (before the WordPress service provider), so * that the injector is subscribed before the first `WP_Roles` exists. @@ -67,6 +72,21 @@ public function boot(): void // Early priority: listeners after it see the roles as the code declares them. $action->add('wp_roles_init', $injector->inject(...), 1); $action->add('init', $injector->reportWarnings(...), PHP_INT_MAX); + + // An alias the application already gives to another middleware is kept. + $this->callAfterResolving('router', static function (Router $router): void { + if (! array_key_exists('role', $router->getMiddleware())) { + $router->aliasMiddleware('role', EnsureUserHasRole::class); + } + }); + + // Once every provider has booted, to replace the @role of Sage Directives. + $this->app->booted(function (): void { + $this->callAfterResolving('blade.compiler', static function (BladeCompiler $blade): void { + $blade->directive('role', new RoleDirective); + $blade->directive('endrole', static fn (): string => ''); + }); + }); } /** diff --git a/src/Role/UI/View/RoleDirective.php b/src/Role/UI/View/RoleDirective.php new file mode 100644 index 00000000..381044ff --- /dev/null +++ b/src/Role/UI/View/RoleDirective.php @@ -0,0 +1,26 @@ +roles, [%s])) : ?>', + RoleReference::class, + $expression + ); + } +} diff --git a/src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php b/src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php index b2ae38a6..cca261ca 100644 --- a/src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php +++ b/src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php @@ -5,6 +5,7 @@ namespace Pollora\WpRest\Infrastructure\Services; use Pollora\Attributes\Attributable; +use Pollora\Attributes\WpRestRoute\Permission; use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface; use Psr\Log\LoggerInterface; @@ -22,7 +23,7 @@ public function __construct( private string $className, public string $namespace, public string $route, - public ?string $classPermission = null, + public string|Permission|null $classPermission = null, private ?ReflectionCacheInterface $reflectionCache = null ) { $this->realInstance = $this->createRealInstance(); diff --git a/src/WpRest/Permissions/Can.php b/src/WpRest/Permissions/Can.php new file mode 100644 index 00000000..ef9418dd --- /dev/null +++ b/src/WpRest/Permissions/Can.php @@ -0,0 +1,43 @@ +capability instanceof BackedEnum ? (string) $this->capability->value : $this->capability; + $arguments = $this->parameter === null ? [] : [$request->get_param($this->parameter)]; + + return current_user_can($capability, ...$arguments) ?: new WP_Error( + 'rest_forbidden', + __('You do not have permission to access this endpoint.'), + ['status' => rest_authorization_required_code()] + ); + } +} diff --git a/tests/Unit/Role/EnsureUserHasRoleTest.php b/tests/Unit/Role/EnsureUserHasRoleTest.php new file mode 100644 index 00000000..382d68c7 --- /dev/null +++ b/tests/Unit/Role/EnsureUserHasRoleTest.php @@ -0,0 +1,46 @@ +setUserResolver(fn (): mixed => $user); + + return (new EnsureUserHasRole)->handle($request, fn (): Response => new Response('passed'), ...$roles); +} + +it('lets through a user who has one of the roles', function (): void { + $user = userWithWpUser(wpUserWithRoles(['event_manager'])); + + expect(throughRoleMiddleware($user, 'editor', EventManager::class)->getContent())->toBe('passed'); +}); + +it('refuses a user who has none of the roles', function (): void { + throughRoleMiddleware(userWithWpUser(wpUserWithRoles(['subscriber'])), 'editor'); +})->throws(AuthorizationException::class); + +it('refuses a guest', function (): void { + throughRoleMiddleware(null, 'editor'); +})->throws(AuthorizationException::class); + +it('refuses a user model without roles', function (): void { + throughRoleMiddleware(Mockery::mock(Authenticatable::class), 'editor'); +})->throws(AuthorizationException::class); + +it('builds the middleware string for slugs and role classes', function (): void { + expect(EnsureUserHasRole::using('editor', EventManager::class)) + ->toBe(EnsureUserHasRole::class.':editor,'.EventManager::class); +}); diff --git a/tests/Unit/Role/Fixtures/role-users.php b/tests/Unit/Role/Fixtures/role-users.php new file mode 100644 index 00000000..546ad66d --- /dev/null +++ b/tests/Unit/Role/Fixtures/role-users.php @@ -0,0 +1,54 @@ + $roles + */ +function wpUserWithRoles(array $roles): WP_User +{ + return new class($roles) extends WP_User + { + /** @param list $roles */ + public function __construct(public array $roles) {} + + public function add_role(string $role): void + { + $this->roles[] = $role; + } + + public function remove_role(string $role): void + { + $this->roles = array_values(array_diff($this->roles, [$role])); + } + }; +} + +/** + * A user model whose WP_User is the given one. + */ +function userWithWpUser(WP_User $wpUser): User +{ + $user = new class extends User + { + public WP_User $wpUser; + + public function toWpUser(): WP_User + { + return $this->wpUser; + } + }; + $user->wpUser = $wpUser; + + return $user; +} diff --git a/tests/Unit/Role/HasRolesTest.php b/tests/Unit/Role/HasRolesTest.php new file mode 100644 index 00000000..ed48ada1 --- /dev/null +++ b/tests/Unit/Role/HasRolesTest.php @@ -0,0 +1,53 @@ +roles())->toBe(['author', 'event_manager']) + ->and($user->roles)->toBe(['author', 'event_manager']); +}); + +it('tells whether the user has one of the roles, by slug or class', function (): void { + $user = userWithWpUser(wpUserWithRoles(['event_manager'])); + + expect($user->hasRole(EventManager::class))->toBeTrue() + ->and($user->hasRole('editor', 'event_manager'))->toBeTrue() + ->and($user->hasRole('editor'))->toBeFalse(); +}); + +it('assigns a role WordPress knows, through WP_User', function (): void { + $roles = Mockery::mock(); + $roles->shouldReceive('is_role')->once()->with('event_manager')->andReturn(true); + Functions\when('wp_roles')->justReturn($roles); + $wpUser = wpUserWithRoles(['subscriber']); + + expect(userWithWpUser($wpUser)->assignRole(EventManager::class))->toBeInstanceOf(User::class) + ->and($wpUser->roles)->toBe(['subscriber', 'event_manager']); +}); + +it('refuses to assign a role WordPress does not know', function (): void { + $roles = Mockery::mock(); + $roles->shouldReceive('is_role')->with('ghost')->andReturn(false); + Functions\when('wp_roles')->justReturn($roles); + $wpUser = wpUserWithRoles(['subscriber']); + + expect(fn (): User => userWithWpUser($wpUser)->assignRole('ghost'))->toThrow(InvalidArgumentException::class, 'The role "ghost" does not exist.') + ->and($wpUser->roles)->toBe(['subscriber']); +}); + +it('removes a role, by slug or class', function (): void { + $wpUser = wpUserWithRoles(['subscriber', 'event_manager', 'retired']); + $user = userWithWpUser($wpUser); + + $user->removeRole(EventManager::class)->removeRole('retired'); + + expect($wpUser->roles)->toBe(['subscriber']); +}); diff --git a/tests/Unit/Role/RoleDirectiveTest.php b/tests/Unit/Role/RoleDirectiveTest.php new file mode 100644 index 00000000..191956dc --- /dev/null +++ b/tests/Unit/Role/RoleDirectiveTest.php @@ -0,0 +1,37 @@ +|null $roles null for a guest + */ +function renderRole(string $expression, ?array $roles): string +{ + Functions\when('is_user_logged_in')->justReturn($roles !== null); + Functions\when('wp_get_current_user')->justReturn((object) ['roles' => $roles ?? []]); + + ob_start(); + eval('?>'.(new RoleDirective)($expression).'shown'); + + return (string) ob_get_clean(); +} + +it('shows its content for one of the slugs, ignoring case, as Sage did', function (): void { + expect(renderRole("'editor', 'author'", ['author']))->toBe('shown') + ->and(renderRole("'Editor'", ['editor']))->toBe('shown') + ->and(renderRole("'editor'", ['subscriber']))->toBe(''); +}); + +it('accepts the class of a #[Role]', function (): void { + expect(renderRole('\\'.EventManager::class."::class, 'editor'", ['event_manager']))->toBe('shown'); +}); + +it('hides its content from a guest', function (): void { + expect(renderRole("'editor'", null))->toBe(''); +}); diff --git a/tests/Unit/Role/RoleReferenceTest.php b/tests/Unit/Role/RoleReferenceTest.php new file mode 100644 index 00000000..8b7c2a47 --- /dev/null +++ b/tests/Unit/Role/RoleReferenceTest.php @@ -0,0 +1,29 @@ +toBe('editor'); +}); + +it('reads the slug of a #[Role] or #[ModifyRole] class', function (): void { + expect(RoleReference::slug(EventManager::class))->toBe('event_manager') + ->and(RoleReference::slug(EditorAdjustments::class))->toBe('editor'); +}); + +it('refuses a class that names no role', function (): void { + RoleReference::slug(Venue::class); +})->throws(InvalidArgumentException::class, Venue::class.' does not name a role'); + +it('matches when the user has one of the roles, ignoring case', function (): void { + expect(RoleReference::matchesAny(['author', 'event_manager'], ['editor', EventManager::class]))->toBeTrue() + ->and(RoleReference::matchesAny(['Editor'], ['EDITOR']))->toBeTrue() + ->and(RoleReference::matchesAny(['author'], ['editor', EventManager::class]))->toBeFalse() + ->and(RoleReference::matchesAny([], ['editor']))->toBeFalse() + ->and(RoleReference::matchesAny(['editor'], []))->toBeFalse(); +}); diff --git a/tests/Unit/Role/RoleServiceProviderTest.php b/tests/Unit/Role/RoleServiceProviderTest.php index 66f09f75..dbda25f0 100644 --- a/tests/Unit/Role/RoleServiceProviderTest.php +++ b/tests/Unit/Role/RoleServiceProviderTest.php @@ -3,14 +3,19 @@ declare(strict_types=1); use Illuminate\Config\Repository; +use Illuminate\Events\Dispatcher; use Illuminate\Filesystem\Filesystem; use Illuminate\Foundation\Application; +use Illuminate\Routing\Router; +use Illuminate\View\Compilers\BladeCompiler; use Pollora\Hook\Domain\Contract\Action; use Pollora\Role\Application\Services\RoleDefinitionBuilder; use Pollora\Role\Infrastructure\Adapters\WordPressRoleInjector; +use Pollora\Role\Infrastructure\Middleware\EnsureUserHasRole; use Pollora\Role\Infrastructure\Providers\RoleServiceProvider; use Pollora\Role\Infrastructure\Services\RoleDiscovery; use Pollora\Role\UI\Console\RoleMakeCommand; +use Pollora\Role\UI\View\RoleDirective; use Psr\Log\LoggerInterface; beforeEach(function (): void { @@ -47,3 +52,35 @@ ->toContain('final class EventManager') ->and($command->getName())->toBe('pollora:make:role'); }); + +it('aliases the role middleware, unless the application already uses the alias', function (): void { + $this->action->shouldReceive('add')->andReturnSelf(); + $router = new Router(new Dispatcher, $this->app); + $this->app->instance('router', $router); + + $this->provider->boot(); + + expect($router->getMiddleware()['role'])->toBe(EnsureUserHasRole::class); + + $router->aliasMiddleware('role', 'App\\Http\\Middleware\\Role'); + $this->provider->boot(); + + expect($router->getMiddleware()['role'])->toBe('App\\Http\\Middleware\\Role'); +}); + +it('replaces the @role directive once every provider has booted', function (): void { + $this->action->shouldReceive('add')->andReturnSelf(); + $blade = new BladeCompiler(new Filesystem, sys_get_temp_dir()); + $blade->directive('role', fn (): string => 'sage'); + + $this->app->instance('blade.compiler', $blade); + + $this->provider->boot(); + + expect($blade->getCustomDirectives()['role']('x'))->toBe('sage'); + + $this->app->boot(); + + expect($blade->getCustomDirectives()['role'])->toBeInstanceOf(RoleDirective::class) + ->and($blade->compileString("@role('editor') yes @endrole"))->toContain('matchesAny')->toContain(''); +}); diff --git a/tests/Unit/WpRest/CanPermissionTest.php b/tests/Unit/WpRest/CanPermissionTest.php new file mode 100644 index 00000000..e2c2b1e9 --- /dev/null +++ b/tests/Unit/WpRest/CanPermissionTest.php @@ -0,0 +1,45 @@ +justReturn(401); +}); + +it('allows a user who has the capability', function (): void { + Functions\expect('current_user_can')->once()->with('export_attendees')->andReturn(true); + + expect((new Can(EventCap::ExportAttendees))->allow(Mockery::mock(WP_REST_Request::class)))->toBeTrue(); +}); + +it('passes the value of a request parameter, for a meta capability', function (): void { + $request = Mockery::mock(WP_REST_Request::class); + $request->shouldReceive('get_param')->with('id')->andReturn('42'); + Functions\expect('current_user_can')->once()->with('edit_post', '42')->andReturn(true); + + expect((new Can('edit_post', parameter: 'id'))->allow($request))->toBeTrue(); +}); + +it("refuses with WordPress's authorization status", function (): void { + Functions\when('current_user_can')->justReturn(false); + + expect((new Can('manage_options'))->allow(Mockery::mock(WP_REST_Request::class)))->toBeInstanceOf(WP_Error::class); +}); + +it('is accepted as an instance by #[Method] and #[WpRestRoute]', function (): void { + Functions\when('current_user_can')->justReturn(true); + $resolve = new ReflectionMethod(Method::class, 'resolvePermissionCallback'); + + $callback = $resolve->invoke(new Method('GET', permissionCallback: new Can('edit_posts')), new Can('edit_posts')); + + expect($callback(Mockery::mock(WP_REST_Request::class)))->toBeTrue(); +}); From f9a1fa378c2f276e56ac0e8fbe9fbe761e3411d8 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Mon, 5 Oct 2026 16:51:17 +0200 Subject: [PATCH 02/13] feat(roles): translate role labels with the declaring text domain --- CHANGELOG.md | 3 ++ src/Attributes/Role.php | 2 ++ .../Services/RoleDefinitionBuilder.php | 1 + .../Application/Services/RoleRegistry.php | 18 ++++++++++ src/Role/Domain/Models/RoleDefinition.php | 3 +- .../Adapters/WordPressRoleLabelTranslator.php | 33 +++++++++++++++++++ .../Providers/RoleServiceProvider.php | 5 +++ tests/Unit/Role/Fixtures/Usher.php | 10 ++++++ tests/Unit/Role/RoleDefinitionBuilderTest.php | 9 +++++ tests/Unit/Role/RoleRegistryTest.php | 8 +++++ tests/Unit/Role/RoleServiceProviderTest.php | 6 +++- .../Role/WordPressRoleLabelTranslatorTest.php | 29 ++++++++++++++++ 12 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 src/Role/Infrastructure/Adapters/WordPressRoleLabelTranslator.php create mode 100644 tests/Unit/Role/Fixtures/Usher.php create mode 100644 tests/Unit/Role/WordPressRoleLabelTranslatorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 62def616..6b6c0a77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.4...develop) +### Added +- 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 + ## [v13.34.4](https://github.com/Pollora/framework/compare/v13.34.3...v13.34.4) - 2026-10-05 ### Added diff --git a/src/Attributes/Role.php b/src/Attributes/Role.php index 3ed4a5ab..f4909c2e 100644 --- a/src/Attributes/Role.php +++ b/src/Attributes/Role.php @@ -30,11 +30,13 @@ * @param string|null $label The name shown in the admin. Defaults to the class name, humanized * @param string|null $inherits A role whose capabilities are the starting point: a slug, or the class of a #[Role] * @param bool $allowSensitive Required to grant a sensitive capability (manage_options, edit_users…) + * @param string|null $textDomain The text domain the label is translated with in the admin, such as the declaring theme's or plugin's */ public function __construct( public string $slug, public ?string $label = null, public ?string $inherits = null, public bool $allowSensitive = false, + public ?string $textDomain = null, ) {} } diff --git a/src/Role/Application/Services/RoleDefinitionBuilder.php b/src/Role/Application/Services/RoleDefinitionBuilder.php index 53366017..d2cf93a1 100644 --- a/src/Role/Application/Services/RoleDefinitionBuilder.php +++ b/src/Role/Application/Services/RoleDefinitionBuilder.php @@ -87,6 +87,7 @@ public function build(string $class): RoleDefinition|RoleModification inherits: $inherits, changes: $changes, declaringClass: $class, + textDomain: $role->textDomain, ); } diff --git a/src/Role/Application/Services/RoleRegistry.php b/src/Role/Application/Services/RoleRegistry.php index 115682f0..4032385f 100644 --- a/src/Role/Application/Services/RoleRegistry.php +++ b/src/Role/Application/Services/RoleRegistry.php @@ -176,6 +176,24 @@ public function modifications(?Closure $warn = null): array )); } + /** + * The text domain of each role label that has one, by label. + * + * @return array + */ + public function labelDomains(): array + { + $domains = []; + + foreach ($this->definitions as $definition) { + if ($definition->textDomain !== null) { + $domains[$definition->label] = $definition->textDomain; + } + } + + return $domains; + } + /** * Every capability the project declares, for the super roles. * diff --git a/src/Role/Domain/Models/RoleDefinition.php b/src/Role/Domain/Models/RoleDefinition.php index b61afc7d..25d38890 100644 --- a/src/Role/Domain/Models/RoleDefinition.php +++ b/src/Role/Domain/Models/RoleDefinition.php @@ -18,10 +18,11 @@ public function __construct( public ?string $inherits, public RoleChanges $changes, public string $declaringClass, + public ?string $textDomain = null, ) {} public function withChanges(RoleChanges $changes): self { - return new self($this->slug, $this->label, $this->inherits, $changes, $this->declaringClass); + return new self($this->slug, $this->label, $this->inherits, $changes, $this->declaringClass, $this->textDomain); } } diff --git a/src/Role/Infrastructure/Adapters/WordPressRoleLabelTranslator.php b/src/Role/Infrastructure/Adapters/WordPressRoleLabelTranslator.php new file mode 100644 index 00000000..369b1bd4 --- /dev/null +++ b/src/Role/Infrastructure/Adapters/WordPressRoleLabelTranslator.php @@ -0,0 +1,33 @@ +registry->labelDomains()[$text] ?? null; + + return $domain === null ? $translation : translate($text, $domain); + } +} diff --git a/src/Role/Infrastructure/Providers/RoleServiceProvider.php b/src/Role/Infrastructure/Providers/RoleServiceProvider.php index 2e868963..b0dedfd0 100644 --- a/src/Role/Infrastructure/Providers/RoleServiceProvider.php +++ b/src/Role/Infrastructure/Providers/RoleServiceProvider.php @@ -7,12 +7,14 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\ServiceProvider; use Pollora\Hook\Domain\Contract\Action; +use Pollora\Hook\Domain\Contract\Filter; use Pollora\Role\Application\Services\CapabilityOwnerReader; use Pollora\Role\Application\Services\RoleDefinitionBuilder; use Pollora\Role\Application\Services\RoleRegistry; use Pollora\Role\Domain\Services\PostTypeCapabilityMap; use Pollora\Role\Domain\Services\RoleCompiler; use Pollora\Role\Infrastructure\Adapters\WordPressRoleInjector; +use Pollora\Role\Infrastructure\Adapters\WordPressRoleLabelTranslator; use Pollora\Role\Infrastructure\Services\RoleDiscovery; use Pollora\Role\UI\Console\RoleMakeCommand; use Psr\Log\LoggerInterface; @@ -34,6 +36,7 @@ public function register(): void $this->app->singleton(PostTypeCapabilityMap::class); $this->app->singleton(RoleCompiler::class); $this->app->singleton(RoleRegistry::class); + $this->app->singleton(WordPressRoleLabelTranslator::class); $this->app->singleton(RoleDefinitionBuilder::class, fn (Application $app): RoleDefinitionBuilder => new RoleDefinitionBuilder( $app->make(CapabilityOwnerReader::class), @@ -67,6 +70,8 @@ public function boot(): void // Early priority: listeners after it see the roles as the code declares them. $action->add('wp_roles_init', $injector->inject(...), 1); $action->add('init', $injector->reportWarnings(...), PHP_INT_MAX); + + $this->app->make(Filter::class)->add('gettext_with_context_default', $this->app->make(WordPressRoleLabelTranslator::class)->translate(...), 10, 3); } /** diff --git a/tests/Unit/Role/Fixtures/Usher.php b/tests/Unit/Role/Fixtures/Usher.php new file mode 100644 index 00000000..6757554f --- /dev/null +++ b/tests/Unit/Role/Fixtures/Usher.php @@ -0,0 +1,10 @@ +toBeInstanceOf(RoleDefinition::class) ->and($role->slug)->toBe('event_manager') ->and($role->label)->toBe('Event manager') + ->and($role->textDomain)->toBeNull() ->and($role->inherits)->toBe('author') ->and($role->declaringClass)->toBe(EventManager::class) ->and($role->changes->grants)->toBe(['export_attendees', 'scan_tickets', 'upload_files']) @@ -57,6 +59,13 @@ ->and($role->changes->grants)->toBe(['manage_options']); }); +it('keeps the text domain of the label, through resolution', function (): void { + $role = $this->builder->build(Usher::class); + + expect($role->textDomain)->toBe('events') + ->and($role->withChanges($role->changes)->textDomain)->toBe('events'); +}); + it('builds a modification from #[ModifyRole]', function (): void { $modification = $this->builder->build(EditorAdjustments::class); diff --git a/tests/Unit/Role/RoleRegistryTest.php b/tests/Unit/Role/RoleRegistryTest.php index 20670553..9210874d 100644 --- a/tests/Unit/Role/RoleRegistryTest.php +++ b/tests/Unit/Role/RoleRegistryTest.php @@ -20,6 +20,7 @@ use Tests\Unit\Role\Fixtures\LoopA; use Tests\Unit\Role\Fixtures\LoopB; use Tests\Unit\Role\Fixtures\Topic; +use Tests\Unit\Role\Fixtures\Usher; use Tests\Unit\Role\Fixtures\Venue; require_once __DIR__.'/Fixtures/Invalid.php'; @@ -114,3 +115,10 @@ expect($this->registry->modifications())->toHaveCount(1); }); + +it('lists the text domain of each label that declares one', function (): void { + $this->registry->addRole(($this->build)(Usher::class)); + $this->registry->addRole(($this->build)(LoopB::class)); + + expect($this->registry->labelDomains())->toBe(['Usher' => 'events']); +}); diff --git a/tests/Unit/Role/RoleServiceProviderTest.php b/tests/Unit/Role/RoleServiceProviderTest.php index 66f09f75..ce71e56f 100644 --- a/tests/Unit/Role/RoleServiceProviderTest.php +++ b/tests/Unit/Role/RoleServiceProviderTest.php @@ -6,6 +6,7 @@ use Illuminate\Filesystem\Filesystem; use Illuminate\Foundation\Application; use Pollora\Hook\Domain\Contract\Action; +use Pollora\Hook\Domain\Contract\Filter; use Pollora\Role\Application\Services\RoleDefinitionBuilder; use Pollora\Role\Infrastructure\Adapters\WordPressRoleInjector; use Pollora\Role\Infrastructure\Providers\RoleServiceProvider; @@ -20,6 +21,8 @@ $this->action = Mockery::mock(Action::class); $this->app->instance(Action::class, $this->action); + $this->filter = Mockery::mock(Filter::class); + $this->app->instance(Filter::class, $this->filter); $this->provider = new RoleServiceProvider($this->app); $this->provider->register(); }); @@ -30,9 +33,10 @@ ->and((new ReflectionProperty(RoleDefinitionBuilder::class, 'superRoles'))->getValue($this->app->make(RoleDefinitionBuilder::class)))->toBe(['administrator', 'network_admin']); }); -it('subscribes the injector to wp_roles_init, early, and reports warnings once on init', function (): void { +it('subscribes the injector to wp_roles_init, early, reports warnings once on init, and translates labels', function (): void { $this->action->shouldReceive('add')->once()->with('wp_roles_init', Mockery::type(Closure::class), 1)->andReturnSelf(); $this->action->shouldReceive('add')->once()->with('init', Mockery::type(Closure::class), PHP_INT_MAX)->andReturnSelf(); + $this->filter->shouldReceive('add')->once()->with('gettext_with_context_default', Mockery::type(Closure::class), 10, 3)->andReturnSelf(); $this->provider->boot(); }); diff --git a/tests/Unit/Role/WordPressRoleLabelTranslatorTest.php b/tests/Unit/Role/WordPressRoleLabelTranslatorTest.php new file mode 100644 index 00000000..261a37c5 --- /dev/null +++ b/tests/Unit/Role/WordPressRoleLabelTranslatorTest.php @@ -0,0 +1,29 @@ +addRole((new RoleDefinitionBuilder(new CapabilityOwnerReader))->build(Usher::class)); + + $this->translator = new WordPressRoleLabelTranslator($registry); + Functions\when('translate')->alias(fn (string $text, string $domain): string => $domain === 'events' ? 'Placeur' : $text); +}); + +it("translates a declared label with the role's text domain", function (): void { + expect($this->translator->translate('Usher', 'Usher', 'User role'))->toBe('Placeur'); +}); + +it('keeps a translation WordPress already found, and other texts', function (): void { + expect($this->translator->translate('Huissier', 'Usher', 'User role'))->toBe('Huissier') + ->and($this->translator->translate('Usher', 'Usher', 'noun'))->toBe('Usher') + ->and($this->translator->translate('Editor', 'Editor', 'User role'))->toBe('Editor'); +}); From 634dd2c3e39acf1b7a981071d9253bedfe592440 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Mon, 5 Oct 2026 17:02:22 +0200 Subject: [PATCH 03/13] feat(meta): typed meta for users, comments and undeclared post types and taxonomies #[PostMeta], #[TermMeta], #[UserMeta] and #[CommentMeta] attach a #[Meta] schema to objects the project does not declare. A schema now holds a list of subtypes, empty for every object of its type. --- CHANGELOG.md | 1 + src/Attributes/CommentMeta.php | 25 ++++++++++++ src/Attributes/Meta.php | 3 +- src/Attributes/PostMeta.php | 37 ++++++++++++++++++ src/Attributes/TermMeta.php | 37 ++++++++++++++++++ src/Attributes/UserMeta.php | 25 ++++++++++++ .../Application/Services/MetaAccessor.php | 4 +- .../Services/MetaSchemaBuilder.php | 38 ++++++++++--------- .../Services/MetaSchemaRepository.php | 6 +-- src/Meta/Domain/Enums/MetaObjectType.php | 2 + .../InvalidMetaDefinitionException.php | 8 ++-- src/Meta/Domain/Models/MetaSchema.php | 28 ++++++++++++-- .../Adapters/WordPressMetaRegistry.php | 11 ++++-- .../Infrastructure/Services/MetaDiscovery.php | 9 ++++- tests/Unit/Meta/Fixtures/ArticleExtras.php | 15 ++++++++ tests/Unit/Meta/Fixtures/BillingProfile.php | 15 ++++++++ tests/Unit/Meta/Fixtures/CategoryExtras.php | 15 ++++++++ tests/Unit/Meta/Fixtures/MemberProfile.php | 18 +++++++++ tests/Unit/Meta/Fixtures/PageExtras.php | 15 ++++++++ tests/Unit/Meta/Fixtures/ReviewMeta.php | 15 ++++++++ tests/Unit/Meta/Fixtures/UserRevisions.php | 15 ++++++++ tests/Unit/Meta/MetaDefinitionTest.php | 2 +- tests/Unit/Meta/MetaDiscoveryTest.php | 12 ++++-- tests/Unit/Meta/MetaSchemaBuilderTest.php | 27 +++++++++++-- tests/Unit/Meta/MetaSchemaRepositoryTest.php | 28 +++++++++++++- tests/Unit/Meta/WordPressMetaRegistryTest.php | 26 +++++++++++++ 26 files changed, 390 insertions(+), 47 deletions(-) create mode 100644 src/Attributes/CommentMeta.php create mode 100644 src/Attributes/PostMeta.php create mode 100644 src/Attributes/TermMeta.php create mode 100644 src/Attributes/UserMeta.php create mode 100644 tests/Unit/Meta/Fixtures/ArticleExtras.php create mode 100644 tests/Unit/Meta/Fixtures/BillingProfile.php create mode 100644 tests/Unit/Meta/Fixtures/CategoryExtras.php create mode 100644 tests/Unit/Meta/Fixtures/MemberProfile.php create mode 100644 tests/Unit/Meta/Fixtures/PageExtras.php create mode 100644 tests/Unit/Meta/Fixtures/ReviewMeta.php create mode 100644 tests/Unit/Meta/Fixtures/UserRevisions.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f71f495..0756a4eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.4...develop) ### 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 - 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 diff --git a/src/Attributes/CommentMeta.php b/src/Attributes/CommentMeta.php new file mode 100644 index 00000000..70f8733a --- /dev/null +++ b/src/Attributes/CommentMeta.php @@ -0,0 +1,25 @@ + */ + public array $postTypes; + + /** + * @param string|list $postTypes The post type slugs the meta belong to + */ + public function __construct(string|array $postTypes) + { + $this->postTypes = array_values((array) $postTypes); + } +} diff --git a/src/Attributes/TermMeta.php b/src/Attributes/TermMeta.php new file mode 100644 index 00000000..8bdc3d09 --- /dev/null +++ b/src/Attributes/TermMeta.php @@ -0,0 +1,37 @@ + */ + public array $taxonomies; + + /** + * @param string|list $taxonomies The taxonomy slugs the meta belong to + */ + public function __construct(string|array $taxonomies) + { + $this->taxonomies = array_values((array) $taxonomies); + } +} diff --git a/src/Attributes/UserMeta.php b/src/Attributes/UserMeta.php new file mode 100644 index 00000000..25a70858 --- /dev/null +++ b/src/Attributes/UserMeta.php @@ -0,0 +1,25 @@ +newsletterOptIn; + * + * @experimental The API may still change before it is declared stable. + */ +#[Attribute(Attribute::TARGET_CLASS)] +final readonly class UserMeta {} diff --git a/src/Meta/Application/Services/MetaAccessor.php b/src/Meta/Application/Services/MetaAccessor.php index ad9063f9..4edf0edb 100644 --- a/src/Meta/Application/Services/MetaAccessor.php +++ b/src/Meta/Application/Services/MetaAccessor.php @@ -30,8 +30,8 @@ public function __construct( ) {} /** - * @param class-string $class The `#[PostType]` or `#[Taxonomy]` class declaring the meta - * @param int $objectId The post or term ID + * @param class-string $class The class declaring the meta + * @param int $objectId The post, term, user or comment ID */ public function of(string $class, int $objectId): MetaRecord { diff --git a/src/Meta/Application/Services/MetaSchemaBuilder.php b/src/Meta/Application/Services/MetaSchemaBuilder.php index 71aed516..15e45b37 100644 --- a/src/Meta/Application/Services/MetaSchemaBuilder.php +++ b/src/Meta/Application/Services/MetaSchemaBuilder.php @@ -6,9 +6,13 @@ use BackedEnum; use DateTimeInterface; +use Pollora\Attributes\CommentMeta; use Pollora\Attributes\Meta; +use Pollora\Attributes\PostMeta; use Pollora\Attributes\PostType; use Pollora\Attributes\Taxonomy; +use Pollora\Attributes\TermMeta; +use Pollora\Attributes\UserMeta; use Pollora\Meta\Domain\Enums\MetaObjectType; use Pollora\Meta\Domain\Enums\MetaValueType; use Pollora\Meta\Domain\Exceptions\InvalidMetaDefinitionException; @@ -20,7 +24,8 @@ use UnitEnum; /** - * Builds the meta schema of a `#[PostType]` or `#[Taxonomy]` class from its + * Builds the meta schema of a class declaring meta (`#[PostType]`, `#[Taxonomy]`, + * `#[PostMeta]`, `#[TermMeta]`, `#[UserMeta]`, `#[CommentMeta]`) from its * `#[Meta]` properties. * * Every rule WordPress would otherwise break silently is checked here, so a @@ -36,7 +41,7 @@ final class MetaSchemaBuilder public function build(string $class): MetaSchema { $reflection = new ReflectionClass($class); - [$objectType, $subtype] = $this->resolveOwner($reflection); + [$objectType, $subtypes] = $this->resolveOwner($reflection); $definitions = []; $properties = []; @@ -62,28 +67,27 @@ public function build(string $class): MetaSchema $definitions[$definition->property] = $definition; } - return new MetaSchema($class, $objectType, $subtype, $definitions); + return new MetaSchema($class, $objectType, $subtypes, $definitions); } /** * @param ReflectionClass $reflection - * @return array{0: MetaObjectType, 1: string} + * @return array{0: MetaObjectType, 1: list} */ private function resolveOwner(ReflectionClass $reflection): array { - $postType = $reflection->getAttributes(PostType::class)[0] ?? null; - - if ($postType !== null) { - return [MetaObjectType::Post, $postType->newInstance()->resolveSlug($reflection->getName())]; - } - - $taxonomy = $reflection->getAttributes(Taxonomy::class)[0] ?? null; - - if ($taxonomy !== null) { - return [MetaObjectType::Term, $taxonomy->newInstance()->resolveSlug($reflection->getName())]; - } - - throw InvalidMetaDefinitionException::notADeclaration($reflection->getName()); + $class = $reflection->getName(); + $owner = static fn (string $attribute): ?object => ($reflection->getAttributes($attribute)[0] ?? null)?->newInstance(); + + return match (true) { + ($postType = $owner(PostType::class)) instanceof PostType => [MetaObjectType::Post, [$postType->resolveSlug($class)]], + ($taxonomy = $owner(Taxonomy::class)) instanceof Taxonomy => [MetaObjectType::Term, [$taxonomy->resolveSlug($class)]], + ($postMeta = $owner(PostMeta::class)) instanceof PostMeta => [MetaObjectType::Post, $postMeta->postTypes], + ($termMeta = $owner(TermMeta::class)) instanceof TermMeta => [MetaObjectType::Term, $termMeta->taxonomies], + $owner(UserMeta::class) instanceof UserMeta => [MetaObjectType::User, []], + $owner(CommentMeta::class) instanceof CommentMeta => [MetaObjectType::Comment, []], + default => throw InvalidMetaDefinitionException::notADeclaration($class), + }; } private function buildDefinition(string $class, ReflectionProperty $property, Meta $meta, MetaObjectType $objectType): MetaDefinition diff --git a/src/Meta/Application/Services/MetaSchemaRepository.php b/src/Meta/Application/Services/MetaSchemaRepository.php index 4ff00da4..b918e369 100644 --- a/src/Meta/Application/Services/MetaSchemaRepository.php +++ b/src/Meta/Application/Services/MetaSchemaRepository.php @@ -24,15 +24,13 @@ final class MetaSchemaRepository public function add(MetaSchema $schema): void { foreach ($this->schemas as $existing) { - if ($existing->declaringClass === $schema->declaringClass - || $existing->objectType !== $schema->objectType - || $existing->subtype !== $schema->subtype) { + if ($existing->declaringClass === $schema->declaringClass || ! $existing->overlaps($schema)) { continue; } foreach ($schema->definitions as $definition) { if ($existing->find($definition->key) instanceof MetaDefinition) { - throw InvalidMetaDefinitionException::duplicateKey($definition->key, $schema->subtype, $existing->declaringClass, $schema->declaringClass); + throw InvalidMetaDefinitionException::duplicateKey($definition->key, $schema->ownerName(), $existing->declaringClass, $schema->declaringClass); } } } diff --git a/src/Meta/Domain/Enums/MetaObjectType.php b/src/Meta/Domain/Enums/MetaObjectType.php index 1ddbf9b7..910f21c1 100644 --- a/src/Meta/Domain/Enums/MetaObjectType.php +++ b/src/Meta/Domain/Enums/MetaObjectType.php @@ -11,4 +11,6 @@ enum MetaObjectType: string { case Post = 'post'; case Term = 'term'; + case User = 'user'; + case Comment = 'comment'; } diff --git a/src/Meta/Domain/Exceptions/InvalidMetaDefinitionException.php b/src/Meta/Domain/Exceptions/InvalidMetaDefinitionException.php index 6c14fd67..62e2b956 100644 --- a/src/Meta/Domain/Exceptions/InvalidMetaDefinitionException.php +++ b/src/Meta/Domain/Exceptions/InvalidMetaDefinitionException.php @@ -21,15 +21,15 @@ public static function forProperty(string $class, string $property, string $reas public static function notADeclaration(string $class): self { - return new self(sprintf('%s declares no meta: it carries neither #[PostType] nor #[Taxonomy].', $class)); + return new self(sprintf('%s declares no meta: it carries none of #[PostType], #[Taxonomy], #[PostMeta], #[TermMeta], #[UserMeta], #[CommentMeta].', $class)); } - public static function duplicateKey(string $key, string $subtype, string $firstClass, string $secondClass): self + public static function duplicateKey(string $key, string $owner, string $firstClass, string $secondClass): self { return new self(sprintf( - 'The meta key "%s" of "%s" is declared twice, by %s and by %s.', + 'The meta key "%s" of %s is declared twice, by %s and by %s.', $key, - $subtype, + $owner, $firstClass, $secondClass )); diff --git a/src/Meta/Domain/Models/MetaSchema.php b/src/Meta/Domain/Models/MetaSchema.php index 6f633338..56710e82 100644 --- a/src/Meta/Domain/Models/MetaSchema.php +++ b/src/Meta/Domain/Models/MetaSchema.php @@ -12,18 +12,40 @@ final readonly class MetaSchema { /** - * @param class-string $declaringClass The `#[PostType]` or `#[Taxonomy]` class + * @param class-string $declaringClass The class declaring the meta * @param MetaObjectType $objectType The WordPress object the meta belong to - * @param string $subtype The post type or taxonomy slug + * @param list $subtypes The post type or taxonomy slugs; empty for every object of the type * @param array $definitions Definitions keyed by property name */ public function __construct( public string $declaringClass, public MetaObjectType $objectType, - public string $subtype, + public array $subtypes, public array $definitions, ) {} + /** + * Whether some object can carry the meta of both schemas. + */ + public function overlaps(self $other): bool + { + if ($this->objectType !== $other->objectType) { + return false; + } + + return $this->subtypes === [] || $other->subtypes === [] || array_intersect($this->subtypes, $other->subtypes) !== []; + } + + /** + * The objects the meta belong to, for messages: `post "event"`, `user`. + */ + public function ownerName(): string + { + return $this->subtypes === [] + ? $this->objectType->value + : sprintf('%s "%s"', $this->objectType->value, implode('", "', $this->subtypes)); + } + public function isEmpty(): bool { return $this->definitions === []; diff --git a/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php b/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php index a68b25a7..a371046a 100644 --- a/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php +++ b/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php @@ -38,18 +38,21 @@ public function register(MetaSchema $schema): void private function registerNow(MetaSchema $schema): void { - foreach ($schema->definitions as $definition) { - \register_meta($schema->objectType->value, $definition->key, $this->argumentsFor($schema, $definition)); + // An empty subtype registers the meta for every object of the type. + foreach ($schema->subtypes === [] ? [''] : $schema->subtypes as $subtype) { + foreach ($schema->definitions as $definition) { + \register_meta($schema->objectType->value, $definition->key, $this->argumentsFor($schema, $definition, $subtype)); + } } } /** * @return array */ - private function argumentsFor(MetaSchema $schema, MetaDefinition $definition): array + private function argumentsFor(MetaSchema $schema, MetaDefinition $definition, string $subtype): array { $arguments = [ - 'object_subtype' => $schema->subtype, + 'object_subtype' => $subtype, 'type' => $definition->wordPressType(), 'single' => true, 'sanitize_callback' => $definition->sanitize ?? $this->sanitizerFor($definition), diff --git a/src/Meta/Infrastructure/Services/MetaDiscovery.php b/src/Meta/Infrastructure/Services/MetaDiscovery.php index d3f0ebd0..dda95fde 100644 --- a/src/Meta/Infrastructure/Services/MetaDiscovery.php +++ b/src/Meta/Infrastructure/Services/MetaDiscovery.php @@ -4,8 +4,12 @@ namespace Pollora\Meta\Infrastructure\Services; +use Pollora\Attributes\CommentMeta; +use Pollora\Attributes\PostMeta; use Pollora\Attributes\PostType; use Pollora\Attributes\Taxonomy; +use Pollora\Attributes\TermMeta; +use Pollora\Attributes\UserMeta; use Pollora\Discovery\Domain\Contracts\DiscoveryInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface; use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface; @@ -18,7 +22,8 @@ use Spatie\StructureDiscoverer\Data\DiscoveredStructure; /** - * Discovers the `#[Meta]` properties of `#[PostType]` and `#[Taxonomy]` classes. + * Discovers the `#[Meta]` properties of `#[PostType]`, `#[Taxonomy]`, `#[PostMeta]`, + * `#[TermMeta]`, `#[UserMeta]` and `#[CommentMeta]` classes. * * 1. **discover()** — keeps the classes carrying one of those attributes. * 2. **apply()** — builds each class's schema, stores it for `Meta::of()` and @@ -31,7 +36,7 @@ final class MetaDiscovery implements DiscoveryInterface { use IsDiscovery; - private const array OWNER_ATTRIBUTES = [PostType::class, Taxonomy::class]; + private const array OWNER_ATTRIBUTES = [PostType::class, Taxonomy::class, PostMeta::class, TermMeta::class, UserMeta::class, CommentMeta::class]; public function __construct( private readonly MetaSchemaBuilder $builder, diff --git a/tests/Unit/Meta/Fixtures/ArticleExtras.php b/tests/Unit/Meta/Fixtures/ArticleExtras.php new file mode 100644 index 00000000..678006f7 --- /dev/null +++ b/tests/Unit/Meta/Fixtures/ArticleExtras.php @@ -0,0 +1,15 @@ +and($schema->find('starts_at')?->property)->toBe('startsAt') ->and($schema->find('unknown'))->toBeNull() ->and($schema->isEmpty())->toBeFalse() - ->and((new MetaSchema(Event::class, $schema->objectType, 'event', []))->isEmpty())->toBeTrue(); + ->and((new MetaSchema(Event::class, $schema->objectType, ['event'], []))->isEmpty())->toBeTrue(); }); diff --git a/tests/Unit/Meta/MetaDiscoveryTest.php b/tests/Unit/Meta/MetaDiscoveryTest.php index e790d9c7..93ec5ed5 100644 --- a/tests/Unit/Meta/MetaDiscoveryTest.php +++ b/tests/Unit/Meta/MetaDiscoveryTest.php @@ -12,13 +12,17 @@ use Psr\Log\LoggerInterface; use Spatie\StructureDiscoverer\Data\DiscoveredClass; use Spatie\StructureDiscoverer\Data\DiscoveredEnum; +use Tests\Unit\Meta\Fixtures\ArticleExtras; use Tests\Unit\Meta\Fixtures\BookGenre; +use Tests\Unit\Meta\Fixtures\CategoryExtras; use Tests\Unit\Meta\Fixtures\Event; use Tests\Unit\Meta\Fixtures\EventExtras; use Tests\Unit\Meta\Fixtures\EventStatus; use Tests\Unit\Meta\Fixtures\InvalidArray; +use Tests\Unit\Meta\Fixtures\MemberProfile; use Tests\Unit\Meta\Fixtures\NoMeta; use Tests\Unit\Meta\Fixtures\NotADeclaration; +use Tests\Unit\Meta\Fixtures\ReviewMeta; require_once __DIR__.'/Fixtures/Invalid.php'; @@ -42,11 +46,11 @@ abstract class AbstractMetaDeclaration }; }); -it('keeps post type and taxonomy classes only', function (): void { - ($this->discover)(Event::class, BookGenre::class, NotADeclaration::class, AbstractMetaDeclaration::class); +it('keeps the classes declaring meta only', function (): void { + ($this->discover)(Event::class, BookGenre::class, ArticleExtras::class, CategoryExtras::class, MemberProfile::class, ReviewMeta::class, NotADeclaration::class, AbstractMetaDeclaration::class); $this->discovery->discover($this->location, DiscoveredEnum::fromReflection(new ReflectionEnum(EventStatus::class))); - expect(array_column(iterator_to_array($this->discovery->getItems()), 'class'))->toBe([Event::class, BookGenre::class]); + expect(array_column(iterator_to_array($this->discovery->getItems()), 'class'))->toBe([Event::class, BookGenre::class, ArticleExtras::class, CategoryExtras::class, MemberProfile::class, ReviewMeta::class]); }); it('stores and registers the schema of each declaring class', function (): void { @@ -66,7 +70,7 @@ abstract class AbstractMetaDeclaration it('logs a declaration it cannot register and carries on', function (): void { $this->registry->shouldReceive('register')->twice(); $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/InvalidArray: .*the type array is not supported yet/'), Mockery::type('array')); - $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/EventExtras: The meta key "capacity" of "event" is declared twice/'), Mockery::type('array')); + $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/EventExtras: The meta key "capacity" of post "event" is declared twice/'), Mockery::type('array')); ($this->discover)(Event::class, InvalidArray::class, EventExtras::class, BookGenre::class); $this->discovery->apply(); diff --git a/tests/Unit/Meta/MetaSchemaBuilderTest.php b/tests/Unit/Meta/MetaSchemaBuilderTest.php index a7af8414..53f1e4c0 100644 --- a/tests/Unit/Meta/MetaSchemaBuilderTest.php +++ b/tests/Unit/Meta/MetaSchemaBuilderTest.php @@ -8,7 +8,9 @@ use Pollora\Meta\Domain\Enums\MetaValueType; use Pollora\Meta\Domain\Exceptions\InvalidMetaDefinitionException; use Pollora\Meta\Domain\Models\MetaSchema; +use Tests\Unit\Meta\Fixtures\ArticleExtras; use Tests\Unit\Meta\Fixtures\BookGenre; +use Tests\Unit\Meta\Fixtures\CategoryExtras; use Tests\Unit\Meta\Fixtures\Event; use Tests\Unit\Meta\Fixtures\EventStatus; use Tests\Unit\Meta\Fixtures\InvalidArray; @@ -19,8 +21,11 @@ use Tests\Unit\Meta\Fixtures\InvalidTermRevisions; use Tests\Unit\Meta\Fixtures\InvalidUnion; use Tests\Unit\Meta\Fixtures\InvalidUntyped; +use Tests\Unit\Meta\Fixtures\MemberProfile; use Tests\Unit\Meta\Fixtures\NotADeclaration; use Tests\Unit\Meta\Fixtures\Priority; +use Tests\Unit\Meta\Fixtures\ReviewMeta; +use Tests\Unit\Meta\Fixtures\UserRevisions; require_once __DIR__.'/Fixtures/Invalid.php'; @@ -31,7 +36,7 @@ it('attaches the meta to the post type slug', function (): void { expect($this->schema->objectType)->toBe(MetaObjectType::Post) - ->and($this->schema->subtype)->toBe('event') + ->and($this->schema->subtypes)->toBe(['event']) ->and($this->schema->declaringClass)->toBe(Event::class); }); @@ -86,13 +91,13 @@ $schema = (new MetaSchemaBuilder)->build(BookGenre::class); expect($schema->objectType)->toBe(MetaObjectType::Term) - ->and($schema->subtype)->toBe('book-genre') + ->and($schema->subtypes)->toBe(['book-genre']) ->and($schema->definitions['color']->key)->toBe('color'); }); -it('refuses a class that is neither a post type nor a taxonomy', function (): void { +it('refuses a class that declares no meta owner', function (): void { (new MetaSchemaBuilder)->build(NotADeclaration::class); -})->throws(InvalidMetaDefinitionException::class, 'carries neither #[PostType] nor #[Taxonomy]'); +})->throws(InvalidMetaDefinitionException::class, 'carries none of #[PostType], #[Taxonomy], #[PostMeta], #[TermMeta], #[UserMeta], #[CommentMeta]'); it('refuses a declaration WordPress cannot register', function (string $class, string $message): void { expect(fn (): MetaSchema => (new MetaSchemaBuilder)->build($class)) @@ -105,5 +110,19 @@ 'no default, not nullable' => [InvalidNoDefault::class, 'give the property a default value or make it nullable'], 'protected key in REST' => [InvalidProtectedInRest::class, 'the protected key "_secret" can only be exposed in REST with an explicit capability'], 'revisions on a term' => [InvalidTermRevisions::class, 'revisions only exist for post types'], + 'revisions on a user' => [UserRevisions::class, 'revisions only exist for post types'], 'same key twice' => [InvalidDuplicateKey::class, 'the key "shared" is already used by $first'], ]); + +it('attaches the meta of #[PostMeta], #[TermMeta], #[UserMeta] and #[CommentMeta] to their objects', function (string $class, MetaObjectType $objectType, array $subtypes): void { + $schema = (new MetaSchemaBuilder)->build($class); + + expect($schema->objectType)->toBe($objectType) + ->and($schema->subtypes)->toBe($subtypes) + ->and($schema->isEmpty())->toBeFalse(); +})->with([ + 'post types' => [ArticleExtras::class, MetaObjectType::Post, ['post', 'page']], + 'a taxonomy' => [CategoryExtras::class, MetaObjectType::Term, ['category']], + 'users' => [MemberProfile::class, MetaObjectType::User, []], + 'comments' => [ReviewMeta::class, MetaObjectType::Comment, []], +]); diff --git a/tests/Unit/Meta/MetaSchemaRepositoryTest.php b/tests/Unit/Meta/MetaSchemaRepositoryTest.php index 207d9426..cccdbd19 100644 --- a/tests/Unit/Meta/MetaSchemaRepositoryTest.php +++ b/tests/Unit/Meta/MetaSchemaRepositoryTest.php @@ -5,9 +5,14 @@ use Pollora\Meta\Application\Services\MetaSchemaBuilder; use Pollora\Meta\Application\Services\MetaSchemaRepository; use Pollora\Meta\Domain\Exceptions\InvalidMetaDefinitionException; +use Tests\Unit\Meta\Fixtures\ArticleExtras; +use Tests\Unit\Meta\Fixtures\BillingProfile; use Tests\Unit\Meta\Fixtures\BookGenre; +use Tests\Unit\Meta\Fixtures\CategoryExtras; use Tests\Unit\Meta\Fixtures\Event; use Tests\Unit\Meta\Fixtures\EventExtras; +use Tests\Unit\Meta\Fixtures\MemberProfile; +use Tests\Unit\Meta\Fixtures\PageExtras; it('keeps the schemas by declaring class', function (): void { $repository = new MetaSchemaRepository; @@ -30,5 +35,26 @@ $repository->add((new MetaSchemaBuilder)->build(EventExtras::class)); })->throws( InvalidMetaDefinitionException::class, - 'The meta key "capacity" of "event" is declared twice, by Tests\Unit\Meta\Fixtures\Event and by Tests\Unit\Meta\Fixtures\EventExtras.' + 'The meta key "capacity" of post "event" is declared twice, by Tests\Unit\Meta\Fixtures\Event and by Tests\Unit\Meta\Fixtures\EventExtras.' ); + +it('refuses a key declared twice on objects the schemas share', function (string $first, string $second, string $message): void { + $repository = new MetaSchemaRepository; + $repository->add((new MetaSchemaBuilder)->build($first)); + + expect(fn () => $repository->add((new MetaSchemaBuilder)->build($second)))->toThrow(InvalidMetaDefinitionException::class, $message); +})->with([ + 'a post type in a list' => [ArticleExtras::class, PageExtras::class, 'The meta key "subtitle" of post "page" is declared twice'], + 'users' => [MemberProfile::class, BillingProfile::class, 'The meta key "job_title" of user is declared twice'], +]); + +it('accepts the same key on objects the schemas do not share', function (): void { + $repository = new MetaSchemaRepository; + + $repository->add((new MetaSchemaBuilder)->build(PageExtras::class)); + $repository->add((new MetaSchemaBuilder)->build(Event::class)); + $repository->add((new MetaSchemaBuilder)->build(CategoryExtras::class)); + $repository->add((new MetaSchemaBuilder)->build(BookGenre::class)); + + expect($repository->all())->toHaveCount(4); +}); diff --git a/tests/Unit/Meta/WordPressMetaRegistryTest.php b/tests/Unit/Meta/WordPressMetaRegistryTest.php index 81335e6d..b3e5b358 100644 --- a/tests/Unit/Meta/WordPressMetaRegistryTest.php +++ b/tests/Unit/Meta/WordPressMetaRegistryTest.php @@ -7,8 +7,11 @@ use Pollora\Meta\Application\Services\MetaSchemaBuilder; use Pollora\Meta\Domain\Services\MetaValueCaster; use Pollora\Meta\Infrastructure\Adapters\WordPressMetaRegistry; +use Tests\Unit\Meta\Fixtures\ArticleExtras; use Tests\Unit\Meta\Fixtures\BookGenre; use Tests\Unit\Meta\Fixtures\Event; +use Tests\Unit\Meta\Fixtures\MemberProfile; +use Tests\Unit\Meta\Fixtures\ReviewMeta; /** * Registers the Event schema right away and returns the register_meta() calls, by key. @@ -107,3 +110,26 @@ function registeredEventMeta(string $class = Event::class): array expect($meta['subtitle'][1]['revisions_enabled'])->toBeTrue() ->and($meta['capacity'][1])->not->toHaveKey('revisions_enabled'); }); + +it('registers the meta on each post type of the list, and for every user without a subtype', function (): void { + $calls = []; + Functions\when('did_action')->justReturn(1); + Functions\when('register_meta')->alias(function (string $objectType, string $key, array $args) use (&$calls): bool { + $calls[] = [$objectType, $key, $args['object_subtype']]; + + return true; + }); + $registry = new WordPressMetaRegistry(Mockery::mock(Action::class), new MetaValueCaster); + + $registry->register((new MetaSchemaBuilder)->build(ArticleExtras::class)); + $registry->register((new MetaSchemaBuilder)->build(MemberProfile::class)); + $registry->register((new MetaSchemaBuilder)->build(ReviewMeta::class)); + + expect($calls)->toBe([ + ['post', 'subtitle', 'post'], + ['post', 'subtitle', 'page'], + ['user', 'newsletter_opt_in', ''], + ['user', 'job_title', ''], + ['comment', 'rating', ''], + ]); +}); From 368277bf7359737c12eeb13b5071f7ab4cb873b6 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Mon, 5 Oct 2026 17:14:40 +0200 Subject: [PATCH 04/13] 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 05/13] 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 { From 1c89286ccef3f2ff2d1e5a585eaf54ec307a56c4 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 6 Oct 2026 10:12:02 +0200 Subject: [PATCH 06/13] feat(meta): add custom-fields to a declared post type exposing meta in REST --- CHANGELOG.md | 1 + .../Application/Services/MetaSchemaBuilder.php | 18 +++++++++--------- src/Meta/Domain/Models/MetaSchema.php | 16 ++++++++++++++++ .../Adapters/WordPressMetaRegistry.php | 12 +++++++++++- tests/Unit/Meta/MetaSchemaBuilderTest.php | 8 ++++++++ tests/Unit/Meta/WordPressMetaRegistryTest.php | 14 ++++++++++++++ 6 files changed, 59 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa6c5b2f..4bf3d94d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.4...develop) ### Added +- Typed meta: a post type declared with `#[PostType]` gets `custom-fields` support when one of its `#[Meta]` has `showInRest: true`, without which WordPress leaves `meta` out of its REST responses. A post type targeted by `#[PostMeta]` is left as it is - 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 diff --git a/src/Meta/Application/Services/MetaSchemaBuilder.php b/src/Meta/Application/Services/MetaSchemaBuilder.php index 15e45b37..1349dbd2 100644 --- a/src/Meta/Application/Services/MetaSchemaBuilder.php +++ b/src/Meta/Application/Services/MetaSchemaBuilder.php @@ -41,7 +41,7 @@ final class MetaSchemaBuilder public function build(string $class): MetaSchema { $reflection = new ReflectionClass($class); - [$objectType, $subtypes] = $this->resolveOwner($reflection); + [$objectType, $subtypes, $declaresSubtypes] = $this->resolveOwner($reflection); $definitions = []; $properties = []; @@ -67,12 +67,12 @@ public function build(string $class): MetaSchema $definitions[$definition->property] = $definition; } - return new MetaSchema($class, $objectType, $subtypes, $definitions); + return new MetaSchema($class, $objectType, $subtypes, $definitions, $declaresSubtypes); } /** * @param ReflectionClass $reflection - * @return array{0: MetaObjectType, 1: list} + * @return array{0: MetaObjectType, 1: list, 2: bool} */ private function resolveOwner(ReflectionClass $reflection): array { @@ -80,12 +80,12 @@ private function resolveOwner(ReflectionClass $reflection): array $owner = static fn (string $attribute): ?object => ($reflection->getAttributes($attribute)[0] ?? null)?->newInstance(); return match (true) { - ($postType = $owner(PostType::class)) instanceof PostType => [MetaObjectType::Post, [$postType->resolveSlug($class)]], - ($taxonomy = $owner(Taxonomy::class)) instanceof Taxonomy => [MetaObjectType::Term, [$taxonomy->resolveSlug($class)]], - ($postMeta = $owner(PostMeta::class)) instanceof PostMeta => [MetaObjectType::Post, $postMeta->postTypes], - ($termMeta = $owner(TermMeta::class)) instanceof TermMeta => [MetaObjectType::Term, $termMeta->taxonomies], - $owner(UserMeta::class) instanceof UserMeta => [MetaObjectType::User, []], - $owner(CommentMeta::class) instanceof CommentMeta => [MetaObjectType::Comment, []], + ($postType = $owner(PostType::class)) instanceof PostType => [MetaObjectType::Post, [$postType->resolveSlug($class)], true], + ($taxonomy = $owner(Taxonomy::class)) instanceof Taxonomy => [MetaObjectType::Term, [$taxonomy->resolveSlug($class)], true], + ($postMeta = $owner(PostMeta::class)) instanceof PostMeta => [MetaObjectType::Post, $postMeta->postTypes, false], + ($termMeta = $owner(TermMeta::class)) instanceof TermMeta => [MetaObjectType::Term, $termMeta->taxonomies, false], + $owner(UserMeta::class) instanceof UserMeta => [MetaObjectType::User, [], false], + $owner(CommentMeta::class) instanceof CommentMeta => [MetaObjectType::Comment, [], false], default => throw InvalidMetaDefinitionException::notADeclaration($class), }; } diff --git a/src/Meta/Domain/Models/MetaSchema.php b/src/Meta/Domain/Models/MetaSchema.php index 56710e82..c7e0fa19 100644 --- a/src/Meta/Domain/Models/MetaSchema.php +++ b/src/Meta/Domain/Models/MetaSchema.php @@ -16,12 +16,14 @@ * @param MetaObjectType $objectType The WordPress object the meta belong to * @param list $subtypes The post type or taxonomy slugs; empty for every object of the type * @param array $definitions Definitions keyed by property name + * @param bool $declaresSubtypes Whether the class also declares its post type or taxonomy (`#[PostType]`, `#[Taxonomy]`) */ public function __construct( public string $declaringClass, public MetaObjectType $objectType, public array $subtypes, public array $definitions, + public bool $declaresSubtypes = false, ) {} /** @@ -46,6 +48,20 @@ public function ownerName(): string : sprintf('%s "%s"', $this->objectType->value, implode('", "', $this->subtypes)); } + /** + * Whether a meta of the schema is exposed in REST. + */ + public function exposesInRest(): bool + { + foreach ($this->definitions as $definition) { + if ($definition->showInRest) { + return true; + } + } + + return false; + } + public function isEmpty(): bool { return $this->definitions === []; diff --git a/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php b/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php index a371046a..9b5b256d 100644 --- a/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php +++ b/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php @@ -14,7 +14,8 @@ /** * Registers typed meta with `register_meta()`, on `init` after post types and - * taxonomies (priority 20), or right away when `init` has already run. + * taxonomies (priority 20), or right away when `init` has already run, and adds + * `custom-fields` to a declared post type that exposes a meta in REST. */ final readonly class WordPressMetaRegistry implements MetaRegistryInterface { @@ -38,6 +39,15 @@ public function register(MetaSchema $schema): void private function registerNow(MetaSchema $schema): void { + // WordPress leaves `meta` out of a post's REST response unless its post + // type supports custom-fields. Only a post type the class declares is + // changed: one targeted by #[PostMeta] belongs to someone else. + if ($schema->objectType === MetaObjectType::Post && $schema->declaresSubtypes && $schema->exposesInRest()) { + foreach ($schema->subtypes as $postType) { + \add_post_type_support($postType, 'custom-fields'); + } + } + // An empty subtype registers the meta for every object of the type. foreach ($schema->subtypes === [] ? [''] : $schema->subtypes as $subtype) { foreach ($schema->definitions as $definition) { diff --git a/tests/Unit/Meta/MetaSchemaBuilderTest.php b/tests/Unit/Meta/MetaSchemaBuilderTest.php index 53f1e4c0..9e773285 100644 --- a/tests/Unit/Meta/MetaSchemaBuilderTest.php +++ b/tests/Unit/Meta/MetaSchemaBuilderTest.php @@ -119,6 +119,7 @@ expect($schema->objectType)->toBe($objectType) ->and($schema->subtypes)->toBe($subtypes) + ->and($schema->declaresSubtypes)->toBeFalse() ->and($schema->isEmpty())->toBeFalse(); })->with([ 'post types' => [ArticleExtras::class, MetaObjectType::Post, ['post', 'page']], @@ -126,3 +127,10 @@ 'users' => [MemberProfile::class, MetaObjectType::User, []], 'comments' => [ReviewMeta::class, MetaObjectType::Comment, []], ]); + +it('knows when the class declares its post type or taxonomy', function (): void { + expect((new MetaSchemaBuilder)->build(Event::class)->declaresSubtypes)->toBeTrue() + ->and((new MetaSchemaBuilder)->build(BookGenre::class)->declaresSubtypes)->toBeTrue() + ->and((new MetaSchemaBuilder)->build(Event::class)->exposesInRest())->toBeTrue() + ->and((new MetaSchemaBuilder)->build(ReviewMeta::class)->exposesInRest())->toBeFalse(); +}); diff --git a/tests/Unit/Meta/WordPressMetaRegistryTest.php b/tests/Unit/Meta/WordPressMetaRegistryTest.php index b3e5b358..bf4daec3 100644 --- a/tests/Unit/Meta/WordPressMetaRegistryTest.php +++ b/tests/Unit/Meta/WordPressMetaRegistryTest.php @@ -22,6 +22,7 @@ function registeredEventMeta(string $class = Event::class): array { $calls = []; Functions\when('did_action')->justReturn(1); + Functions\when('add_post_type_support')->justReturn(); Functions\when('register_meta')->alias(function (string $objectType, string $key, array $args) use (&$calls): bool { $calls[$key] = [$objectType, $args]; @@ -36,6 +37,7 @@ function registeredEventMeta(string $class = Event::class): array it('waits for init, after post types and taxonomies', function (): void { Functions\when('did_action')->justReturn(0); + Functions\when('add_post_type_support')->justReturn(); Functions\expect('register_meta')->never(); $action = Mockery::mock(Action::class); $action->shouldReceive('add')->once()->with('init', Mockery::type(Closure::class), 20)->andReturnUsing(function (string $hook, Closure $callback) use ($action): Action { @@ -114,6 +116,7 @@ function registeredEventMeta(string $class = Event::class): array it('registers the meta on each post type of the list, and for every user without a subtype', function (): void { $calls = []; Functions\when('did_action')->justReturn(1); + Functions\when('add_post_type_support')->justReturn(); Functions\when('register_meta')->alias(function (string $objectType, string $key, array $args) use (&$calls): bool { $calls[] = [$objectType, $key, $args['object_subtype']]; @@ -133,3 +136,14 @@ function registeredEventMeta(string $class = Event::class): array ['comment', 'rating', ''], ]); }); + +it('adds custom-fields to a declared post type exposing a meta in REST, and to no other', function (): void { + Functions\when('did_action')->justReturn(1); + Functions\when('register_meta')->justReturn(true); + Functions\expect('add_post_type_support')->once()->with('event', 'custom-fields'); + $registry = new WordPressMetaRegistry(Mockery::mock(Action::class), new MetaValueCaster); + + $registry->register((new MetaSchemaBuilder)->build(Event::class)); + $registry->register((new MetaSchemaBuilder)->build(ArticleExtras::class)); + $registry->register((new MetaSchemaBuilder)->build(BookGenre::class)); +}); From 2103897c7f1fa6fe856e4b914ecd7ad43848103f Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 6 Oct 2026 10:12:28 +0200 Subject: [PATCH 07/13] style: newline before assignment (rector) --- tests/Unit/Meta/WordPressMetaRegistryTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Unit/Meta/WordPressMetaRegistryTest.php b/tests/Unit/Meta/WordPressMetaRegistryTest.php index bf4daec3..2e084d82 100644 --- a/tests/Unit/Meta/WordPressMetaRegistryTest.php +++ b/tests/Unit/Meta/WordPressMetaRegistryTest.php @@ -38,6 +38,7 @@ function registeredEventMeta(string $class = Event::class): array it('waits for init, after post types and taxonomies', function (): void { Functions\when('did_action')->justReturn(0); Functions\when('add_post_type_support')->justReturn(); + Functions\expect('register_meta')->never(); $action = Mockery::mock(Action::class); $action->shouldReceive('add')->once()->with('init', Mockery::type(Closure::class), 20)->andReturnUsing(function (string $hook, Closure $callback) use ($action): Action { From 9cf185c77961f953d1e13268f29452b5c9223a92 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 6 Oct 2026 10:19:45 +0200 Subject: [PATCH 08/13] feat(meta): validation rules on typed meta, for PHP and REST writes --- CHANGELOG.md | 1 + src/Attributes/Meta.php | 2 + .../Application/Services/MetaAccessor.php | 4 +- .../Services/MetaSchemaBuilder.php | 1 + .../Contracts/MetaValidatorInterface.php | 21 +++ .../Exceptions/MetaValidationException.php | 24 ++++ src/Meta/Domain/Models/MetaDefinition.php | 2 + src/Meta/Domain/Models/MetaRecord.php | 11 +- .../Adapters/WordPressRestMetaValidation.php | 128 ++++++++++++++++++ .../Providers/MetaServiceProvider.php | 19 ++- .../Services/LaravelMetaValidator.php | 60 ++++++++ src/Models/Concerns/HasTypedMeta.php | 9 +- tests/Unit/Meta/Fixtures/RatedEvent.php | 24 ++++ tests/Unit/Meta/Fixtures/validator.php | 24 ++++ tests/Unit/Meta/HasTypedMetaTest.php | 14 ++ tests/Unit/Meta/LaravelMetaValidatorTest.php | 54 ++++++++ tests/Unit/Meta/MetaRecordTest.php | 15 ++ tests/Unit/Meta/MetaServiceProviderTest.php | 21 ++- .../Meta/WordPressRestMetaValidationTest.php | 75 ++++++++++ 19 files changed, 503 insertions(+), 6 deletions(-) create mode 100644 src/Meta/Domain/Contracts/MetaValidatorInterface.php create mode 100644 src/Meta/Domain/Exceptions/MetaValidationException.php create mode 100644 src/Meta/Infrastructure/Adapters/WordPressRestMetaValidation.php create mode 100644 src/Meta/Infrastructure/Services/LaravelMetaValidator.php create mode 100644 tests/Unit/Meta/Fixtures/RatedEvent.php create mode 100644 tests/Unit/Meta/Fixtures/validator.php create mode 100644 tests/Unit/Meta/LaravelMetaValidatorTest.php create mode 100644 tests/Unit/Meta/WordPressRestMetaValidationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index aa6c5b2f..263dc063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.4...develop) ### Added +- Typed meta validation: `#[Meta(rules: ['min:0', 'max:5000'])]` checks a value with Laravel's validator, the type rule implied (`integer` for an int, so `max` compares numbers). A write from PHP (`Meta::of()->set()`, a model attribute) throws a `MetaValidationException` naming the meta; a REST write to a post, term, user or comment answers 400 with the message of the rule (`params` → `meta.`) before anything is stored. Writes through WordPress's own functions (`update_post_meta()`) are only sanitized - 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 diff --git a/src/Attributes/Meta.php b/src/Attributes/Meta.php index 94180691..9e8ef790 100644 --- a/src/Attributes/Meta.php +++ b/src/Attributes/Meta.php @@ -38,6 +38,7 @@ * @param string|array{0: class-string|object, 1: string}|null $sanitize Callable replacing the sanitization derived from the type * @param string|null $capability Capability required to write the meta through REST and the editor * @param bool $revisions Versions the meta with post revisions (post types only) + * @param array $rules Laravel validation rules, checked on writes from PHP and REST */ public function __construct( public ?string $key = null, @@ -47,6 +48,7 @@ public function __construct( public string|array|null $sanitize = null, public ?string $capability = null, public bool $revisions = false, + public array $rules = [], ) {} /** diff --git a/src/Meta/Application/Services/MetaAccessor.php b/src/Meta/Application/Services/MetaAccessor.php index 5ae6e6bb..3e26e780 100644 --- a/src/Meta/Application/Services/MetaAccessor.php +++ b/src/Meta/Application/Services/MetaAccessor.php @@ -5,6 +5,7 @@ namespace Pollora\Meta\Application\Services; use Pollora\Meta\Domain\Contracts\MetaStoreInterface; +use Pollora\Meta\Domain\Contracts\MetaValidatorInterface; use Pollora\Meta\Domain\Exceptions\InvalidMetaValueException; use Pollora\Meta\Domain\Models\MetaDefinition; use Pollora\Meta\Domain\Models\MetaRecord; @@ -28,6 +29,7 @@ public function __construct( private MetaValueCaster $caster, private bool $debug = false, private ?LoggerInterface $logger = null, + private ?MetaValidatorInterface $validator = null, ) {} /** @@ -44,7 +46,7 @@ public function of(string $class, int $objectId): MetaRecord */ public function record(MetaSchema $schema, int $objectId): MetaRecord { - return new MetaRecord($schema, $objectId, $this->store, $this->caster, $this->handleUnreadable(...)); + return new MetaRecord($schema, $objectId, $this->store, $this->caster, $this->handleUnreadable(...), $this->validator); } private function handleUnreadable(InvalidMetaValueException $exception, MetaDefinition $definition): mixed diff --git a/src/Meta/Application/Services/MetaSchemaBuilder.php b/src/Meta/Application/Services/MetaSchemaBuilder.php index 15e45b37..aa0946e8 100644 --- a/src/Meta/Application/Services/MetaSchemaBuilder.php +++ b/src/Meta/Application/Services/MetaSchemaBuilder.php @@ -131,6 +131,7 @@ private function buildDefinition(string $class, ReflectionProperty $property, Me sanitize: $meta->sanitize, capability: $meta->capability, revisions: $meta->revisions, + rules: array_values($meta->rules), ); } diff --git a/src/Meta/Domain/Contracts/MetaValidatorInterface.php b/src/Meta/Domain/Contracts/MetaValidatorInterface.php new file mode 100644 index 00000000..d54175bd --- /dev/null +++ b/src/Meta/Domain/Contracts/MetaValidatorInterface.php @@ -0,0 +1,21 @@ + $messages The messages of the failed rules + */ + public function __construct( + public readonly MetaDefinition $definition, + public readonly array $messages, + ) { + parent::__construct(sprintf('The meta "%s" is invalid: %s', $definition->key, implode(' ', $messages))); + } +} diff --git a/src/Meta/Domain/Models/MetaDefinition.php b/src/Meta/Domain/Models/MetaDefinition.php index 324c5db9..883fc2af 100644 --- a/src/Meta/Domain/Models/MetaDefinition.php +++ b/src/Meta/Domain/Models/MetaDefinition.php @@ -29,6 +29,7 @@ * @param string|array{0: class-string|object, 1: string}|null $sanitize Callable replacing the derived sanitization * @param string|null $capability Capability required to write through REST and the editor * @param bool $revisions Versions the meta with post revisions + * @param array $rules Laravel validation rules */ public function __construct( public string $property, @@ -43,6 +44,7 @@ public function __construct( public string|array|null $sanitize = null, public ?string $capability = null, public bool $revisions = false, + public array $rules = [], ) {} /** diff --git a/src/Meta/Domain/Models/MetaRecord.php b/src/Meta/Domain/Models/MetaRecord.php index c610f162..38d80e04 100644 --- a/src/Meta/Domain/Models/MetaRecord.php +++ b/src/Meta/Domain/Models/MetaRecord.php @@ -7,7 +7,9 @@ use Closure; use InvalidArgumentException; use Pollora\Meta\Domain\Contracts\MetaStoreInterface; +use Pollora\Meta\Domain\Contracts\MetaValidatorInterface; use Pollora\Meta\Domain\Exceptions\InvalidMetaValueException; +use Pollora\Meta\Domain\Exceptions\MetaValidationException; use Pollora\Meta\Domain\Services\MetaValueCaster; /** @@ -43,6 +45,7 @@ public function __construct( private readonly MetaStoreInterface $store, private readonly MetaValueCaster $caster, private readonly Closure $onUnreadable, + private readonly ?MetaValidatorInterface $validator = null, ) {} public function __get(string $name): mixed @@ -83,12 +86,18 @@ public function get(string $name): mixed * Sets a meta, written on `save()`. Null deletes a nullable meta. * * @throws InvalidMetaValueException When the value does not match the property type + * @throws MetaValidationException When the value breaks a rule of the meta */ public function set(string $name, mixed $value): static { $definition = $this->definition($name); + $stored = $this->caster->toStorage($definition, $value); - $this->pending[$definition->property] = $this->caster->toStorage($definition, $value); + if ($stored !== null) { + $this->validator?->validate($definition, $value); + } + + $this->pending[$definition->property] = $stored; $this->values[$definition->property] = $value; return $this; diff --git a/src/Meta/Infrastructure/Adapters/WordPressRestMetaValidation.php b/src/Meta/Infrastructure/Adapters/WordPressRestMetaValidation.php new file mode 100644 index 00000000..337a9a9f --- /dev/null +++ b/src/Meta/Infrastructure/Adapters/WordPressRestMetaValidation.php @@ -0,0 +1,128 @@ + $handler The matched route handler + */ + public function validate(mixed $response, array $handler, WP_REST_Request $request): mixed + { + $meta = $request->get_param('meta'); + + if ($response instanceof WP_Error || ! is_array($meta) || $meta === [] || $request->get_method() === 'GET') { + return $response; + } + + [$objectType, $subtype] = $this->objectOf($handler['callback'][0] ?? null); + + if (! $objectType instanceof MetaObjectType) { + return $response; + } + + $errors = []; + + foreach ($this->schemas->forObject($objectType, $subtype) as $schema) { + foreach ($schema->definitions as $definition) { + if ($definition->rules === [] || ! array_key_exists($definition->key, $meta)) { + continue; + } + + $message = $this->check($definition, $meta[$definition->key]); + + if ($message !== null) { + $errors['meta.'.$definition->key] = $message; + } + } + } + + if ($errors === []) { + return $response; + } + + return new WP_Error( + 'rest_invalid_param', + /* translators: %s: List of invalid parameters. */ + sprintf(__('Invalid parameter(s): %s'), 'meta'), + ['status' => 400, 'params' => $errors] + ); + } + + private function check(MetaDefinition $definition, mixed $value): ?string + { + try { + // A value of the wrong type is left to WordPress's schema validation. + $phpValue = $this->caster->toPhp($definition, $value); + } catch (InvalidMetaValueException) { + return null; + } + + if ($phpValue === null) { + return null; + } + + try { + $this->validator->validate($definition, $phpValue); + } catch (MetaValidationException $metaValidationException) { + return implode(' ', $metaValidationException->messages); + } + + return null; + } + + /** + * The meta object type and subtype a core REST controller writes. + * + * @return array{0: MetaObjectType|null, 1: string|null} + */ + private function objectOf(mixed $controller): array + { + return match (true) { + $controller instanceof WP_REST_Posts_Controller => [MetaObjectType::Post, $this->property($controller, 'post_type')], + $controller instanceof WP_REST_Terms_Controller => [MetaObjectType::Term, $this->property($controller, 'taxonomy')], + $controller instanceof WP_REST_Users_Controller => [MetaObjectType::User, null], + $controller instanceof WP_REST_Comments_Controller => [MetaObjectType::Comment, null], + default => [null, null], + }; + } + + private function property(object $controller, string $name): ?string + { + $value = (new ReflectionProperty($controller, $name))->getValue($controller); + + return is_string($value) ? $value : null; + } +} diff --git a/src/Meta/Infrastructure/Providers/MetaServiceProvider.php b/src/Meta/Infrastructure/Providers/MetaServiceProvider.php index e0443d7f..fd5e44db 100644 --- a/src/Meta/Infrastructure/Providers/MetaServiceProvider.php +++ b/src/Meta/Infrastructure/Providers/MetaServiceProvider.php @@ -7,19 +7,24 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\ServiceProvider; use Pollora\Hook\Domain\Contract\Action; +use Pollora\Hook\Domain\Contract\Filter; use Pollora\Meta\Application\Services\MetaAccessor; use Pollora\Meta\Application\Services\MetaSchemaBuilder; use Pollora\Meta\Application\Services\MetaSchemaRepository; use Pollora\Meta\Domain\Contracts\MetaRegistryInterface; use Pollora\Meta\Domain\Contracts\MetaStoreInterface; +use Pollora\Meta\Domain\Contracts\MetaValidatorInterface; use Pollora\Meta\Domain\Services\MetaValueCaster; use Pollora\Meta\Infrastructure\Adapters\WordPressMetaRegistry; use Pollora\Meta\Infrastructure\Adapters\WordPressMetaStore; +use Pollora\Meta\Infrastructure\Adapters\WordPressRestMetaValidation; +use Pollora\Meta\Infrastructure\Services\LaravelMetaValidator; use Pollora\Meta\Infrastructure\Services\MetaDiscovery; use Psr\Log\LoggerInterface; /** - * Typed meta: `#[Meta]` discovery, `register_meta()` and `Meta::of()`. + * Typed meta: `#[Meta]` discovery, `register_meta()`, `Meta::of()`, and the + * `rules` of typed meta applied to REST writes. * * Bindings: * - `wp.meta` → {@see MetaAccessor} (singleton, used by the Meta facade) @@ -33,6 +38,7 @@ public function register(): void $this->app->singleton(MetaSchemaBuilder::class); $this->app->singleton(MetaSchemaRepository::class); $this->app->singleton(MetaStoreInterface::class, WordPressMetaStore::class); + $this->app->singleton(MetaValidatorInterface::class, fn (Application $app): LaravelMetaValidator => new LaravelMetaValidator($app->make('validator'))); $this->app->singleton(MetaRegistryInterface::class, fn (Application $app): WordPressMetaRegistry => new WordPressMetaRegistry( $app->make(Action::class), @@ -46,6 +52,7 @@ public function register(): void $app->make(MetaValueCaster::class), (bool) $app->make('config')->get('app.debug', false), $app->make(LoggerInterface::class), + $app->make(MetaValidatorInterface::class), )); $this->app->alias('wp.meta', MetaAccessor::class); @@ -56,4 +63,14 @@ public function register(): void $app->make(LoggerInterface::class), )); } + + public function boot(): void + { + $this->app->make(Filter::class)->add( + 'rest_request_before_callbacks', + fn (mixed $response, array $handler, \WP_REST_Request $request): mixed => $this->app->make(WordPressRestMetaValidation::class)->validate($response, $handler, $request), + 10, + 3 + ); + } } diff --git a/src/Meta/Infrastructure/Services/LaravelMetaValidator.php b/src/Meta/Infrastructure/Services/LaravelMetaValidator.php new file mode 100644 index 00000000..f9bff41f --- /dev/null +++ b/src/Meta/Infrastructure/Services/LaravelMetaValidator.php @@ -0,0 +1,60 @@ +rules === []) { + return; + } + + $rules = [...$this->impliedRules($definition), ...$definition->rules]; + $validator = $this->validator->make( + [$definition->key => $value instanceof BackedEnum ? $value->value : $value], + [$definition->key => $rules], + [], + [$definition->key => $definition->label ?? str_replace('_', ' ', $definition->key)], + ); + + if ($validator->fails()) { + throw new MetaValidationException($definition, array_values($validator->errors()->all())); + } + } + + /** + * @return list + */ + private function impliedRules(MetaDefinition $definition): array + { + $type = match ($definition->valueType) { + MetaValueType::String => 'string', + MetaValueType::Integer => 'integer', + MetaValueType::Number => 'numeric', + MetaValueType::Boolean => 'boolean', + MetaValueType::DateTime => 'date', + MetaValueType::Enum => null, + }; + + return array_values(array_filter([$definition->nullable ? 'nullable' : null, $type])); + } +} diff --git a/src/Models/Concerns/HasTypedMeta.php b/src/Models/Concerns/HasTypedMeta.php index e992fe9f..e2eaf445 100644 --- a/src/Models/Concerns/HasTypedMeta.php +++ b/src/Models/Concerns/HasTypedMeta.php @@ -14,6 +14,7 @@ use Pollora\Colt\Model\User; use Pollora\Meta\Application\Services\MetaAccessor; use Pollora\Meta\Application\Services\MetaSchemaRepository; +use Pollora\Meta\Domain\Contracts\MetaValidatorInterface; use Pollora\Meta\Domain\Enums\MetaObjectType; use Pollora\Meta\Domain\Enums\MetaValueType; use Pollora\Meta\Domain\Models\MetaDefinition; @@ -34,7 +35,7 @@ * * $event = Event::find($id); * $event->capacity; // int, or by its key: $event->starts_at - * $event->capacity = 250; // checked now, written on save() + * $event->capacity = 250; // type and rules checked now, written on save() * $event->save(); * Event::whereMeta('capacity', '>=', 100)->get(); * @@ -141,7 +142,11 @@ public function setAttribute($key, $value): mixed } $definition = $schema->find($key); - resolve(MetaValueCaster::class)->toStorage($definition, $value); + + if (resolve(MetaValueCaster::class)->toStorage($definition, $value) !== null) { + resolve(MetaValidatorInterface::class)->validate($definition, $value); + } + $this->pendingTypedMeta[$schema->declaringClass][$definition->property] = $value; return $this; diff --git a/tests/Unit/Meta/Fixtures/RatedEvent.php b/tests/Unit/Meta/Fixtures/RatedEvent.php new file mode 100644 index 00000000..cb31f5cb --- /dev/null +++ b/tests/Unit/Meta/Fixtures/RatedEvent.php @@ -0,0 +1,24 @@ +addMessages('en', 'validation', [ + 'max' => ['numeric' => 'The :attribute field must not be greater than :max.'], + 'min' => ['numeric' => 'The :attribute field must be at least :min.'], + 'in' => 'The selected :attribute is invalid.', + 'email' => 'The :attribute field must be a valid email address.', + 'integer' => 'The :attribute field must be an integer.', + ]); + + return new LaravelMetaValidator(new Factory(new Translator($loader, 'en'))); +} diff --git a/tests/Unit/Meta/HasTypedMetaTest.php b/tests/Unit/Meta/HasTypedMetaTest.php index 721665ba..969412ac 100644 --- a/tests/Unit/Meta/HasTypedMetaTest.php +++ b/tests/Unit/Meta/HasTypedMetaTest.php @@ -13,13 +13,18 @@ use Pollora\Meta\Application\Services\MetaSchemaBuilder; use Pollora\Meta\Application\Services\MetaSchemaRepository; use Pollora\Meta\Domain\Contracts\MetaStoreInterface; +use Pollora\Meta\Domain\Contracts\MetaValidatorInterface; use Pollora\Meta\Domain\Enums\MetaObjectType; use Pollora\Meta\Domain\Exceptions\InvalidMetaValueException; +use Pollora\Meta\Domain\Exceptions\MetaValidationException; use Pollora\Meta\Domain\Services\MetaValueCaster; use Pollora\Models\Post; use Pollora\Models\User; use Tests\Unit\Meta\Fixtures\Event; use Tests\Unit\Meta\Fixtures\MemberProfile; +use Tests\Unit\Meta\Fixtures\RatedEvent; + +require_once __DIR__.'/Fixtures/validator.php'; /** * A post model bound to the `event` post type, as a project would write it. @@ -92,6 +97,7 @@ public function version(): string $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)); + $container->instance(MetaValidatorInterface::class, metaValidator()); }); afterEach(function (): void { @@ -141,6 +147,14 @@ public function version(): string ->and($event->capacity)->toBe(300); }); +it('checks the rules of a meta when it is set', function (): void { + $this->repository->add((new MetaSchemaBuilder)->build(RatedEvent::class)); + $event = existingModel(Post::class, ['ID' => 5, 'post_type' => 'rated_event']); + + expect(fn (): int => $event->capacity = 6000)->toThrow(MetaValidationException::class) + ->and($event->hasPendingTypedMeta())->toBeFalse(); +}); + it('reads the default of a model not saved yet', function (): void { expect((new EventModel)->capacity)->toBe(0); }); diff --git a/tests/Unit/Meta/LaravelMetaValidatorTest.php b/tests/Unit/Meta/LaravelMetaValidatorTest.php new file mode 100644 index 00000000..b01924d1 --- /dev/null +++ b/tests/Unit/Meta/LaravelMetaValidatorTest.php @@ -0,0 +1,54 @@ +schema = (new MetaSchemaBuilder)->build(RatedEvent::class); +}); + +it('passes a value that keeps the rules', function (): void { + metaValidator()->validate($this->schema->definitions['capacity'], 5000); + metaValidator()->validate($this->schema->definitions['status'], EventStatus::Published); + metaValidator()->validate($this->schema->definitions['contact'], 'team@example.com'); + + expect(true)->toBeTrue(); +}); + +it('compares numbers as numbers, and names the meta by its label', function (): void { + metaValidator()->validate($this->schema->definitions['capacity'], 6000); +})->throws(MetaValidationException::class, 'The meta "capacity" is invalid: The Capacity field must not be greater than 5000.'); + +it('checks an enum by its backing value', function (): void { + metaValidator()->validate($this->schema->definitions['status'], EventStatus::Draft); +})->throws(MetaValidationException::class, 'The selected status is invalid.'); + +it('keeps the messages of the failed rules', function (): void { + $caught = null; + + try { + metaValidator()->validate($this->schema->definitions['contact'], 'nope'); + } catch (MetaValidationException $metaValidationException) { + $caught = $metaValidationException; + } + + expect($caught?->messages)->toBe(['The contact field must be a valid email address.']) + ->and($caught?->definition->key)->toBe('contact'); +}); + +it('skips a meta without rules', function (): void { + metaValidator()->validate($this->schema->definitions['free'], -1); + + expect(true)->toBeTrue(); +}); + +it('reads the rules from #[Meta]', function (): void { + expect($this->schema->definitions['capacity']->rules)->toBe(['min:0', 'max:5000']) + ->and($this->schema->definitions['free']->rules)->toBe([]); +}); diff --git a/tests/Unit/Meta/MetaRecordTest.php b/tests/Unit/Meta/MetaRecordTest.php index b57dba58..2e8d51e3 100644 --- a/tests/Unit/Meta/MetaRecordTest.php +++ b/tests/Unit/Meta/MetaRecordTest.php @@ -6,11 +6,15 @@ use Pollora\Meta\Domain\Contracts\MetaStoreInterface; use Pollora\Meta\Domain\Enums\MetaObjectType; use Pollora\Meta\Domain\Exceptions\InvalidMetaValueException; +use Pollora\Meta\Domain\Exceptions\MetaValidationException; use Pollora\Meta\Domain\Models\MetaDefinition; use Pollora\Meta\Domain\Models\MetaRecord; use Pollora\Meta\Domain\Services\MetaValueCaster; use Tests\Unit\Meta\Fixtures\Event; use Tests\Unit\Meta\Fixtures\EventStatus; +use Tests\Unit\Meta\Fixtures\RatedEvent; + +require_once __DIR__.'/Fixtures/validator.php'; /** * An in-memory meta store that records its writes. @@ -154,3 +158,14 @@ function eventRecord(MetaStoreInterface $store, ?Closure $onUnreadable = null): ->and(isset($record->startsAt))->toBeFalse() ->and(isset($record->unknown))->toBeFalse(); }); + +it('checks the rules of a meta on write, and keeps nothing pending when one fails', function (): void { + $store = memoryMetaStore(); + $record = new MetaRecord((new MetaSchemaBuilder)->build(RatedEvent::class), 42, $store, new MetaValueCaster, fn (): mixed => null, metaValidator()); + + expect(fn (): MetaRecord => $record->set('capacity', 6000))->toThrow(MetaValidationException::class); + + $record->set('capacity', 300)->set('contact', null)->save(); + + expect($store->stored)->toBe(['capacity' => '300']); +}); diff --git a/tests/Unit/Meta/MetaServiceProviderTest.php b/tests/Unit/Meta/MetaServiceProviderTest.php index d211aba6..2d8c478f 100644 --- a/tests/Unit/Meta/MetaServiceProviderTest.php +++ b/tests/Unit/Meta/MetaServiceProviderTest.php @@ -5,14 +5,20 @@ use Illuminate\Config\Repository; use Illuminate\Foundation\Application; use Illuminate\Support\Facades\Facade; +use Illuminate\Translation\ArrayLoader; +use Illuminate\Translation\Translator; +use Illuminate\Validation\Factory; use Pollora\Hook\Domain\Contract\Action; +use Pollora\Hook\Domain\Contract\Filter; use Pollora\Meta\Application\Services\MetaAccessor; use Pollora\Meta\Domain\Contracts\MetaRegistryInterface; use Pollora\Meta\Domain\Contracts\MetaStoreInterface; +use Pollora\Meta\Domain\Contracts\MetaValidatorInterface; use Pollora\Meta\Domain\Models\MetaRecord; use Pollora\Meta\Infrastructure\Adapters\WordPressMetaRegistry; use Pollora\Meta\Infrastructure\Adapters\WordPressMetaStore; use Pollora\Meta\Infrastructure\Providers\MetaServiceProvider; +use Pollora\Meta\Infrastructure\Services\LaravelMetaValidator; use Pollora\Meta\Infrastructure\Services\MetaDiscovery; use Pollora\Support\Facades\Meta; use Psr\Log\LoggerInterface; @@ -23,8 +29,13 @@ $this->app->instance('config', new Repository(['app' => ['debug' => true]])); $this->app->instance(Action::class, Mockery::mock(Action::class)); $this->app->instance(LoggerInterface::class, Mockery::mock(LoggerInterface::class)); + $this->app->instance('validator', new Factory(new Translator(new ArrayLoader, 'en'))); - (new MetaServiceProvider($this->app))->register(); + $this->filter = Mockery::mock(Filter::class); + $this->app->instance(Filter::class, $this->filter); + + $this->provider = new MetaServiceProvider($this->app); + $this->provider->register(); }); afterEach(function (): void { @@ -44,3 +55,11 @@ expect(Meta::of(Event::class, 42))->toBeInstanceOf(MetaRecord::class); }); + +it('binds the Laravel validator and applies the rules to REST writes', function (): void { + $this->filter->shouldReceive('add')->once()->with('rest_request_before_callbacks', Mockery::type(Closure::class), 10, 3)->andReturnSelf(); + + $this->provider->boot(); + + expect($this->app->make(MetaValidatorInterface::class))->toBeInstanceOf(LaravelMetaValidator::class); +}); diff --git a/tests/Unit/Meta/WordPressRestMetaValidationTest.php b/tests/Unit/Meta/WordPressRestMetaValidationTest.php new file mode 100644 index 00000000..6b770306 --- /dev/null +++ b/tests/Unit/Meta/WordPressRestMetaValidationTest.php @@ -0,0 +1,75 @@ + 'post_type', 'WP_REST_Terms_Controller' => 'taxonomy'] as $class => $property) { + if (! class_exists($class)) { + eval("class {$class} { public function __construct(protected string \${$property} = '') {} }"); + } +} + +foreach (['WP_REST_Users_Controller', 'WP_REST_Comments_Controller'] as $class) { + if (! class_exists($class)) { + eval("class {$class} {}"); + } +} + +// The same stand-in as the other tests, whichever loads first. +if (! class_exists('WP_REST_Request')) { + eval('class WP_REST_Request { public function __construct(private string $method = "", private string $route = "") {} public function get_route(): string { return $this->route; } }'); +} + +/** + * A REST write of the given meta, as WordPress hands it to the filter. + * + * @param array|null $meta + */ +function restMetaWrite(?array $meta, string $method = 'POST'): WP_REST_Request +{ + $request = Mockery::mock(WP_REST_Request::class); + $request->shouldReceive('get_param')->with('meta')->andReturn($meta); + $request->shouldReceive('get_method')->andReturn($method); + + return $request; +} + +beforeEach(function (): void { + $schemas = new MetaSchemaRepository; + $schemas->add((new MetaSchemaBuilder)->build(RatedEvent::class)); + $schemas->add((new MetaSchemaBuilder)->build(MemberProfile::class)); + + $this->validation = new WordPressRestMetaValidation($schemas, new MetaValueCaster, metaValidator()); + $this->posts = ['callback' => [new WP_REST_Posts_Controller('rated_event'), 'update_item']]; +}); + +it('refuses with a 400 a write that breaks a rule, before anything is stored', function (): void { + expect($this->validation->validate(null, $this->posts, restMetaWrite(['capacity' => 6000])))->toBeInstanceOf(WP_Error::class); +}); + +it('lets through a valid write, a meta without rules, and a value of the wrong type for WordPress to refuse', function (): void { + expect($this->validation->validate(null, $this->posts, restMetaWrite(['capacity' => 300, 'free' => -5])))->toBeNull() + ->and($this->validation->validate(null, $this->posts, restMetaWrite(['capacity' => 'many'])))->toBeNull(); +}); + +it('only looks at writes with meta to a core object route', function (): void { + expect($this->validation->validate(null, $this->posts, restMetaWrite(['capacity' => 6000], 'GET')))->toBeNull() + ->and($this->validation->validate(null, $this->posts, restMetaWrite(null)))->toBeNull() + ->and($this->validation->validate(null, ['callback' => [new WP_REST_Posts_Controller('page'), 'update_item']], restMetaWrite(['capacity' => 6000])))->toBeNull() + ->and($this->validation->validate(null, ['callback' => 'some_function'], restMetaWrite(['capacity' => 6000])))->toBeNull() + ->and($this->validation->validate(null, ['callback' => [new WP_REST_Users_Controller, 'update_item']], restMetaWrite(['capacity' => 6000])))->toBeNull(); +}); + +it('keeps an error an earlier filter returned', function (): void { + $error = new WP_Error; + + expect($this->validation->validate($error, $this->posts, restMetaWrite(['capacity' => 6000])))->toBe($error); +}); From d8d7d7675c488c8b1a1326f7444509bf8923dd8a Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 6 Oct 2026 10:29:02 +0200 Subject: [PATCH 09/13] feat(meta): arrays and data objects as typed meta Arrays of typed items, stored one row per item (single: false) or as one serialized array; classes with public typed properties, stored as arrays. REST schemas with items and properties; whereMeta() on rows. --- src/Attributes/Meta.php | 4 + .../Services/MetaSchemaBuilder.php | 138 ++++++++++++- .../Domain/Contracts/MetaStoreInterface.php | 19 +- src/Meta/Domain/Enums/MetaValueType.php | 6 + src/Meta/Domain/Models/MetaDefinition.php | 56 ++++++ src/Meta/Domain/Models/MetaRecord.php | 18 +- src/Meta/Domain/Services/MetaValueCaster.php | 189 +++++++++++++++-- .../Adapters/WordPressMetaRegistry.php | 31 ++- .../Adapters/WordPressMetaStore.php | 16 +- .../Services/LaravelMetaValidator.php | 18 +- src/Models/Concerns/HasTypedMeta.php | 17 +- tests/Unit/Meta/Fixtures/Conference.php | 31 +++ tests/Unit/Meta/Fixtures/Invalid.php | 41 ++++ tests/Unit/Meta/Fixtures/Schedule.php | 20 ++ tests/Unit/Meta/HasTypedMetaTest.php | 30 ++- tests/Unit/Meta/MetaArraysAndObjectsTest.php | 190 ++++++++++++++++++ tests/Unit/Meta/MetaDiscoveryTest.php | 2 +- tests/Unit/Meta/MetaRecordTest.php | 15 +- tests/Unit/Meta/MetaSchemaBuilderTest.php | 2 +- 19 files changed, 798 insertions(+), 45 deletions(-) create mode 100644 tests/Unit/Meta/Fixtures/Conference.php create mode 100644 tests/Unit/Meta/Fixtures/Schedule.php create mode 100644 tests/Unit/Meta/MetaArraysAndObjectsTest.php diff --git a/src/Attributes/Meta.php b/src/Attributes/Meta.php index 9e8ef790..efcd5d89 100644 --- a/src/Attributes/Meta.php +++ b/src/Attributes/Meta.php @@ -39,6 +39,8 @@ * @param string|null $capability Capability required to write the meta through REST and the editor * @param bool $revisions Versions the meta with post revisions (post types only) * @param array $rules Laravel validation rules, checked on writes from PHP and REST + * @param bool $single On an `array` property, false stores one row per item instead of one serialized array + * @param string|null $items On an `array` property, the item type: `'string'`, `'int'`, `'float'`, `'bool'` or a class. Defaults to the `@var list<…>` docblock */ public function __construct( public ?string $key = null, @@ -49,6 +51,8 @@ public function __construct( public ?string $capability = null, public bool $revisions = false, public array $rules = [], + public bool $single = true, + public ?string $items = null, ) {} /** diff --git a/src/Meta/Application/Services/MetaSchemaBuilder.php b/src/Meta/Application/Services/MetaSchemaBuilder.php index aa0946e8..c67855a2 100644 --- a/src/Meta/Application/Services/MetaSchemaBuilder.php +++ b/src/Meta/Application/Services/MetaSchemaBuilder.php @@ -6,6 +6,7 @@ use BackedEnum; use DateTimeInterface; +use Illuminate\Support\Str; use Pollora\Attributes\CommentMeta; use Pollora\Attributes\Meta; use Pollora\Attributes\PostMeta; @@ -99,12 +100,20 @@ private function buildDefinition(string $class, ReflectionProperty $property, Me throw InvalidMetaDefinitionException::forProperty($class, $name, 'the property needs a single type (no union, no untyped property).'); } - [$valueType, $valueClass] = $this->resolveValueType($class, $name, $type); + [$valueType, $valueClass] = $this->resolveValueType($class, $name, $type->getName()); - if (! $property->hasDefaultValue() && ! $type->allowsNull()) { + // A data object without a default reads as an instance with its own defaults. + if (! $property->hasDefaultValue() && ! $type->allowsNull() && $valueType !== MetaValueType::DataObject) { throw InvalidMetaDefinitionException::forProperty($class, $name, 'give the property a default value or make it nullable: it is what an absent meta reads as.'); } + if (! $meta->single && $valueType !== MetaValueType::ArrayOf) { + throw InvalidMetaDefinitionException::forProperty($class, $name, 'single: false stores one row per item, so it needs an array property.'); + } + + $items = $valueType === MetaValueType::ArrayOf ? $this->buildItems($class, $property, $meta) : null; + $properties = $valueType === MetaValueType::DataObject ? $this->buildProperties($class, $name, (string) $valueClass) : []; + $key = $meta->resolveKey($name); if ($meta->showInRest && str_starts_with($key, '_') && $meta->capability === null) { @@ -132,16 +141,17 @@ private function buildDefinition(string $class, ReflectionProperty $property, Me capability: $meta->capability, revisions: $meta->revisions, rules: array_values($meta->rules), + single: $meta->single, + items: $items, + properties: $properties, ); } /** * @return array{0: MetaValueType, 1: class-string|null} */ - private function resolveValueType(string $class, string $property, ReflectionNamedType $type): array + private function resolveValueType(string $class, string $property, string $typeName): array { - $typeName = $type->getName(); - $scalar = match ($typeName) { 'string' => MetaValueType::String, 'int' => MetaValueType::Integer, @@ -162,10 +172,126 @@ private function resolveValueType(string $class, string $property, ReflectionNam return [MetaValueType::Enum, $typeName]; } + if ($typeName === 'array') { + return [MetaValueType::ArrayOf, null]; + } + + if (class_exists($typeName) && ! is_a($typeName, UnitEnum::class, true) && (new ReflectionClass($typeName))->isInstantiable()) { + return [MetaValueType::DataObject, $typeName]; + } + $reason = is_a($typeName, UnitEnum::class, true) ? sprintf('the enum %s needs backing values (enum %s: string).', $typeName, class_basename($typeName)) - : sprintf('the type %s is not supported yet; use string, int, float, bool, a date or a backed enum.', $typeName); + : sprintf('the type %s is not supported; use string, int, float, bool, a date, a backed enum, an array or a class with public typed properties.', $typeName); throw InvalidMetaDefinitionException::forProperty($class, $property, $reason); } + + /** + * The item of an array property, from `items:` or the `@var list<…>` docblock. + */ + private function buildItems(string $class, ReflectionProperty $property, Meta $meta): MetaDefinition + { + $name = $property->getName(); + $itemType = $meta->items ?? $this->itemTypeFromDocblock((string) $property->getDocComment()); + + if ($itemType === null) { + throw InvalidMetaDefinitionException::forProperty($class, $name, "say what the array holds, with items: 'string' (or int, float, bool, a class) or a @var list docblock."); + } + + $itemType = ltrim($itemType, '\\'); + + if ($itemType === 'array') { + throw InvalidMetaDefinitionException::forProperty($class, $name, 'an item cannot be an array: use a class with public typed properties.'); + } + + $scalar = ['string' => 'string', 'int' => 'int', 'integer' => 'int', 'float' => 'float', 'bool' => 'bool', 'boolean' => 'bool'][$itemType] ?? null; + + if ($scalar === null && ! class_exists($itemType) && ! enum_exists($itemType)) { + throw InvalidMetaDefinitionException::forProperty($class, $name, sprintf('the item type %s is unknown: use string, int, float, bool or a fully qualified class.', $itemType)); + } + + [$valueType, $valueClass] = $this->resolveValueType($class, $name, $scalar ?? $itemType); + + if ($valueType === MetaValueType::ArrayOf || ($valueType === MetaValueType::DataObject && ! $meta->single)) { + throw InvalidMetaDefinitionException::forProperty($class, $name, 'an item cannot be an array, and objects need single: true.'); + } + + return new MetaDefinition( + property: $name, + key: $meta->resolveKey($name), + valueType: $valueType, + valueClass: $valueClass, + nullable: false, + default: null, + properties: $valueType === MetaValueType::DataObject ? $this->buildProperties($class, $name, (string) $valueClass) : [], + ); + } + + private function itemTypeFromDocblock(string $docblock): ?string + { + if (preg_match('/@var\s+(?:list<\s*([\w\\\\]+)\s*>|array<\s*(?:int\s*,\s*)?([\w\\\\]+)\s*>|([\w\\\\]+)\[\])/', $docblock, $matches) !== 1) { + return null; + } + + return $matches[1] ?: ($matches[2] ?? '') ?: ($matches[3] ?? null); + } + + /** + * The properties of a data object: public typed properties of a scalar, date + * or enum type, each with a default or nullable. + * + * @param class-string|string $objectClass + * @return array + */ + private function buildProperties(string $class, string $name, string $objectClass): array + { + $reflection = new ReflectionClass($objectClass); + $defaults = []; + + foreach ($reflection->getConstructor()?->getParameters() ?? [] as $parameter) { + if ($parameter->isPromoted() && $parameter->isDefaultValueAvailable()) { + $defaults[$parameter->getName()] = $parameter->getDefaultValue(); + } + } + + $properties = []; + + foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + if ($property->isStatic()) { + continue; + } + + $propertyName = $property->getName(); + $type = $property->getType(); + $where = sprintf('%s::$%s', $objectClass, $propertyName); + + if (! $type instanceof ReflectionNamedType) { + throw InvalidMetaDefinitionException::forProperty($class, $name, sprintf('%s needs a single type.', $where)); + } + + [$valueType, $valueClass] = $this->resolveValueType($class, $name, $type->getName()); + + if ($valueType === MetaValueType::ArrayOf || $valueType === MetaValueType::DataObject) { + throw InvalidMetaDefinitionException::forProperty($class, $name, sprintf('%s: an object property can be a string, int, float, bool, date or backed enum, not an array or an object.', $where)); + } + + $hasDefault = $property->hasDefaultValue() || array_key_exists($propertyName, $defaults); + + if (! $hasDefault && ! $type->allowsNull()) { + throw InvalidMetaDefinitionException::forProperty($class, $name, sprintf('%s needs a default value or a nullable type.', $where)); + } + + $properties[$propertyName] = new MetaDefinition( + property: $propertyName, + key: Str::snake($propertyName), + valueType: $valueType, + valueClass: $valueClass, + nullable: $type->allowsNull(), + default: $property->hasDefaultValue() ? $property->getDefaultValue() : ($defaults[$propertyName] ?? null), + ); + } + + return $properties; + } } diff --git a/src/Meta/Domain/Contracts/MetaStoreInterface.php b/src/Meta/Domain/Contracts/MetaStoreInterface.php index 21df8e4f..a339ed41 100644 --- a/src/Meta/Domain/Contracts/MetaStoreInterface.php +++ b/src/Meta/Domain/Contracts/MetaStoreInterface.php @@ -16,7 +16,24 @@ interface MetaStoreInterface */ public function get(MetaObjectType $objectType, int $objectId, string $key): mixed; - public function update(MetaObjectType $objectType, int $objectId, string $key, string $value): void; + /** + * Every stored row of a non-single meta, in order. + * + * @return list + */ + public function getAll(MetaObjectType $objectType, int $objectId, string $key): array; + + /** + * @param string|array $value A string, or an array WordPress serializes + */ + public function update(MetaObjectType $objectType, int $objectId, string $key, string|array $value): void; + + /** + * Replaces every row of a non-single meta. + * + * @param list $values + */ + public function replaceAll(MetaObjectType $objectType, int $objectId, string $key, array $values): void; public function delete(MetaObjectType $objectType, int $objectId, string $key): void; } diff --git a/src/Meta/Domain/Enums/MetaValueType.php b/src/Meta/Domain/Enums/MetaValueType.php index c9bfa8d0..fbcf1fb3 100644 --- a/src/Meta/Domain/Enums/MetaValueType.php +++ b/src/Meta/Domain/Enums/MetaValueType.php @@ -15,4 +15,10 @@ enum MetaValueType case Boolean; case DateTime; case Enum; + + /** A PHP array of typed items: one row per item, or one serialized array */ + case ArrayOf; + + /** A class with public typed properties, stored as an array */ + case DataObject; } diff --git a/src/Meta/Domain/Models/MetaDefinition.php b/src/Meta/Domain/Models/MetaDefinition.php index 883fc2af..4e236ecb 100644 --- a/src/Meta/Domain/Models/MetaDefinition.php +++ b/src/Meta/Domain/Models/MetaDefinition.php @@ -5,6 +5,7 @@ namespace Pollora\Meta\Domain\Models; use BackedEnum; +use LogicException; use Pollora\Meta\Domain\Enums\MetaValueType; use ReflectionEnum; @@ -30,6 +31,9 @@ * @param string|null $capability Capability required to write through REST and the editor * @param bool $revisions Versions the meta with post revisions * @param array $rules Laravel validation rules + * @param bool $single False for an array stored one row per item + * @param MetaDefinition|null $items The item of an array + * @param array $properties The properties of a data object, by property name */ public function __construct( public string $property, @@ -45,6 +49,9 @@ public function __construct( public ?string $capability = null, public bool $revisions = false, public array $rules = [], + public bool $single = true, + public ?MetaDefinition $items = null, + public array $properties = [], ) {} /** @@ -66,6 +73,9 @@ public function wordPressType(): string MetaValueType::Number => 'number', MetaValueType::Boolean => 'boolean', MetaValueType::Enum => $this->isIntegerBackedEnum() ? 'integer' : 'string', + // A non-single meta registers the type of one row: WordPress wraps it in an array. + MetaValueType::ArrayOf => $this->single ? 'array' : $this->item()->wordPressType(), + MetaValueType::DataObject => 'object', }; } @@ -76,6 +86,20 @@ public function wordPressType(): string */ public function restSchema(): array { + if ($this->valueType === MetaValueType::ArrayOf) { + return $this->single ? ['type' => 'array', 'items' => $this->item()->restSchema()] : $this->item()->restSchema(); + } + + if ($this->valueType === MetaValueType::DataObject) { + $properties = []; + + foreach ($this->properties as $property) { + $properties[$property->key] = $property->nullableRestSchema(); + } + + return ['type' => 'object', 'properties' => $properties, 'additionalProperties' => false]; + } + $schema = ['type' => $this->wordPressType()]; if ($this->valueType === MetaValueType::DateTime) { @@ -91,6 +115,38 @@ public function restSchema(): array return $schema; } + /** + * The item of an array. + */ + public function item(): MetaDefinition + { + return $this->items ?? throw new LogicException(sprintf('The meta "%s" is not an array.', $this->key)); + } + + /** + * Whether the value is an array or an object, stored serialized or in several rows. + */ + public function isStructured(): bool + { + return $this->valueType === MetaValueType::ArrayOf || $this->valueType === MetaValueType::DataObject; + } + + /** + * The REST schema of a property inside an object, accepting null when it does. + * + * @return array + */ + private function nullableRestSchema(): array + { + $schema = $this->restSchema(); + + if ($this->nullable && is_string($schema['type'])) { + $schema['type'] = [$schema['type'], 'null']; + } + + return $schema; + } + private function isIntegerBackedEnum(): bool { /** @var class-string $enum */ diff --git a/src/Meta/Domain/Models/MetaRecord.php b/src/Meta/Domain/Models/MetaRecord.php index 38d80e04..57829a0d 100644 --- a/src/Meta/Domain/Models/MetaRecord.php +++ b/src/Meta/Domain/Models/MetaRecord.php @@ -32,7 +32,7 @@ final class MetaRecord private array $values = []; /** - * @var array Stored forms waiting for save(), by property name + * @var array|null> Stored forms waiting for save(), by property name */ private array $pending = []; @@ -121,13 +121,13 @@ public function fill(array $values): static public function save(): static { foreach ($this->pending as $property => $stored) { - $key = $this->schema->definitions[$property]->key; + $definition = $this->schema->definitions[$property]; - if ($stored === null) { - $this->store->delete($this->schema->objectType, $this->objectId, $key); - } else { - $this->store->update($this->schema->objectType, $this->objectId, $key, $stored); - } + match (true) { + $stored === null => $this->store->delete($this->schema->objectType, $this->objectId, $definition->key), + ! $definition->single && is_array($stored) => $this->store->replaceAll($this->schema->objectType, $this->objectId, $definition->key, array_values(array_map(strval(...), $stored))), + default => $this->store->update($this->schema->objectType, $this->objectId, $definition->key, $stored), + }; } $this->pending = []; @@ -162,7 +162,9 @@ private function definition(string $name): MetaDefinition private function read(MetaDefinition $definition): mixed { - $raw = $this->store->get($this->schema->objectType, $this->objectId, $definition->key); + $raw = $definition->single + ? $this->store->get($this->schema->objectType, $this->objectId, $definition->key) + : $this->store->getAll($this->schema->objectType, $this->objectId, $definition->key); try { return $this->caster->toPhp($definition, $raw); diff --git a/src/Meta/Domain/Services/MetaValueCaster.php b/src/Meta/Domain/Services/MetaValueCaster.php index 277d3d47..69ebee88 100644 --- a/src/Meta/Domain/Services/MetaValueCaster.php +++ b/src/Meta/Domain/Services/MetaValueCaster.php @@ -13,15 +13,18 @@ use Pollora\Meta\Domain\Enums\MetaValueType; use Pollora\Meta\Domain\Exceptions\InvalidMetaValueException; use Pollora\Meta\Domain\Models\MetaDefinition; +use ReflectionClass; use Stringable; use Throwable; /** - * Converts meta values between their PHP type and the string WordPress stores. + * Converts meta values between their PHP type and what WordPress stores. * * Reading is tolerant (a boolean accepts `1`, `true`, `yes`, `on`…), writing is * strict (the PHP value must match the property type). Dates are stored in - * ISO 8601, in UTC. + * ISO 8601, in UTC. A scalar is stored as a string; an array as a list of + * strings (one row each) or a serialized array of JSON values; a data object + * as a serialized array, never as a PHP object. */ final class MetaValueCaster { @@ -35,36 +38,52 @@ final class MetaValueCaster public function toPhp(MetaDefinition $definition, mixed $raw): mixed { if ($raw === null || $raw === '') { - return $definition->default; + return $this->absentValue($definition); } - $value = match ($definition->valueType) { - MetaValueType::String => is_scalar($raw) ? (string) $raw : null, - MetaValueType::Integer => $this->readInteger($raw), - MetaValueType::Number => is_numeric($raw) ? (float) $raw : null, - MetaValueType::Boolean => is_scalar($raw) ? filter_var($raw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) : null, - MetaValueType::DateTime => $this->readDate($definition, $raw), - MetaValueType::Enum => $this->readEnum($definition, $raw), - }; + return $this->read($definition, $raw) ?? throw InvalidMetaValueException::forRead($definition, $raw); + } - if ($value === null) { - throw InvalidMetaValueException::forRead($definition, $raw); + /** + * What an absent meta reads as: the property default, or for a data object + * without one, an instance with its own defaults. + */ + public function absentValue(MetaDefinition $definition): mixed + { + if ($definition->valueType === MetaValueType::DataObject && $definition->default === null && ! $definition->nullable) { + return $this->readObject($definition, []); } - return $value; + return $definition->default; } /** * The stored form of a value written from PHP. Null means the meta is deleted. * + * A scalar gives a string; an array a list of strings (one row each) when the + * meta is not single, otherwise a list of JSON values; a data object an array + * of JSON values by property key. + * + * @return string|array|null + * * @throws InvalidMetaValueException When the value does not match the property type */ - public function toStorage(MetaDefinition $definition, mixed $value): ?string + public function toStorage(MetaDefinition $definition, mixed $value): string|array|null { if ($value === null) { return $definition->nullable ? null : throw InvalidMetaValueException::forWrite($definition, $value); } + if ($definition->valueType === MetaValueType::ArrayOf && ! $definition->single) { + return is_array($value) + ? array_values(array_map(fn (mixed $item): string => (string) $this->toStorage($definition->item(), $item), $value)) + : throw InvalidMetaValueException::forWrite($definition, $value); + } + + if ($definition->isStructured()) { + return $this->toJson($definition, $value); + } + $stored = match ($definition->valueType) { MetaValueType::String => is_string($value) || $value instanceof Stringable ? (string) $value : null, MetaValueType::Integer => is_int($value) ? (string) $value : null, @@ -72,17 +91,44 @@ public function toStorage(MetaDefinition $definition, mixed $value): ?string MetaValueType::Boolean => is_bool($value) ? ($value ? '1' : '0') : null, MetaValueType::DateTime => $value instanceof DateTimeInterface ? $this->formatDate($value) : null, MetaValueType::Enum => $value instanceof BackedEnum && $value::class === $definition->valueClass ? (string) $value->value : null, + MetaValueType::ArrayOf, MetaValueType::DataObject => null, }; return $stored ?? throw InvalidMetaValueException::forWrite($definition, $value); } + /** + * A value as JSON can carry it, inside a serialized array and in REST: numbers + * and booleans keep their type, dates and enums become strings or integers. + * + * @throws InvalidMetaValueException When the value does not match the declared type + */ + public function toJson(MetaDefinition $definition, mixed $value): mixed + { + if ($value === null) { + return $definition->nullable ? null : throw InvalidMetaValueException::forWrite($definition, $value); + } + + $json = match ($definition->valueType) { + MetaValueType::String => is_string($value) || $value instanceof Stringable ? (string) $value : null, + MetaValueType::Integer => is_int($value) ? $value : null, + MetaValueType::Number => is_int($value) || is_float($value) ? $value : null, + MetaValueType::Boolean => is_bool($value) ? $value : null, + MetaValueType::DateTime => $value instanceof DateTimeInterface ? $this->formatDate($value) : null, + MetaValueType::Enum => $value instanceof BackedEnum && $value::class === $definition->valueClass ? $value->value : null, + MetaValueType::ArrayOf => is_array($value) ? array_values(array_map(fn (mixed $item): mixed => $this->toJson($definition->item(), $item), $value)) : null, + MetaValueType::DataObject => $this->objectToJson($definition, $value), + }; + + return $json ?? throw InvalidMetaValueException::forWrite($definition, $value); + } + /** * Normalizes any value written through WordPress (REST, editor, `update_post_meta()`) * to the stored form. Used as `sanitize_callback`: an unreadable value becomes * an empty string, which reads back as the default. */ - public function sanitize(MetaDefinition $definition, mixed $value): string + public function sanitize(MetaDefinition $definition, mixed $value): mixed { // Empty stays empty (an absent value), so sanitizing twice changes nothing. if ($value === null || $value === '') { @@ -90,7 +136,7 @@ public function sanitize(MetaDefinition $definition, mixed $value): string } try { - $phpValue = $value instanceof DateTimeInterface || $value instanceof BackedEnum + $phpValue = $value instanceof DateTimeInterface || $value instanceof BackedEnum || ($definition->valueType === MetaValueType::DataObject && is_object($value)) ? $value : $this->toPhp($definition, $value); @@ -103,17 +149,122 @@ public function sanitize(MetaDefinition $definition, mixed $value): string /** * The default as the REST API publishes it, or null when there is none. */ - public function toRestDefault(MetaDefinition $definition): int|float|bool|string|null + public function toRestDefault(MetaDefinition $definition): mixed { - $default = $definition->default; + $default = $this->absentValue($definition); return match (true) { $default === null => null, + // A non-single meta has a default per row, which a list cannot give. + $definition->valueType === MetaValueType::ArrayOf && ! $definition->single => null, + $definition->isStructured() => $this->toJson($definition, $default), $default instanceof BackedEnum => $default->value, default => $default, }; } + /** + * The value of a raw stored or JSON value, or null when it cannot be read. + */ + private function read(MetaDefinition $definition, mixed $raw): mixed + { + return match ($definition->valueType) { + MetaValueType::String => is_scalar($raw) ? (string) $raw : null, + MetaValueType::Integer => $this->readInteger($raw), + MetaValueType::Number => is_numeric($raw) ? (float) $raw : null, + MetaValueType::Boolean => is_scalar($raw) ? filter_var($raw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) : null, + MetaValueType::DateTime => $this->readDate($definition, $raw), + MetaValueType::Enum => $this->readEnum($definition, $raw), + MetaValueType::ArrayOf => $this->readArray($definition, $raw), + MetaValueType::DataObject => is_array($raw) ? $this->readObject($definition, $raw) : null, + }; + } + + /** + * @return list|null + */ + private function readArray(MetaDefinition $definition, mixed $raw): ?array + { + if (! is_array($raw)) { + return null; + } + + $items = []; + + foreach ($raw as $item) { + $value = $this->read($definition->item(), $item); + + if ($value === null) { + return null; + } + + $items[] = $value; + } + + return $items; + } + + /** + * An instance of the data object, its properties read from the array; a + * missing property keeps its default. + * + * @param array $raw + */ + private function readObject(MetaDefinition $definition, array $raw): ?object + { + /** @var class-string $class */ + $class = $definition->valueClass; + $object = (new ReflectionClass($class))->newInstanceWithoutConstructor(); + + foreach ($definition->properties as $property) { + if (! array_key_exists($property->key, $raw)) { + $object->{$property->property} = $property->default; + + continue; + } + + $item = $raw[$property->key]; + + if ($item === null) { + if (! $property->nullable) { + return null; + } + + $object->{$property->property} = null; + + continue; + } + + $value = $this->read($property, $item); + + if ($value === null) { + return null; + } + + $object->{$property->property} = $value; + } + + return $object; + } + + /** + * @return array|null + */ + private function objectToJson(MetaDefinition $definition, mixed $value): ?array + { + if (! is_object($value) || ! is_a($value, (string) $definition->valueClass)) { + return null; + } + + $json = []; + + foreach ($definition->properties as $property) { + $json[$property->key] = $this->toJson($property, $value->{$property->property} ?? null); + } + + return $json; + } + private function readInteger(mixed $raw): ?int { $value = is_scalar($raw) ? filter_var($raw, FILTER_VALIDATE_INT) : false; diff --git a/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php b/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php index a371046a..4f6878d7 100644 --- a/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php +++ b/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php @@ -54,7 +54,7 @@ private function argumentsFor(MetaSchema $schema, MetaDefinition $definition, st $arguments = [ 'object_subtype' => $subtype, 'type' => $definition->wordPressType(), - 'single' => true, + 'single' => $definition->single, 'sanitize_callback' => $definition->sanitize ?? $this->sanitizerFor($definition), 'show_in_rest' => $definition->showInRest ? ['schema' => $definition->restSchema()] : false, ]; @@ -87,10 +87,37 @@ private function argumentsFor(MetaSchema $schema, MetaDefinition $definition, st private function sanitizerFor(MetaDefinition $definition): callable { + // WordPress sanitizes each row of a non-single meta on its own. + if ($definition->valueType === MetaValueType::ArrayOf && ! $definition->single) { + return $this->sanitizerFor($definition->item()); + } + if ($definition->valueType === MetaValueType::String) { return 'sanitize_text_field'; } - return fn (mixed $value): string => $this->caster->sanitize($definition, $value); + return fn (mixed $value): mixed => $this->sanitizeText($definition, $this->caster->sanitize($definition, $value)); + } + + /** + * Strips HTML from the strings inside an array or an object, as from a string meta. + */ + private function sanitizeText(MetaDefinition $definition, mixed $value): mixed + { + if (! is_array($value)) { + return $definition->valueType === MetaValueType::String && is_string($value) ? \sanitize_text_field($value) : $value; + } + + if ($definition->valueType === MetaValueType::ArrayOf) { + return array_map(fn (mixed $item): mixed => $this->sanitizeText($definition->item(), $item), $value); + } + + foreach ($definition->properties as $property) { + if (array_key_exists($property->key, $value)) { + $value[$property->key] = $this->sanitizeText($property, $value[$property->key]); + } + } + + return $value; } } diff --git a/src/Meta/Infrastructure/Adapters/WordPressMetaStore.php b/src/Meta/Infrastructure/Adapters/WordPressMetaStore.php index 702995ad..bb967fea 100644 --- a/src/Meta/Infrastructure/Adapters/WordPressMetaStore.php +++ b/src/Meta/Infrastructure/Adapters/WordPressMetaStore.php @@ -20,11 +20,25 @@ public function get(MetaObjectType $objectType, int $objectId, string $key): mix return \get_metadata_raw($objectType->value, $objectId, $key, true); } - public function update(MetaObjectType $objectType, int $objectId, string $key, string $value): void + public function getAll(MetaObjectType $objectType, int $objectId, string $key): array + { + return array_values((array) \get_metadata_raw($objectType->value, $objectId, $key, false)); + } + + public function update(MetaObjectType $objectType, int $objectId, string $key, string|array $value): void { \update_metadata($objectType->value, $objectId, \wp_slash($key), \wp_slash($value)); } + public function replaceAll(MetaObjectType $objectType, int $objectId, string $key, array $values): void + { + \delete_metadata($objectType->value, $objectId, \wp_slash($key)); + + foreach ($values as $value) { + \add_metadata($objectType->value, $objectId, \wp_slash($key), \wp_slash($value)); + } + } + public function delete(MetaObjectType $objectType, int $objectId, string $key): void { \delete_metadata($objectType->value, $objectId, \wp_slash($key)); diff --git a/src/Meta/Infrastructure/Services/LaravelMetaValidator.php b/src/Meta/Infrastructure/Services/LaravelMetaValidator.php index f9bff41f..9fc176e5 100644 --- a/src/Meta/Infrastructure/Services/LaravelMetaValidator.php +++ b/src/Meta/Infrastructure/Services/LaravelMetaValidator.php @@ -5,6 +5,7 @@ namespace Pollora\Meta\Infrastructure\Services; use BackedEnum; +use DateTimeInterface; use Illuminate\Contracts\Validation\Factory; use Pollora\Meta\Domain\Contracts\MetaValidatorInterface; use Pollora\Meta\Domain\Enums\MetaValueType; @@ -30,7 +31,7 @@ public function validate(MetaDefinition $definition, mixed $value): void $rules = [...$this->impliedRules($definition), ...$definition->rules]; $validator = $this->validator->make( - [$definition->key => $value instanceof BackedEnum ? $value->value : $value], + [$definition->key => $this->comparable($value)], [$definition->key => $rules], [], [$definition->key => $definition->label ?? str_replace('_', ' ', $definition->key)], @@ -41,6 +42,20 @@ public function validate(MetaDefinition $definition, mixed $value): void } } + /** + * The value as rules compare it: enums by their backing value, data objects + * as arrays of their public properties. + */ + private function comparable(mixed $value): mixed + { + return match (true) { + $value instanceof BackedEnum => $value->value, + is_array($value) => array_map($this->comparable(...), $value), + is_object($value) && ! $value instanceof DateTimeInterface => array_map($this->comparable(...), get_object_vars($value)), + default => $value, + }; + } + /** * @return list */ @@ -53,6 +68,7 @@ private function impliedRules(MetaDefinition $definition): array MetaValueType::Boolean => 'boolean', MetaValueType::DateTime => 'date', MetaValueType::Enum => null, + MetaValueType::ArrayOf, MetaValueType::DataObject => 'array', }; return array_values(array_filter([$definition->nullable ? 'nullable' : null, $type])); diff --git a/src/Models/Concerns/HasTypedMeta.php b/src/Models/Concerns/HasTypedMeta.php index e2eaf445..b4419573 100644 --- a/src/Models/Concerns/HasTypedMeta.php +++ b/src/Models/Concerns/HasTypedMeta.php @@ -180,6 +180,8 @@ public function saveTypedMeta(): void /** * 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. + * For an array stored one row per item, the value is an item, and a model + * matches when one of its items does. * * @param Builder $query * @@ -205,10 +207,21 @@ protected function scopeWhereMeta(Builder $query, string $name, mixed $operator, }; } + $key = $definition->key; + + // A meta stored one row per item matches when one of its rows does. + if ($definition->valueType === MetaValueType::ArrayOf && ! $definition->single) { + $definition = $definition->item(); + } + + if ($definition->isStructured()) { + throw new InvalidArgumentException(sprintf('whereMeta() cannot compare "%s": it is stored serialized. Store it with single: false to filter on its items.', $name)); + } + $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); + return $query->whereHas('meta', static function (Builder $meta) use ($definition, $key, $operator, $stored): void { + $meta->where('meta_key', $key); match ($definition->valueType) { MetaValueType::Integer => $meta->whereRaw('CAST(meta_value AS SIGNED) '.$operator.' ?', [(int) $stored]), diff --git a/tests/Unit/Meta/Fixtures/Conference.php b/tests/Unit/Meta/Fixtures/Conference.php new file mode 100644 index 00000000..0b349674 --- /dev/null +++ b/tests/Unit/Meta/Fixtures/Conference.php @@ -0,0 +1,31 @@ + */ + #[Meta(showInRest: true, rules: ['max:3'], single: false)] + public array $speakers = []; + + #[Meta(showInRest: true, items: 'int')] + public array $roomIds = []; + + #[Meta(single: false, items: EventStatus::class)] + public array $statuses = []; + + #[Meta(showInRest: true)] + public Schedule $schedule; + + #[Meta] + public ?Schedule $backup = null; + + #[Meta(showInRest: true, items: Schedule::class)] + public array $sessions = []; +} diff --git a/tests/Unit/Meta/Fixtures/Invalid.php b/tests/Unit/Meta/Fixtures/Invalid.php index 56636338..13928358 100644 --- a/tests/Unit/Meta/Fixtures/Invalid.php +++ b/tests/Unit/Meta/Fixtures/Invalid.php @@ -66,3 +66,44 @@ class InvalidUntyped #[Meta] public $value = ''; } + +#[PostType('invalid-single')] +class InvalidSingleFalse +{ + #[Meta(single: false)] + public int $count = 0; +} + +#[PostType('invalid-nested')] +class InvalidNestedArray +{ + #[Meta(items: 'array')] + public array $matrix = []; +} + +#[PostType('invalid-unknown-item')] +class InvalidUnknownItem +{ + /** @var list */ + #[Meta] + public array $speakers = []; +} + +#[PostType('invalid-rows-of-objects')] +class InvalidRowsOfObjects +{ + #[Meta(single: false, items: Schedule::class)] + public array $sessions = []; +} + +final class ScheduleWithList +{ + public array $tags = []; +} + +#[PostType('invalid-object-property')] +class InvalidObjectProperty +{ + #[Meta] + public ?ScheduleWithList $schedule = null; +} diff --git a/tests/Unit/Meta/Fixtures/Schedule.php b/tests/Unit/Meta/Fixtures/Schedule.php new file mode 100644 index 00000000..4fd0e9f3 --- /dev/null +++ b/tests/Unit/Meta/Fixtures/Schedule.php @@ -0,0 +1,20 @@ + */ + /** @var array> */ public array $values = []; public function get(MetaObjectType $objectType, int $objectId, string $key): mixed @@ -48,7 +55,7 @@ public function get(MetaObjectType $objectType, int $objectId, string $key): mix return $this->values["{$objectType->value}:{$objectId}:{$key}"] ?? null; } - public function update(MetaObjectType $objectType, int $objectId, string $key, string $value): void + public function update(MetaObjectType $objectType, int $objectId, string $key, string|array $value): void { $this->values["{$objectType->value}:{$objectId}:{$key}"] = $value; } @@ -57,6 +64,16 @@ public function delete(MetaObjectType $objectType, int $objectId, string $key): { unset($this->values["{$objectType->value}:{$objectId}:{$key}"]); } + + public function getAll(MetaObjectType $objectType, int $objectId, string $key): array + { + return (array) ($this->values["{$objectType->value}:{$objectId}:{$key}"] ?? []); + } + + public function replaceAll(MetaObjectType $objectType, int $objectId, string $key, array $values): void + { + $this->values["{$objectType->value}:{$objectId}:{$key}"] = $values; + } } /** @@ -225,6 +242,15 @@ public function version(): string ->and($query->getBindings())->toContain('sold_out', '1'); }); + it('matches a model by one of the items of an array stored one row per item', function (): void { + $this->repository->add((new MetaSchemaBuilder)->build(Conference::class)); + $query = ConferenceModel::query()->whereMeta('speakers', 'Ada'); + + expect($query->toSql())->toContain('`meta_value` = ?') + ->and($query->getBindings())->toContain('speakers', 'Ada') + ->and(fn () => ConferenceModel::query()->whereMeta('roomIds', [3]))->toThrow(InvalidArgumentException::class, 'it is stored serialized'); + }); + it('matches an absent meta with null', function (): void { expect(EventModel::query()->whereMeta('subtitle', null)->toSql())->toContain('not exists'); }); diff --git a/tests/Unit/Meta/MetaArraysAndObjectsTest.php b/tests/Unit/Meta/MetaArraysAndObjectsTest.php new file mode 100644 index 00000000..6ab3e570 --- /dev/null +++ b/tests/Unit/Meta/MetaArraysAndObjectsTest.php @@ -0,0 +1,190 @@ +schema = (new MetaSchemaBuilder)->build(Conference::class); + $this->caster = new MetaValueCaster; +}); + +describe('declaring', function (): void { + it('reads the item type from the docblock or items:', function (): void { + $definitions = $this->schema->definitions; + + expect($definitions['speakers']->valueType)->toBe(MetaValueType::ArrayOf) + ->and($definitions['speakers']->single)->toBeFalse() + ->and($definitions['speakers']->item()->valueType)->toBe(MetaValueType::String) + ->and($definitions['roomIds']->item()->valueType)->toBe(MetaValueType::Integer) + ->and($definitions['statuses']->item()->valueClass)->toBe(EventStatus::class) + ->and($definitions['sessions']->item()->valueType)->toBe(MetaValueType::DataObject) + ->and($definitions['schedule']->valueType)->toBe(MetaValueType::DataObject) + ->and(array_keys($definitions['schedule']->properties))->toBe(['startsAt', 'durationMinutes', 'status', 'room', 'public']); + }); + + it('publishes REST schemas WordPress accepts', function (): void { + $definitions = $this->schema->definitions; + + expect($definitions['speakers']->wordPressType())->toBe('string') + ->and($definitions['speakers']->restSchema())->toBe(['type' => 'string']) + ->and($definitions['roomIds']->wordPressType())->toBe('array') + ->and($definitions['roomIds']->restSchema())->toBe(['type' => 'array', 'items' => ['type' => 'integer']]) + ->and($definitions['schedule']->wordPressType())->toBe('object') + ->and($definitions['schedule']->restSchema())->toBe([ + 'type' => 'object', + 'properties' => [ + 'starts_at' => ['type' => ['string', 'null'], 'format' => 'date-time'], + 'duration_minutes' => ['type' => 'integer'], + 'status' => ['type' => 'string', 'enum' => ['draft', 'published']], + 'room' => ['type' => ['string', 'null']], + 'public' => ['type' => 'boolean'], + ], + 'additionalProperties' => false, + ]); + }); + + it('refuses what it cannot store', function (string $class, string $message): void { + expect(fn (): MetaSchema => (new MetaSchemaBuilder)->build($class))->toThrow(InvalidMetaDefinitionException::class, $message); + })->with([ + 'single: false on a scalar' => [InvalidSingleFalse::class, 'single: false stores one row per item, so it needs an array property'], + 'an array of arrays' => [InvalidNestedArray::class, 'an item cannot be an array'], + 'an unresolvable docblock class' => [InvalidUnknownItem::class, 'the item type Speaker is unknown'], + 'rows of objects' => [InvalidRowsOfObjects::class, 'objects need single: true'], + 'an object with an array' => [InvalidObjectProperty::class, 'ScheduleWithList::$tags: an object property can be'], + ]); +}); + +describe('converting', function (): void { + it('stores one row per item, as strings', function (): void { + expect($this->caster->toStorage($this->schema->definitions['speakers'], ['Ada', 'Grace']))->toBe(['Ada', 'Grace']) + ->and($this->caster->toStorage($this->schema->definitions['statuses'], [EventStatus::Published]))->toBe(['published']) + ->and($this->caster->toPhp($this->schema->definitions['statuses'], ['draft', 'published']))->toBe([EventStatus::Draft, EventStatus::Published]); + }); + + it('stores a single array with JSON values', function (): void { + $roomIds = $this->schema->definitions['roomIds']; + + expect($this->caster->toStorage($roomIds, [3, 7]))->toBe([3, 7]) + ->and($this->caster->toPhp($roomIds, ['3', 7]))->toBe([3, 7]) + ->and(fn () => $this->caster->toStorage($roomIds, ['three']))->toThrow(InvalidMetaValueException::class) + ->and(fn () => $this->caster->toPhp($roomIds, 'not an array'))->toThrow(InvalidMetaValueException::class); + }); + + it('stores a data object as an array, never as a PHP object, and reads it back', function (): void { + $definition = $this->schema->definitions['schedule']; + $schedule = new Schedule(public: false); + $schedule->startsAt = CarbonImmutable::parse('2026-11-14 09:00', 'Europe/Paris'); + $schedule->status = EventStatus::Published; + + $stored = $this->caster->toStorage($definition, $schedule); + $read = $this->caster->toPhp($definition, $stored); + + expect($stored)->toBe([ + 'starts_at' => '2026-11-14T08:00:00+00:00', + 'duration_minutes' => 60, + 'status' => 'published', + 'room' => null, + 'public' => false, + ]) + ->and($read)->toBeInstanceOf(Schedule::class) + ->and($read->startsAt?->toIso8601String())->toBe('2026-11-14T08:00:00+00:00') + ->and($read->status)->toBe(EventStatus::Published) + ->and($read->public)->toBeFalse(); + }); + + it('reads an absent object as an instance with its defaults, and a missing property as its default', function (): void { + $definition = $this->schema->definitions['schedule']; + + expect($this->caster->toPhp($definition, null))->toEqual(new Schedule) + ->and($this->caster->toPhp($definition, ['duration_minutes' => 90])->durationMinutes)->toBe(90) + ->and($this->caster->toPhp($definition, ['duration_minutes' => 90])->public)->toBeTrue() + ->and($this->caster->toPhp($this->schema->definitions['backup'], null))->toBeNull() + ->and(fn () => $this->caster->toPhp($definition, ['duration_minutes' => 'long']))->toThrow(InvalidMetaValueException::class); + }); + + it('stores a list of objects', function (): void { + $sessions = $this->schema->definitions['sessions']; + + $stored = $this->caster->toStorage($sessions, [new Schedule, new Schedule(public: false)]); + + expect($stored)->toHaveCount(2) + ->and($stored[1]['public'])->toBeFalse() + ->and($this->caster->toPhp($sessions, $stored)[1])->toBeInstanceOf(Schedule::class); + }); + + it('sanitizes REST input into the stored form', function (): void { + expect($this->caster->sanitize($this->schema->definitions['roomIds'], ['3', 4]))->toBe([3, 4]) + ->and($this->caster->sanitize($this->schema->definitions['schedule'], ['duration_minutes' => '45']))->toMatchArray(['duration_minutes' => 45, 'public' => true]) + ->and($this->caster->sanitize($this->schema->definitions['roomIds'], 'junk'))->toBe(''); + }); +}); + +describe('reading and writing', function (): void { + it('reads rows and replaces them all on save', function (): void { + $store = Mockery::mock(MetaStoreInterface::class); + $store->shouldReceive('getAll')->once()->with(MetaObjectType::Post, 9, 'speakers')->andReturn(['Ada', 'Grace']); + $store->shouldReceive('replaceAll')->once()->with(MetaObjectType::Post, 9, 'speakers', ['Linus']); + $store->shouldReceive('update')->once()->with(MetaObjectType::Post, 9, 'schedule', Mockery::on(fn (array $value): bool => $value['duration_minutes'] === 30)); + $record = new MetaRecord($this->schema, 9, $store, $this->caster, fn (): mixed => null, metaValidator()); + + expect($record->speakers)->toBe(['Ada', 'Grace']); + + $schedule = new Schedule; + $schedule->durationMinutes = 30; + $record->fill(['speakers' => ['Linus'], 'schedule' => $schedule])->save(); + }); + + it('checks the rules on the whole array', function (): void { + $record = new MetaRecord($this->schema, 9, Mockery::mock(MetaStoreInterface::class), $this->caster, fn (): mixed => null, metaValidator()); + + $record->set('speakers', ['A', 'B', 'C', 'D']); + })->throws(MetaValidationException::class); +}); + +describe('registering', function (): void { + it('registers a non-single meta and sanitizes each row, and strings inside arrays and objects', function (): void { + $calls = []; + Functions\when('did_action')->justReturn(1); + Functions\when('add_post_type_support')->justReturn(); + Functions\when('sanitize_text_field')->alias(fn (string $value): string => strip_tags($value)); + Functions\when('register_meta')->alias(function (string $objectType, string $key, array $args) use (&$calls): bool { + $calls[$key] = $args; + + return true; + }); + + (new WordPressMetaRegistry(Mockery::mock(Action::class), new MetaValueCaster))->register($this->schema); + + expect($calls['speakers']['single'])->toBeFalse() + ->and($calls['speakers']['sanitize_callback'])->toBe('sanitize_text_field') + ->and($calls['speakers'])->not->toHaveKey('default') + ->and($calls['room_ids']['default'])->toBe([]) + ->and($calls['schedule']['default'])->toMatchArray(['duration_minutes' => 60, 'public' => true]) + ->and(($calls['schedule']['sanitize_callback'])(['room' => 'Hall A']))->toMatchArray(['room' => 'Hall A']); + }); +}); diff --git a/tests/Unit/Meta/MetaDiscoveryTest.php b/tests/Unit/Meta/MetaDiscoveryTest.php index 0de3768a..48755b2e 100644 --- a/tests/Unit/Meta/MetaDiscoveryTest.php +++ b/tests/Unit/Meta/MetaDiscoveryTest.php @@ -72,7 +72,7 @@ abstract class AbstractMetaDeclaration it('logs a declaration it cannot register and carries on', function (): void { $this->registry->shouldReceive('register')->twice(); - $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/InvalidArray: .*the type array is not supported yet/'), Mockery::type('array')); + $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/InvalidArray: .*say what the array holds/'), Mockery::type('array')); $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/EventExtras: The meta key "capacity" of post "event" is declared twice/'), Mockery::type('array')); ($this->discover)(Event::class, InvalidArray::class, EventExtras::class, BookGenre::class); diff --git a/tests/Unit/Meta/MetaRecordTest.php b/tests/Unit/Meta/MetaRecordTest.php index 2e8d51e3..9de2f6fd 100644 --- a/tests/Unit/Meta/MetaRecordTest.php +++ b/tests/Unit/Meta/MetaRecordTest.php @@ -36,7 +36,7 @@ public function get(MetaObjectType $objectType, int $objectId, string $key): mix return $this->stored[$key] ?? null; } - public function update(MetaObjectType $objectType, int $objectId, string $key, string $value): void + public function update(MetaObjectType $objectType, int $objectId, string $key, string|array $value): void { $this->writes[] = ['update', $objectType, $objectId, $key, $value]; $this->stored[$key] = $value; @@ -47,6 +47,19 @@ public function delete(MetaObjectType $objectType, int $objectId, string $key): $this->writes[] = ['delete', $objectType, $objectId, $key]; unset($this->stored[$key]); } + + public function getAll(MetaObjectType $objectType, int $objectId, string $key): array + { + $this->reads++; + + return (array) ($this->stored[$key] ?? []); + } + + public function replaceAll(MetaObjectType $objectType, int $objectId, string $key, array $values): void + { + $this->writes[] = ['replaceAll', $objectType, $objectId, $key, $values]; + $this->stored[$key] = $values; + } }; } diff --git a/tests/Unit/Meta/MetaSchemaBuilderTest.php b/tests/Unit/Meta/MetaSchemaBuilderTest.php index 53f1e4c0..ac6f4a76 100644 --- a/tests/Unit/Meta/MetaSchemaBuilderTest.php +++ b/tests/Unit/Meta/MetaSchemaBuilderTest.php @@ -105,7 +105,7 @@ })->with([ 'union type' => [InvalidUnion::class, 'needs a single type'], 'untyped property' => [InvalidUntyped::class, 'needs a single type'], - 'array' => [InvalidArray::class, 'the type array is not supported yet'], + 'array' => [InvalidArray::class, 'say what the array holds'], 'pure enum' => [InvalidPureEnum::class, 'needs backing values'], 'no default, not nullable' => [InvalidNoDefault::class, 'give the property a default value or make it nullable'], 'protected key in REST' => [InvalidProtectedInRest::class, 'the protected key "_secret" can only be exposed in REST with an explicit capability'], From 2d56eea4db2d71838091f7a3d1c30c2645ac9f85 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 6 Oct 2026 10:31:10 +0200 Subject: [PATCH 10/13] docs: changelog for arrays and data objects --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d64d89e2..79c65111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.4...develop) ### Added +- Arrays and data objects as typed meta: an `array` property declares its item type with `items: 'int'` (or a class) or a `@var list` docblock, and is stored as one serialized array or, with `single: false`, one row per item (`whereMeta()` then matches a model by one of its items). A class with public typed properties (scalars, dates, backed enums) is stored as an array, never as a PHP object, and read back as an instance; absent, it reads as an instance with its own defaults. REST publishes `items` and `properties` schemas, so WordPress refuses an item or a property of the wrong type; strings inside are sanitized like a string meta - Typed meta: a post type declared with `#[PostType]` gets `custom-fields` support when one of its `#[Meta]` has `showInRest: true`, without which WordPress leaves `meta` out of its REST responses. A post type targeted by `#[PostMeta]` is left as it is - Typed meta validation: `#[Meta(rules: ['min:0', 'max:5000'])]` checks a value with Laravel's validator, the type rule implied (`integer` for an int, so `max` compares numbers). A write from PHP (`Meta::of()->set()`, a model attribute) throws a `MetaValidationException` naming the meta; a REST write to a post, term, user or comment answers 400 with the message of the rule (`params` → `meta.`) before anything is stored. Writes through WordPress's own functions (`update_post_meta()`) are only sanitized - 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 From 7a684d8bd48aa6fdba3d68f9071517728569c65b Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 6 Oct 2026 10:37:11 +0200 Subject: [PATCH 11/13] feat(meta): a driver contract for the input fields of typed meta Neutral control, group and hints on #[Meta]; Meta::extend(), meta.ui, the schemas handed to the driver on init with the meta it supports; Meta::schemas(), schemaFor(), MetaSchemasRegistered and a conformance check for driver authors. --- CHANGELOG.md | 1 + src/Attributes/Meta.php | 7 + .../Application/Services/MetaAccessor.php | 38 ++++ .../Services/MetaSchemaBuilder.php | 20 ++ .../Application/Services/MetaUiDrivers.php | 66 +++++++ src/Meta/Domain/Contracts/MetaUiDriver.php | 37 ++++ src/Meta/Domain/Enums/Control.php | 25 +++ .../Domain/Events/MetaSchemasRegistered.php | 18 ++ src/Meta/Domain/Models/MetaDefinition.php | 7 + src/Meta/Domain/Models/MetaSchema.php | 10 + .../Providers/MetaServiceProvider.php | 29 ++- src/Meta/Testing/Fixtures/EveryMetaKind.php | 68 +++++++ src/Meta/Testing/MetaUiDriverConformance.php | 67 +++++++ src/Meta/config/meta.php | 18 ++ src/Support/Facades/Meta.php | 11 +- tests/Unit/Meta/MetaServiceProviderTest.php | 5 +- tests/Unit/Meta/MetaUiDriverTest.php | 186 ++++++++++++++++++ 17 files changed, 608 insertions(+), 5 deletions(-) create mode 100644 src/Meta/Application/Services/MetaUiDrivers.php create mode 100644 src/Meta/Domain/Contracts/MetaUiDriver.php create mode 100644 src/Meta/Domain/Enums/Control.php create mode 100644 src/Meta/Domain/Events/MetaSchemasRegistered.php create mode 100644 src/Meta/Testing/Fixtures/EveryMetaKind.php create mode 100644 src/Meta/Testing/MetaUiDriverConformance.php create mode 100644 src/Meta/config/meta.php create mode 100644 tests/Unit/Meta/MetaUiDriverTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c65111..6d06619b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.4...develop) ### Added +- Input fields for typed meta, through a contract (**experimental**): `#[Meta(control: Control::Color, group: 'Profile', hints: ['acf' => [...]])]` describes the field in neutral terms, a control being derived from the type otherwise (`Text`, `RichText` with `sanitize: 'wp_kses_post'`, `Number`, `Toggle`, `DateTime`, `Select`). A package implements `MetaUiDriver` and registers it with `Meta::extend('acf', AcfDriver::class)`; the project picks it in `meta.ui` (none by default), and the driver receives each schema on `init`, with only the meta it supports. `Meta::schemas()`, `Meta::schemaFor('post', 'event')`, the `MetaSchemasRegistered` event, and `MetaUiDriverConformance::check($driver)` for driver authors. The framework names no field plugin - Arrays and data objects as typed meta: an `array` property declares its item type with `items: 'int'` (or a class) or a `@var list` docblock, and is stored as one serialized array or, with `single: false`, one row per item (`whereMeta()` then matches a model by one of its items). A class with public typed properties (scalars, dates, backed enums) is stored as an array, never as a PHP object, and read back as an instance; absent, it reads as an instance with its own defaults. REST publishes `items` and `properties` schemas, so WordPress refuses an item or a property of the wrong type; strings inside are sanitized like a string meta - Typed meta: a post type declared with `#[PostType]` gets `custom-fields` support when one of its `#[Meta]` has `showInRest: true`, without which WordPress leaves `meta` out of its REST responses. A post type targeted by `#[PostMeta]` is left as it is - Typed meta validation: `#[Meta(rules: ['min:0', 'max:5000'])]` checks a value with Laravel's validator, the type rule implied (`integer` for an int, so `max` compares numbers). A write from PHP (`Meta::of()->set()`, a model attribute) throws a `MetaValidationException` naming the meta; a REST write to a post, term, user or comment answers 400 with the message of the rule (`params` → `meta.`) before anything is stored. Writes through WordPress's own functions (`update_post_meta()`) are only sanitized diff --git a/src/Attributes/Meta.php b/src/Attributes/Meta.php index efcd5d89..3a2a0387 100644 --- a/src/Attributes/Meta.php +++ b/src/Attributes/Meta.php @@ -6,6 +6,7 @@ use Attribute; use Illuminate\Support\Str; +use Pollora\Meta\Domain\Enums\Control; /** * Meta Attribute @@ -41,6 +42,9 @@ * @param array $rules Laravel validation rules, checked on writes from PHP and REST * @param bool $single On an `array` property, false stores one row per item instead of one serialized array * @param string|null $items On an `array` property, the item type: `'string'`, `'int'`, `'float'`, `'bool'` or a class. Defaults to the `@var list<…>` docblock + * @param Control|null $control The input a UI driver should build. Defaults to one derived from the type + * @param string|null $group The group of fields a UI driver puts the meta in + * @param array $hints Options passed as they are to UI drivers, by driver: `['acf' => ['wrapper' => ['width' => 50]]]` */ public function __construct( public ?string $key = null, @@ -53,6 +57,9 @@ public function __construct( public array $rules = [], public bool $single = true, public ?string $items = null, + public ?Control $control = null, + public ?string $group = null, + public array $hints = [], ) {} /** diff --git a/src/Meta/Application/Services/MetaAccessor.php b/src/Meta/Application/Services/MetaAccessor.php index 3e26e780..4df56dd4 100644 --- a/src/Meta/Application/Services/MetaAccessor.php +++ b/src/Meta/Application/Services/MetaAccessor.php @@ -4,8 +4,13 @@ namespace Pollora\Meta\Application\Services; +use Closure; +use Illuminate\Contracts\Container\Container; +use LogicException; use Pollora\Meta\Domain\Contracts\MetaStoreInterface; +use Pollora\Meta\Domain\Contracts\MetaUiDriver; use Pollora\Meta\Domain\Contracts\MetaValidatorInterface; +use Pollora\Meta\Domain\Enums\MetaObjectType; use Pollora\Meta\Domain\Exceptions\InvalidMetaValueException; use Pollora\Meta\Domain\Models\MetaDefinition; use Pollora\Meta\Domain\Models\MetaRecord; @@ -30,6 +35,7 @@ public function __construct( private bool $debug = false, private ?LoggerInterface $logger = null, private ?MetaValidatorInterface $validator = null, + private ?MetaUiDrivers $drivers = null, ) {} /** @@ -41,6 +47,38 @@ public function of(string $class, int $objectId): MetaRecord return $this->record($this->schemas->forClass($class) ?? $this->builder->build($class), $objectId); } + /** + * Every typed meta schema of the project. + * + * @return list + */ + public function schemas(): array + { + return $this->schemas->all(); + } + + /** + * The schemas whose meta an object carries: `Meta::schemaFor('post', 'event')`, + * `Meta::schemaFor('user')`. + * + * @return list + */ + public function schemaFor(MetaObjectType|string $objectType, ?string $subtype = null): array + { + return $this->schemas->forObject($objectType instanceof MetaObjectType ? $objectType : MetaObjectType::from($objectType), $subtype); + } + + /** + * Registers a UI driver, from a package's service provider: + * `Meta::extend('acf', AcfDriver::class)`. + * + * @param class-string|Closure(Container): MetaUiDriver $driver + */ + public function extend(string $name, string|Closure $driver): void + { + ($this->drivers ?? throw new LogicException('Meta UI drivers are not available.'))->extend($name, $driver); + } + /** * The typed meta of a schema on one object. */ diff --git a/src/Meta/Application/Services/MetaSchemaBuilder.php b/src/Meta/Application/Services/MetaSchemaBuilder.php index 3c89af2a..98cc0518 100644 --- a/src/Meta/Application/Services/MetaSchemaBuilder.php +++ b/src/Meta/Application/Services/MetaSchemaBuilder.php @@ -14,6 +14,7 @@ use Pollora\Attributes\Taxonomy; use Pollora\Attributes\TermMeta; use Pollora\Attributes\UserMeta; +use Pollora\Meta\Domain\Enums\Control; use Pollora\Meta\Domain\Enums\MetaObjectType; use Pollora\Meta\Domain\Enums\MetaValueType; use Pollora\Meta\Domain\Exceptions\InvalidMetaDefinitionException; @@ -144,6 +145,9 @@ private function buildDefinition(string $class, ReflectionProperty $property, Me single: $meta->single, items: $items, properties: $properties, + control: $meta->control ?? $this->defaultControl($valueType, $meta), + group: $meta->group, + hints: $meta->hints, ); } @@ -187,6 +191,22 @@ private function resolveValueType(string $class, string $property, string $typeN throw InvalidMetaDefinitionException::forProperty($class, $property, $reason); } + /** + * The input a type calls for; none for an array or an object, which a UI + * driver builds its own way. + */ + private function defaultControl(MetaValueType $valueType, Meta $meta): ?Control + { + return match ($valueType) { + MetaValueType::String => $meta->sanitize === 'wp_kses_post' ? Control::RichText : Control::Text, + MetaValueType::Integer, MetaValueType::Number => Control::Number, + MetaValueType::Boolean => Control::Toggle, + MetaValueType::DateTime => Control::DateTime, + MetaValueType::Enum => Control::Select, + MetaValueType::ArrayOf, MetaValueType::DataObject => null, + }; + } + /** * The item of an array property, from `items:` or the `@var list<…>` docblock. */ diff --git a/src/Meta/Application/Services/MetaUiDrivers.php b/src/Meta/Application/Services/MetaUiDrivers.php new file mode 100644 index 00000000..8942ef50 --- /dev/null +++ b/src/Meta/Application/Services/MetaUiDrivers.php @@ -0,0 +1,66 @@ +|Closure(Container): MetaUiDriver> */ + private array $drivers = []; + + public function __construct(private readonly Container $container) {} + + /** + * @param class-string|Closure(Container): MetaUiDriver $driver + */ + public function extend(string $name, string|Closure $driver): void + { + $this->drivers[$name] = $driver; + } + + public function has(string $name): bool + { + return isset($this->drivers[$name]); + } + + /** + * @throws InvalidArgumentException When no package registered that driver + */ + public function driver(string $name): MetaUiDriver + { + $driver = $this->drivers[$name] ?? throw new InvalidArgumentException(sprintf('No meta UI driver is named "%s"; its package registers it with Meta::extend().', $name)); + $instance = $driver instanceof Closure ? $driver($this->container) : $this->container->make($driver); + + return $instance instanceof MetaUiDriver + ? $instance + : throw new InvalidArgumentException(sprintf('The meta UI driver "%s" does not implement %s.', $name, MetaUiDriver::class)); + } + + /** + * Gives each schema to the driver, with only the meta it supports; a schema + * left without any is skipped. + * + * @param list $schemas + */ + public function build(MetaUiDriver $driver, array $schemas): void + { + foreach ($schemas as $schema) { + $supported = array_filter($schema->definitions, $driver->supports(...)); + + if ($supported !== []) { + $driver->register($schema->withDefinitions($supported)); + } + } + } +} diff --git a/src/Meta/Domain/Contracts/MetaUiDriver.php b/src/Meta/Domain/Contracts/MetaUiDriver.php new file mode 100644 index 00000000..c97996aa --- /dev/null +++ b/src/Meta/Domain/Contracts/MetaUiDriver.php @@ -0,0 +1,37 @@ + $schemas + */ + public function __construct(public array $schemas) {} +} diff --git a/src/Meta/Domain/Models/MetaDefinition.php b/src/Meta/Domain/Models/MetaDefinition.php index 4e236ecb..ba574a8d 100644 --- a/src/Meta/Domain/Models/MetaDefinition.php +++ b/src/Meta/Domain/Models/MetaDefinition.php @@ -6,6 +6,7 @@ use BackedEnum; use LogicException; +use Pollora\Meta\Domain\Enums\Control; use Pollora\Meta\Domain\Enums\MetaValueType; use ReflectionEnum; @@ -34,6 +35,9 @@ * @param bool $single False for an array stored one row per item * @param MetaDefinition|null $items The item of an array * @param array $properties The properties of a data object, by property name + * @param Control|null $control The input a UI driver should build; null when no neutral control fits (arrays, objects) + * @param string|null $group The group of fields a UI driver puts the meta in + * @param array $hints Options for UI drivers, by driver */ public function __construct( public string $property, @@ -52,6 +56,9 @@ public function __construct( public bool $single = true, public ?MetaDefinition $items = null, public array $properties = [], + public ?Control $control = null, + public ?string $group = null, + public array $hints = [], ) {} /** diff --git a/src/Meta/Domain/Models/MetaSchema.php b/src/Meta/Domain/Models/MetaSchema.php index c7e0fa19..660e96e8 100644 --- a/src/Meta/Domain/Models/MetaSchema.php +++ b/src/Meta/Domain/Models/MetaSchema.php @@ -48,6 +48,16 @@ public function ownerName(): string : sprintf('%s "%s"', $this->objectType->value, implode('", "', $this->subtypes)); } + /** + * The same schema, with only the given definitions. + * + * @param array $definitions + */ + public function withDefinitions(array $definitions): self + { + return new self($this->declaringClass, $this->objectType, $this->subtypes, $definitions, $this->declaresSubtypes); + } + /** * Whether a meta of the schema is exposed in REST. */ diff --git a/src/Meta/Infrastructure/Providers/MetaServiceProvider.php b/src/Meta/Infrastructure/Providers/MetaServiceProvider.php index fd5e44db..6ed58023 100644 --- a/src/Meta/Infrastructure/Providers/MetaServiceProvider.php +++ b/src/Meta/Infrastructure/Providers/MetaServiceProvider.php @@ -11,9 +11,11 @@ use Pollora\Meta\Application\Services\MetaAccessor; use Pollora\Meta\Application\Services\MetaSchemaBuilder; use Pollora\Meta\Application\Services\MetaSchemaRepository; +use Pollora\Meta\Application\Services\MetaUiDrivers; use Pollora\Meta\Domain\Contracts\MetaRegistryInterface; use Pollora\Meta\Domain\Contracts\MetaStoreInterface; use Pollora\Meta\Domain\Contracts\MetaValidatorInterface; +use Pollora\Meta\Domain\Events\MetaSchemasRegistered; use Pollora\Meta\Domain\Services\MetaValueCaster; use Pollora\Meta\Infrastructure\Adapters\WordPressMetaRegistry; use Pollora\Meta\Infrastructure\Adapters\WordPressMetaStore; @@ -23,8 +25,9 @@ use Psr\Log\LoggerInterface; /** - * Typed meta: `#[Meta]` discovery, `register_meta()`, `Meta::of()`, and the - * `rules` of typed meta applied to REST writes. + * Typed meta: `#[Meta]` discovery, `register_meta()`, `Meta::of()`, the + * `rules` of typed meta applied to REST writes, and the hand-off of the schemas + * to a UI driver (`meta.ui`). * * Bindings: * - `wp.meta` → {@see MetaAccessor} (singleton, used by the Meta facade) @@ -34,6 +37,8 @@ class MetaServiceProvider extends ServiceProvider { public function register(): void { + $this->mergeConfigFrom(__DIR__.'/../../config/meta.php', 'meta'); + $this->app->singleton(MetaUiDrivers::class, fn (Application $app): MetaUiDrivers => new MetaUiDrivers($app)); $this->app->singleton(MetaValueCaster::class); $this->app->singleton(MetaSchemaBuilder::class); $this->app->singleton(MetaSchemaRepository::class); @@ -53,6 +58,7 @@ public function register(): void (bool) $app->make('config')->get('app.debug', false), $app->make(LoggerInterface::class), $app->make(MetaValidatorInterface::class), + $app->make(MetaUiDrivers::class), )); $this->app->alias('wp.meta', MetaAccessor::class); @@ -66,6 +72,9 @@ public function register(): void public function boot(): void { + // Once every schema is registered with WordPress (priority 20). + $this->app->make(Action::class)->add('init', $this->announceSchemas(...), WordPressMetaRegistry::INIT_PRIORITY + 1); + $this->app->make(Filter::class)->add( 'rest_request_before_callbacks', fn (mixed $response, array $handler, \WP_REST_Request $request): mixed => $this->app->make(WordPressRestMetaValidation::class)->validate($response, $handler, $request), @@ -73,4 +82,20 @@ public function boot(): void 3 ); } + + /** + * Dispatches MetaSchemasRegistered and hands the schemas to the UI driver + * the project picks in `meta.ui`. + */ + private function announceSchemas(): void + { + $schemas = $this->app->make(MetaSchemaRepository::class)->all(); + $this->app->make('events')->dispatch(new MetaSchemasRegistered($schemas)); + $driver = $this->app->make('config')->get('meta.ui'); + + if (is_string($driver) && $driver !== '') { + $drivers = $this->app->make(MetaUiDrivers::class); + $drivers->build($drivers->driver($driver), $schemas); + } + } } diff --git a/src/Meta/Testing/Fixtures/EveryMetaKind.php b/src/Meta/Testing/Fixtures/EveryMetaKind.php new file mode 100644 index 00000000..6951c926 --- /dev/null +++ b/src/Meta/Testing/Fixtures/EveryMetaKind.php @@ -0,0 +1,68 @@ + ['width' => 50]])] + public string $text = ''; + + #[Meta(control: Control::Textarea)] + public string $textarea = ''; + + #[Meta(sanitize: 'wp_kses_post')] + public string $richText = ''; + + #[Meta] + public int $number = 0; + + #[Meta] + public float $decimal = 0.0; + + #[Meta] + public bool $toggle = false; + + #[Meta(control: Control::Date)] + public ?CarbonImmutable $date = null; + + #[Meta] + public ?CarbonImmutable $dateTime = null; + + #[Meta] + public MetaObjectType $select = MetaObjectType::Post; + + #[Meta(control: Control::Media)] + public ?int $media = null; + + #[Meta(control: Control::Url)] + public ?string $url = null; + + #[Meta(control: Control::Email)] + public ?string $email = null; + + #[Meta(control: Control::Color)] + public ?string $color = null; + + /** @var list */ + #[Meta(single: false)] + public array $rows = []; + + #[Meta(items: 'int')] + public array $list = []; +} diff --git a/src/Meta/Testing/MetaUiDriverConformance.php b/src/Meta/Testing/MetaUiDriverConformance.php new file mode 100644 index 00000000..dbfc983c --- /dev/null +++ b/src/Meta/Testing/MetaUiDriverConformance.php @@ -0,0 +1,67 @@ +toBe([]); + * + * It gives the driver a schema with one meta of every type and control, and + * returns what breaks the contract. The rules about storage (write in the + * schema's stored form, through WordPress's meta API) depend on the plugin the + * driver targets: the driver's own tests cover them. + */ +final class MetaUiDriverConformance +{ + /** + * A schema with one meta of every type and control. + */ + public static function schema(): MetaSchema + { + return (new MetaSchemaBuilder)->build(EveryMetaKind::class); + } + + /** + * What the driver does against the contract; empty when it conforms. + * + * @return list + */ + public static function check(MetaUiDriver $driver): array + { + $schema = self::schema(); + $violations = []; + + foreach ($schema->definitions as $definition) { + try { + if ($driver->supports($definition) !== $driver->supports($definition)) { + $violations[] = sprintf('supports() answers differently for "%s" when asked twice.', $definition->key); + } + } catch (Throwable $throwable) { + $violations[] = sprintf('supports() throws for "%s" (%s): it must answer false instead.', $definition->key, $throwable->getMessage()); + } + } + + if ($violations !== []) { + return $violations; + } + + try { + (new MetaUiDrivers(new Container))->build($driver, [$schema]); + } catch (Throwable $throwable) { + $violations[] = sprintf('register() throws for the meta it supports: %s', $throwable->getMessage()); + } + + return $violations; + } +} diff --git a/src/Meta/config/meta.php b/src/Meta/config/meta.php new file mode 100644 index 00000000..81e6d10f --- /dev/null +++ b/src/Meta/config/meta.php @@ -0,0 +1,18 @@ + null, +]; diff --git a/src/Support/Facades/Meta.php b/src/Support/Facades/Meta.php index 185f19a0..227bc571 100644 --- a/src/Support/Facades/Meta.php +++ b/src/Support/Facades/Meta.php @@ -6,7 +6,9 @@ use Illuminate\Support\Facades\Facade; use Pollora\Meta\Application\Services\MetaAccessor; +use Pollora\Meta\Domain\Enums\MetaObjectType; use Pollora\Meta\Domain\Models\MetaRecord; +use Pollora\Meta\Domain\Models\MetaSchema; /** * Laravel facade for typed meta. @@ -15,10 +17,15 @@ * $event->capacity; // int * $event->fill(['capacity' => 250])->save(); * - * `Event` is the `#[PostType]` (or `#[Taxonomy]`) class whose properties carry - * `#[Meta]`. Reads and writes go through WordPress's meta API. + * `Event` is the class whose properties carry `#[Meta]`. Reads and writes go + * through WordPress's meta API. `schemas()` and `schemaFor()` give the compiled + * schemas to tooling and UI drivers; a driver's package registers it with + * `extend()`. * * @method static MetaRecord of(string $class, int $objectId) + * @method static list schemas() + * @method static list schemaFor(MetaObjectType|string $objectType, ?string $subtype = null) + * @method static void extend(string $name, string|\Closure $driver) * * @see MetaAccessor */ diff --git a/tests/Unit/Meta/MetaServiceProviderTest.php b/tests/Unit/Meta/MetaServiceProviderTest.php index 2d8c478f..231cd5d8 100644 --- a/tests/Unit/Meta/MetaServiceProviderTest.php +++ b/tests/Unit/Meta/MetaServiceProviderTest.php @@ -27,7 +27,9 @@ beforeEach(function (): void { $this->app = new Application(sys_get_temp_dir()); $this->app->instance('config', new Repository(['app' => ['debug' => true]])); - $this->app->instance(Action::class, Mockery::mock(Action::class)); + + $this->action = Mockery::mock(Action::class); + $this->app->instance(Action::class, $this->action); $this->app->instance(LoggerInterface::class, Mockery::mock(LoggerInterface::class)); $this->app->instance('validator', new Factory(new Translator(new ArrayLoader, 'en'))); @@ -57,6 +59,7 @@ }); it('binds the Laravel validator and applies the rules to REST writes', function (): void { + $this->action->shouldReceive('add')->andReturnSelf(); $this->filter->shouldReceive('add')->once()->with('rest_request_before_callbacks', Mockery::type(Closure::class), 10, 3)->andReturnSelf(); $this->provider->boot(); diff --git a/tests/Unit/Meta/MetaUiDriverTest.php b/tests/Unit/Meta/MetaUiDriverTest.php new file mode 100644 index 00000000..3d87b653 --- /dev/null +++ b/tests/Unit/Meta/MetaUiDriverTest.php @@ -0,0 +1,186 @@ +}> */ + public array $fields = []; + + public function supports(MetaDefinition $definition): bool + { + return ! $definition->isStructured(); + } + + public function register(MetaSchema $schema): void + { + foreach ($schema->definitions as $definition) { + $this->fields[$definition->key] = [ + 'control' => $definition->control?->value, + 'label' => $definition->label, + 'group' => $definition->group, + 'hints' => $definition->hints['recording'] ?? [], + ]; + } + } +} + +describe('declaring', function (): void { + it('derives a neutral control from the type, unless one is given', function (): void { + $definitions = MetaUiDriverConformance::schema()->definitions; + + expect(array_map(static fn (MetaDefinition $definition): ?Control => $definition->control, $definitions))->toBe([ + 'text' => Control::Text, + 'textarea' => Control::Textarea, + 'richText' => Control::RichText, + 'number' => Control::Number, + 'decimal' => Control::Number, + 'toggle' => Control::Toggle, + 'date' => Control::Date, + 'dateTime' => Control::DateTime, + 'select' => Control::Select, + 'media' => Control::Media, + 'url' => Control::Url, + 'email' => Control::Email, + 'color' => Control::Color, + 'rows' => null, + 'list' => null, + ]) + ->and($definitions['text']->group)->toBe('Basics') + ->and($definitions['text']->hints)->toBe(['any-driver' => ['width' => 50]]); + }); +}); + +describe('drivers', function (): void { + it('builds the fields of the meta a driver supports, and skips a schema left without any', function (): void { + $driver = new FieldRecordingDriver; + $drivers = new MetaUiDrivers(new Container); + $drivers->extend('recording', static fn (): MetaUiDriver => $driver); + + $drivers->build($drivers->driver('recording'), [MetaUiDriverConformance::schema()]); + + expect(array_keys($driver->fields))->not->toContain('rows', 'list') + ->and($driver->fields['text'])->toBe(['control' => 'text', 'label' => 'Text', 'group' => 'Basics', 'hints' => []]) + ->and($driver->fields['select']['control'])->toBe('select'); + }); + + it('resolves a driver registered by class, and refuses an unknown or a wrong one', function (): void { + $drivers = new MetaUiDrivers(new Container); + $drivers->extend('recording', FieldRecordingDriver::class); + $drivers->extend('wrong', static fn (): object => new stdClass); + + expect($drivers->driver('recording'))->toBeInstanceOf(FieldRecordingDriver::class) + ->and($drivers->has('recording'))->toBeTrue() + ->and(fn (): MetaUiDriver => $drivers->driver('acf'))->toThrow(InvalidArgumentException::class, 'No meta UI driver is named "acf"') + ->and(fn (): MetaUiDriver => $drivers->driver('wrong'))->toThrow(InvalidArgumentException::class, 'does not implement'); + }); + + it('checks a driver against the contract', function (): void { + $throwing = new class implements MetaUiDriver + { + public function supports(MetaDefinition $definition): bool + { + return $definition->valueType === MetaValueType::String ?: throw new RuntimeException('no'); + } + + public function register(MetaSchema $schema): void {} + }; + + expect(MetaUiDriverConformance::check(new FieldRecordingDriver))->toBe([]) + ->and(MetaUiDriverConformance::check($throwing))->toContain('supports() throws for "number" (no): it must answer false instead.'); + }); +}); + +describe('the facade service', function (): void { + it('gives the schemas and registers drivers', function (): void { + $repository = new MetaSchemaRepository; + $repository->add($event = (new MetaSchemaBuilder)->build(Event::class)); + $repository->add($member = (new MetaSchemaBuilder)->build(MemberProfile::class)); + + $drivers = new MetaUiDrivers(new Container); + $accessor = new MetaAccessor($repository, new MetaSchemaBuilder, Mockery::mock(MetaStoreInterface::class), new MetaValueCaster, drivers: $drivers); + + $accessor->extend('recording', FieldRecordingDriver::class); + + expect($accessor->schemas())->toBe([$event, $member]) + ->and($accessor->schemaFor('post', 'event'))->toBe([$event]) + ->and($accessor->schemaFor('user'))->toBe([$member]) + ->and($drivers->has('recording'))->toBeTrue(); + }); +}); + +describe('on init', function (): void { + beforeEach(function (): void { + $this->app = new Application(sys_get_temp_dir()); + $this->app->instance('config', new Repository(['app' => ['debug' => false]])); + $this->app->instance('events', $this->events = new Dispatcher($this->app)); + $this->app->instance('validator', new Factory(new Translator(new ArrayLoader, 'en'))); + $this->app->instance(LoggerInterface::class, Mockery::mock(LoggerInterface::class)); + + $this->action = Mockery::mock(Action::class); + $this->app->instance(Action::class, $this->action); + $filter = Mockery::mock(Filter::class); + $filter->shouldReceive('add')->andReturnSelf(); + $this->app->instance(Filter::class, $filter); + $this->provider = new MetaServiceProvider($this->app); + $this->provider->register(); + $this->app->make(MetaSchemaRepository::class)->add((new MetaSchemaBuilder)->build(Event::class)); + }); + + it('announces the schemas after registering them, and hands them to the configured driver', function (): void { + $announce = null; + $this->action->shouldReceive('add')->once()->with('init', Mockery::type(Closure::class), 21)->andReturnUsing(function (string $hook, Closure $callback) use (&$announce): Action { + $announce = $callback; + + return $this->action; + }); + $dispatched = []; + $this->events->listen(MetaSchemasRegistered::class, function (MetaSchemasRegistered $event) use (&$dispatched): void { + $dispatched = $event->schemas; + }); + $driver = new FieldRecordingDriver; + $this->app->make('config')->set('meta.ui', 'recording'); + $this->app->make(MetaUiDrivers::class)->extend('recording', static fn (): MetaUiDriver => $driver); + + $this->provider->boot(); + $announce(); + + expect($dispatched)->toHaveCount(1) + ->and($driver->fields)->toHaveKey('capacity'); + }); + + it('generates no field without a driver', function (): void { + expect($this->app->make('config')->get('meta.ui'))->toBeNull(); + }); +}); From 7dc612148b14d5c84d27aa755885950bb0fa0cc6 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 6 Oct 2026 11:02:05 +0200 Subject: [PATCH 12/13] chore: apply Rector 2.7 rules (TernaryToNullsafeCoalesce, RemoveOverriddenAssignBeforeIfElse) --- src/Discovery/Infrastructure/Services/ReflectionCache.php | 2 -- src/Theme/Domain/Models/ThemeInitializer.php | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Discovery/Infrastructure/Services/ReflectionCache.php b/src/Discovery/Infrastructure/Services/ReflectionCache.php index e8235967..6e8b3a25 100644 --- a/src/Discovery/Infrastructure/Services/ReflectionCache.php +++ b/src/Discovery/Infrastructure/Services/ReflectionCache.php @@ -215,8 +215,6 @@ private function buildMethodAttributeCache(string $className, ?string $attribute $methodsWithAttributes = []; foreach ($this->getPublicMethods($className) as $method) { - $hasMatchingAttribute = false; - if ($attributeClass === null) { // Get all methods with any attributes $hasMatchingAttribute = ! empty($method->getAttributes()); diff --git a/src/Theme/Domain/Models/ThemeInitializer.php b/src/Theme/Domain/Models/ThemeInitializer.php index 75d93c6a..c54efe46 100644 --- a/src/Theme/Domain/Models/ThemeInitializer.php +++ b/src/Theme/Domain/Models/ThemeInitializer.php @@ -340,7 +340,7 @@ protected function assetRoot(): string try { $container = $this->app->get(AssetManager::class)->getContainer('theme'); - return $container === null ? '' : $container->getBasePath(); + return $container?->getBasePath() ?? ''; } catch (\Throwable) { return ''; } From 3f346cb962b6dd5310e1e619c1954133d2d17d89 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 6 Oct 2026 12:15:46 +0200 Subject: [PATCH 13/13] chore: release v13.34.5 --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d06619b..39d74064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ All notable changes to the Pollora framework will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.4...develop) +## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.5...develop) + +## [v13.34.5](https://github.com/Pollora/framework/compare/v13.34.4...v13.34.5) - 2026-10-06 ### Added - Input fields for typed meta, through a contract (**experimental**): `#[Meta(control: Control::Color, group: 'Profile', hints: ['acf' => [...]])]` describes the field in neutral terms, a control being derived from the type otherwise (`Text`, `RichText` with `sanitize: 'wp_kses_post'`, `Number`, `Toggle`, `DateTime`, `Select`). A package implements `MetaUiDriver` and registers it with `Meta::extend('acf', AcfDriver::class)`; the project picks it in `meta.ui` (none by default), and the driver receives each schema on `init`, with only the meta it supports. `Meta::schemas()`, `Meta::schemaFor('post', 'event')`, the `MetaSchemasRegistered` event, and `MetaUiDriverConformance::check($driver)` for driver authors. The framework names no field plugin