From 8a24a6e09e195280cd9a87786de066257dcc1f17 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 15 Oct 2026 15:53:15 +0200 Subject: [PATCH] feat: require the pollora/meta package Typed meta lives in pollora/meta: the accessor and the fluent record, the schemas, the UI drivers, the WordPress adapters, the discovery and the conformance suite, with their tests. The framework keeps the Laravel side: provider, validator, doctor check and commands. --- CHANGELOG.md | 1 + composer.json | 1 + .../Application/Services/MetaAccessor.php | 110 ------ src/Meta/Application/Services/MetaAuditor.php | 108 ------ .../Services/MetaSchemaBuilder.php | 323 ------------------ .../Services/MetaSchemaRepository.php | 116 ------- .../Application/Services/MetaUiDrivers.php | 66 ---- .../Contracts/MetaInventoryInterface.php | 32 -- .../Contracts/MetaRegistryInterface.php | 15 - .../Domain/Contracts/MetaStoreInterface.php | 39 --- src/Meta/Domain/Contracts/MetaUiDriver.php | 37 -- .../Contracts/MetaValidatorInterface.php | 21 -- src/Meta/Domain/Enums/MetaObjectType.php | 16 - src/Meta/Domain/Enums/MetaValueType.php | 24 -- .../Domain/Events/MetaSchemasRegistered.php | 18 - .../InvalidMetaDefinitionException.php | 37 -- .../Exceptions/InvalidMetaValueException.php | 47 --- .../Exceptions/MetaValidationException.php | 24 -- src/Meta/Domain/Models/MetaDefinition.php | 168 --------- src/Meta/Domain/Models/MetaRecord.php | 175 ---------- src/Meta/Domain/Models/MetaSchema.php | 97 ------ src/Meta/Domain/Services/MetaValueCaster.php | 320 ----------------- .../Adapters/WordPressMetaInventory.php | 116 ------- .../Adapters/WordPressMetaRegistry.php | 133 -------- .../Adapters/WordPressMetaStore.php | 46 --- .../Adapters/WordPressRestMetaValidation.php | 128 ------- .../Infrastructure/Services/MetaDiscovery.php | 119 ------- src/Meta/Testing/Fixtures/EveryMetaKind.php | 68 ---- src/Meta/Testing/InstantiatingContainer.php | 32 -- src/Meta/Testing/MetaUiDriverConformance.php | 66 ---- tests/Unit/Meta/MetaAccessorTest.php | 60 ---- tests/Unit/Meta/MetaArraysAndObjectsTest.php | 190 ----------- tests/Unit/Meta/MetaAuditorTest.php | 46 --- tests/Unit/Meta/MetaDefinitionTest.php | 45 --- tests/Unit/Meta/MetaDiscoveryTest.php | 97 ------ tests/Unit/Meta/MetaRecordTest.php | 184 ---------- tests/Unit/Meta/MetaSchemaBuilderTest.php | 159 --------- tests/Unit/Meta/MetaSchemaRepositoryTest.php | 77 ----- tests/Unit/Meta/MetaUiDriverTest.php | 91 ----- tests/Unit/Meta/MetaValueCasterTest.php | 172 ---------- .../Unit/Meta/WordPressMetaInventoryTest.php | 80 ----- tests/Unit/Meta/WordPressMetaRegistryTest.php | 150 -------- tests/Unit/Meta/WordPressMetaStoreTest.php | 29 -- .../Meta/WordPressRestMetaValidationTest.php | 75 ---- 44 files changed, 2 insertions(+), 3956 deletions(-) delete mode 100644 src/Meta/Application/Services/MetaAccessor.php delete mode 100644 src/Meta/Application/Services/MetaAuditor.php delete mode 100644 src/Meta/Application/Services/MetaSchemaBuilder.php delete mode 100644 src/Meta/Application/Services/MetaSchemaRepository.php delete mode 100644 src/Meta/Application/Services/MetaUiDrivers.php delete mode 100644 src/Meta/Domain/Contracts/MetaInventoryInterface.php delete mode 100644 src/Meta/Domain/Contracts/MetaRegistryInterface.php delete mode 100644 src/Meta/Domain/Contracts/MetaStoreInterface.php delete mode 100644 src/Meta/Domain/Contracts/MetaUiDriver.php delete mode 100644 src/Meta/Domain/Contracts/MetaValidatorInterface.php delete mode 100644 src/Meta/Domain/Enums/MetaObjectType.php delete mode 100644 src/Meta/Domain/Enums/MetaValueType.php delete mode 100644 src/Meta/Domain/Events/MetaSchemasRegistered.php delete mode 100644 src/Meta/Domain/Exceptions/InvalidMetaDefinitionException.php delete mode 100644 src/Meta/Domain/Exceptions/InvalidMetaValueException.php delete mode 100644 src/Meta/Domain/Exceptions/MetaValidationException.php delete mode 100644 src/Meta/Domain/Models/MetaDefinition.php delete mode 100644 src/Meta/Domain/Models/MetaRecord.php delete mode 100644 src/Meta/Domain/Models/MetaSchema.php delete mode 100644 src/Meta/Domain/Services/MetaValueCaster.php delete mode 100644 src/Meta/Infrastructure/Adapters/WordPressMetaInventory.php delete mode 100644 src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php delete mode 100644 src/Meta/Infrastructure/Adapters/WordPressMetaStore.php delete mode 100644 src/Meta/Infrastructure/Adapters/WordPressRestMetaValidation.php delete mode 100644 src/Meta/Infrastructure/Services/MetaDiscovery.php delete mode 100644 src/Meta/Testing/Fixtures/EveryMetaKind.php delete mode 100644 src/Meta/Testing/InstantiatingContainer.php delete mode 100644 src/Meta/Testing/MetaUiDriverConformance.php delete mode 100644 tests/Unit/Meta/MetaAccessorTest.php delete mode 100644 tests/Unit/Meta/MetaArraysAndObjectsTest.php delete mode 100644 tests/Unit/Meta/MetaAuditorTest.php delete mode 100644 tests/Unit/Meta/MetaDefinitionTest.php delete mode 100644 tests/Unit/Meta/MetaDiscoveryTest.php delete mode 100644 tests/Unit/Meta/MetaRecordTest.php delete mode 100644 tests/Unit/Meta/MetaSchemaBuilderTest.php delete mode 100644 tests/Unit/Meta/MetaSchemaRepositoryTest.php delete mode 100644 tests/Unit/Meta/MetaValueCasterTest.php delete mode 100644 tests/Unit/Meta/WordPressMetaInventoryTest.php delete mode 100644 tests/Unit/Meta/WordPressMetaRegistryTest.php delete mode 100644 tests/Unit/Meta/WordPressMetaStoreTest.php delete mode 100644 tests/Unit/Meta/WordPressRestMetaValidationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e57bdd8..fe8a3731 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.36.0...develop) ### Added +- Typed meta lives in its own package, [`pollora/meta`](https://github.com/Pollora/meta), which the framework requires: `MetaAccessor` (`of()`, `schemas()`, `schemaFor()`, `extend()`), the fluent `MetaRecord`, the schema builder and repository, `MetaAuditor`, `MetaUiDrivers`, the value caster, the WordPress adapters, `MetaDiscovery` and `MetaUiDriverConformance`, with their tests. Class names do not change. The framework keeps what needs Laravel: `MetaServiceProvider`, the `Meta` facade and `HasTypedMeta`, the Laravel validator (`LaravelMetaValidator`), the `pollora:meta:*` commands and the `pollora:doctor` check - The asset classes live in their own package, [`pollora/assets`](https://github.com/Pollora/assets), which the framework requires: `AssetManager`, `AssetContainer`, `AssetEnqueuer` (`Asset::add()->container()->useVite()`), `AssetFile`, the record and the wp-admin notice of a missing build. Class names do not change. The package also resolves Vite builds without Laravel, for a classic theme, a plugin or a Bedrock site. The framework keeps what needs Laravel: the service provider, the `Asset` facade, `ViteManager` (Laravel's Vite, where `ASSET_URL`, a CDN and the application URL keep applying), `RootAssetManager` and the Laravel implementations of the package's three contracts (`ViteManagerFactory`, `RuntimeContext`, `PublicPath`) ### Changed diff --git a/composer.json b/composer.json index b80baa1f..37f4141a 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "pollora/attributes": "^0.1 || ^1.0", "pollora/discovery": "^0.1 || ^1.0", "pollora/entity": "^1.2", + "pollora/meta": "^0.1", "pollora/hook": "^2.0.1", "pollora/option": "^1.1", "pollora/schedule": "^1.0", diff --git a/src/Meta/Application/Services/MetaAccessor.php b/src/Meta/Application/Services/MetaAccessor.php deleted file mode 100644 index 54dea0f8..00000000 --- a/src/Meta/Application/Services/MetaAccessor.php +++ /dev/null @@ -1,110 +0,0 @@ -record($this->schema($class), $objectId); - } - - /** - * The schema a class declares, discovered or built on the spot. - * - * @param class-string $class - */ - public function schema(string $class): MetaSchema - { - return $this->schemas->forClass($class) ?? $this->builder->build($class); - } - - /** - * 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(ContainerInterface): 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. - */ - public function record(MetaSchema $schema, int $objectId): MetaRecord - { - return new MetaRecord($schema, $objectId, $this->store, $this->caster, $this->handleUnreadable(...), $this->validator); - } - - private function handleUnreadable(InvalidMetaValueException $exception, MetaDefinition $definition): mixed - { - if ($this->debug) { - throw $exception; - } - - $this->logger?->warning($exception->getMessage()); - - return $definition->default; - } -} diff --git a/src/Meta/Application/Services/MetaAuditor.php b/src/Meta/Application/Services/MetaAuditor.php deleted file mode 100644 index d1c0f1b7..00000000 --- a/src/Meta/Application/Services/MetaAuditor.php +++ /dev/null @@ -1,108 +0,0 @@ -, example: string}> - */ - public function unreadable(int $limit): array - { - $findings = []; - - foreach ($this->schemas->all() as $schema) { - foreach ($schema->definitions as $definition) { - $values = $this->inventory->values($schema->objectType, $schema->subtypes, $definition->key, $limit); - $objects = []; - $example = ''; - - foreach ($values as $objectId => $rows) { - try { - $this->caster->toPhp($definition, $definition->single ? $rows[0] : $rows); - } catch (InvalidMetaValueException $invalidMetaValueException) { - $objects[] = $objectId; - $example = $example === '' ? $invalidMetaValueException->getMessage() : $example; - } - } - - if ($objects === []) { - continue; - } - - $findings[] = [ - 'class' => $schema->declaringClass, - 'property' => $definition->property, - 'key' => $definition->key, - 'owner' => $schema->ownerName(), - 'checked' => count($values), - 'unreadable' => count($objects), - 'objects' => array_slice($objects, 0, self::SAMPLE), - 'example' => $example, - ]; - } - } - - return $findings; - } - - /** - * Keys stored on the post types and taxonomies the project declares that - * no `#[Meta]` declares. Protected keys (starting with "_") are left out. - * - * @param int $limit Keys read per post type or taxonomy, at most - * @return list - */ - public function undeclared(int $limit): array - { - $findings = []; - $seen = []; - - foreach ($this->schemas->all() as $schema) { - if (! $schema->declaresSubtypes) { - continue; - } - - foreach ($schema->subtypes as $subtype) { - if (isset($seen[$schema->objectType->value][$subtype])) { - continue; - } - - $seen[$schema->objectType->value][$subtype] = true; - - foreach ($this->inventory->keys($schema->objectType, [$subtype], $limit) as $key => $objects) { - if (! $this->schemas->declares($schema->objectType, $key)) { - $findings[] = ['owner' => sprintf('%s "%s"', $schema->objectType->value, $subtype), 'key' => $key, 'objects' => $objects]; - } - } - } - } - - return $findings; - } -} diff --git a/src/Meta/Application/Services/MetaSchemaBuilder.php b/src/Meta/Application/Services/MetaSchemaBuilder.php deleted file mode 100644 index f1220379..00000000 --- a/src/Meta/Application/Services/MetaSchemaBuilder.php +++ /dev/null @@ -1,323 +0,0 @@ -resolveOwner($reflection); - - $definitions = []; - $properties = []; - - foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { - $attributes = $property->getAttributes(Meta::class); - - if ($attributes === [] || $property->isStatic()) { - continue; - } - - $definition = $this->buildDefinition($class, $property, $attributes[0]->newInstance(), $objectType); - - if (isset($properties[$definition->key])) { - throw InvalidMetaDefinitionException::forProperty($class, $property->getName(), sprintf( - 'the key "%s" is already used by $%s.', - $definition->key, - $properties[$definition->key] - )); - } - - $properties[$definition->key] = $definition->property; - $definitions[$definition->property] = $definition; - } - - return new MetaSchema($class, $objectType, $subtypes, $definitions, $declaresSubtypes); - } - - /** - * @param ReflectionClass $reflection - * @return array{0: MetaObjectType, 1: list, 2: bool} - */ - private function resolveOwner(ReflectionClass $reflection): array - { - $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)], 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), - }; - } - - private function buildDefinition(string $class, ReflectionProperty $property, Meta $meta, MetaObjectType $objectType): MetaDefinition - { - $name = $property->getName(); - $type = $property->getType(); - - if (! $type instanceof ReflectionNamedType) { - throw InvalidMetaDefinitionException::forProperty($class, $name, 'the property needs a single type (no union, no untyped property).'); - } - - [$valueType, $valueClass] = $this->resolveValueType($class, $name, $type->getName()); - - // 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) { - throw InvalidMetaDefinitionException::forProperty($class, $name, sprintf( - 'the protected key "%s" can only be exposed in REST with an explicit capability.', - $key - )); - } - - if ($meta->media && $valueType !== MetaValueType::Integer) { - throw InvalidMetaDefinitionException::forProperty($class, $name, 'media: true holds an attachment ID, so it needs an int property.'); - } - - if ($meta->revisions && $objectType !== MetaObjectType::Post) { - throw InvalidMetaDefinitionException::forProperty($class, $name, 'revisions only exist for post types.'); - } - - return new MetaDefinition( - property: $name, - key: $key, - valueType: $valueType, - valueClass: $valueClass, - nullable: $type->allowsNull(), - default: $property->hasDefaultValue() ? $property->getDefaultValue() : null, - showInRest: $meta->showInRest, - label: $meta->label, - description: $meta->description, - sanitize: $meta->sanitize, - capability: $meta->capability, - revisions: $meta->revisions, - rules: array_values($meta->rules), - single: $meta->single, - items: $items, - properties: $properties, - control: $meta->control ?? ($meta->media ? Control::Media : $this->defaultControl($valueType, $meta)), - group: $meta->group, - hints: $meta->hints, - media: $meta->media, - public: $meta->public, - ); - } - - /** - * @return array{0: MetaValueType, 1: class-string|null} - */ - private function resolveValueType(string $class, string $property, string $typeName): array - { - $scalar = match ($typeName) { - 'string' => MetaValueType::String, - 'int' => MetaValueType::Integer, - 'float' => MetaValueType::Number, - 'bool' => MetaValueType::Boolean, - default => null, - }; - - if ($scalar instanceof MetaValueType) { - return [$scalar, null]; - } - - if (is_a($typeName, DateTimeInterface::class, true)) { - return [MetaValueType::DateTime, $typeName]; - } - - if (is_a($typeName, BackedEnum::class, true)) { - 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; 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 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. - */ - 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: Naming::snake($propertyName), - valueType: $valueType, - valueClass: $valueClass, - nullable: $type->allowsNull(), - default: $property->hasDefaultValue() ? $property->getDefaultValue() : ($defaults[$propertyName] ?? null), - ); - } - - return $properties; - } -} diff --git a/src/Meta/Application/Services/MetaSchemaRepository.php b/src/Meta/Application/Services/MetaSchemaRepository.php deleted file mode 100644 index 070fd4e5..00000000 --- a/src/Meta/Application/Services/MetaSchemaRepository.php +++ /dev/null @@ -1,116 +0,0 @@ - - */ - private array $schemas = []; - - /** - * @var array> Property names and keys, by object type - */ - private array $names = []; - - /** - * @var array Declarations discovery refused, with the reason - */ - private array $failures = []; - - /** - * @throws InvalidMetaDefinitionException When another class already declares one of its keys on the same object - */ - public function add(MetaSchema $schema): void - { - foreach ($this->schemas as $existing) { - 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->ownerName(), $existing->declaringClass, $schema->declaringClass); - } - } - } - - $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; - } - } - - /** - * @param class-string $class - */ - 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]); - } - - /** - * Records a declaration discovery could not register, so that it can be - * reported (pollora:doctor) instead of only logged. - * - * @param class-string $class - */ - public function fail(string $class, string $reason): void - { - $this->failures[$class] = $reason; - } - - /** - * The declarations discovery refused, with the reason, by class. - * - * @return array - */ - public function failures(): array - { - return $this->failures; - } - - /** - * @return list - */ - public function all(): array - { - return array_values($this->schemas); - } -} diff --git a/src/Meta/Application/Services/MetaUiDrivers.php b/src/Meta/Application/Services/MetaUiDrivers.php deleted file mode 100644 index dbbc9714..00000000 --- a/src/Meta/Application/Services/MetaUiDrivers.php +++ /dev/null @@ -1,66 +0,0 @@ -|Closure(ContainerInterface): MetaUiDriver> */ - private array $drivers = []; - - public function __construct(private readonly ContainerInterface $container) {} - - /** - * @param class-string|Closure(ContainerInterface): 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->get($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/MetaInventoryInterface.php b/src/Meta/Domain/Contracts/MetaInventoryInterface.php deleted file mode 100644 index 99b3a001..00000000 --- a/src/Meta/Domain/Contracts/MetaInventoryInterface.php +++ /dev/null @@ -1,32 +0,0 @@ - $subtypes Post types or taxonomies; empty for every object of the type - * @return array> Values by object ID, one per row - */ - public function values(MetaObjectType $objectType, array $subtypes, string $key, int $limit): array; - - /** - * The keys stored on these objects that do not start with "_", with the - * number of objects carrying each. - * - * @param list $subtypes - * @return array - */ - public function keys(MetaObjectType $objectType, array $subtypes, int $limit): array; -} diff --git a/src/Meta/Domain/Contracts/MetaRegistryInterface.php b/src/Meta/Domain/Contracts/MetaRegistryInterface.php deleted file mode 100644 index 27969c37..00000000 --- a/src/Meta/Domain/Contracts/MetaRegistryInterface.php +++ /dev/null @@ -1,15 +0,0 @@ - - */ - 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/Contracts/MetaUiDriver.php b/src/Meta/Domain/Contracts/MetaUiDriver.php deleted file mode 100644 index c97996aa..00000000 --- a/src/Meta/Domain/Contracts/MetaUiDriver.php +++ /dev/null @@ -1,37 +0,0 @@ - $schemas - */ - public function __construct(public array $schemas) {} -} diff --git a/src/Meta/Domain/Exceptions/InvalidMetaDefinitionException.php b/src/Meta/Domain/Exceptions/InvalidMetaDefinitionException.php deleted file mode 100644 index 62e2b956..00000000 --- a/src/Meta/Domain/Exceptions/InvalidMetaDefinitionException.php +++ /dev/null @@ -1,37 +0,0 @@ -key, - self::describeExpected($definition), - get_debug_type($value) - )); - } - - /** - * A value stored in the database that cannot be read as the declared type. - */ - public static function forRead(MetaDefinition $definition, mixed $raw): self - { - return new self(sprintf( - 'The stored value of the meta "%s" cannot be read as %s: %s.', - $definition->key, - self::describeExpected($definition), - is_scalar($raw) ? var_export($raw, true) : get_debug_type($raw) - )); - } - - private static function describeExpected(MetaDefinition $definition): string - { - $expected = $definition->valueClass ?? $definition->wordPressType(); - - return $definition->nullable ? '?'.$expected : $expected; - } -} diff --git a/src/Meta/Domain/Exceptions/MetaValidationException.php b/src/Meta/Domain/Exceptions/MetaValidationException.php deleted file mode 100644 index 757abec4..00000000 --- a/src/Meta/Domain/Exceptions/MetaValidationException.php +++ /dev/null @@ -1,24 +0,0 @@ - $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 deleted file mode 100644 index ed807a21..00000000 --- a/src/Meta/Domain/Models/MetaDefinition.php +++ /dev/null @@ -1,168 +0,0 @@ - $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 - * @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 - * @param bool $media The value is an attachment ID - * @param bool $public The value may be shown to anyone - */ - public function __construct( - public string $property, - public string $key, - public MetaValueType $valueType, - public ?string $valueClass, - public bool $nullable, - public mixed $default, - public bool $showInRest = false, - public ?string $label = null, - public ?string $description = null, - public string|array|null $sanitize = null, - public ?string $capability = null, - public bool $revisions = false, - public array $rules = [], - public bool $single = true, - public ?MetaDefinition $items = null, - public array $properties = [], - public ?Control $control = null, - public ?string $group = null, - public array $hints = [], - public bool $media = false, - public bool $public = false, - ) {} - - /** - * Whether WordPress treats the key as protected (hidden from custom fields and REST by default). - */ - public function isProtected(): bool - { - return str_starts_with($this->key, '_'); - } - - /** - * The `type` passed to `register_meta()`. - */ - public function wordPressType(): string - { - return match ($this->valueType) { - MetaValueType::String, MetaValueType::DateTime => 'string', - MetaValueType::Integer => 'integer', - 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', - }; - } - - /** - * The JSON schema published with the meta in the REST API. - * - * @return array - */ - 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) { - $schema['format'] = 'date-time'; - } - - if ($this->valueType === MetaValueType::Enum) { - /** @var class-string $enum */ - $enum = $this->valueClass; - $schema['enum'] = array_map(static fn (BackedEnum $case): int|string => $case->value, $enum::cases()); - } - - 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 */ - $enum = $this->valueClass; - - return (new ReflectionEnum($enum))->getBackingType()?->getName() === 'int'; - } -} diff --git a/src/Meta/Domain/Models/MetaRecord.php b/src/Meta/Domain/Models/MetaRecord.php deleted file mode 100644 index 57829a0d..00000000 --- a/src/Meta/Domain/Models/MetaRecord.php +++ /dev/null @@ -1,175 +0,0 @@ -capacity; // int - * $event->fill(['capacity' => 250])->save(); - * - * Properties can be named as declared (`startsAt`) or by their key (`starts_at`). - */ -final class MetaRecord -{ - /** - * @var array Values already read or written, by property name - */ - private array $values = []; - - /** - * @var array|null> Stored forms waiting for save(), by property name - */ - private array $pending = []; - - /** - * @param Closure(InvalidMetaValueException, MetaDefinition): mixed $onUnreadable Decides what an unreadable stored value gives - */ - public function __construct( - private readonly MetaSchema $schema, - private readonly int $objectId, - private readonly MetaStoreInterface $store, - private readonly MetaValueCaster $caster, - private readonly Closure $onUnreadable, - private readonly ?MetaValidatorInterface $validator = null, - ) {} - - public function __get(string $name): mixed - { - return $this->get($name); - } - - public function __set(string $name, mixed $value): void - { - $this->set($name, $value); - } - - public function __isset(string $name): bool - { - return $this->schema->find($name) instanceof MetaDefinition && $this->get($name) !== null; - } - - public function objectId(): int - { - return $this->objectId; - } - - /** - * The value of a meta, with its declared PHP type. - */ - public function get(string $name): mixed - { - $definition = $this->definition($name); - - if (! array_key_exists($definition->property, $this->values)) { - $this->values[$definition->property] = $this->read($definition); - } - - return $this->values[$definition->property]; - } - - /** - * 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); - - if ($stored !== null) { - $this->validator?->validate($definition, $value); - } - - $this->pending[$definition->property] = $stored; - $this->values[$definition->property] = $value; - - return $this; - } - - /** - * @param array $values Values by property name or key - */ - public function fill(array $values): static - { - foreach ($values as $name => $value) { - $this->set($name, $value); - } - - return $this; - } - - /** - * Writes the pending values through WordPress's meta API. - */ - public function save(): static - { - foreach ($this->pending as $property => $stored) { - $definition = $this->schema->definitions[$property]; - - 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 = []; - - return $this; - } - - /** - * Every declared meta, by property name. - * - * @return array - */ - public function toArray(): array - { - $values = []; - - foreach (array_keys($this->schema->definitions) as $property) { - $values[$property] = $this->get($property); - } - - return $values; - } - - private function definition(string $name): MetaDefinition - { - return $this->schema->find($name) ?? throw new InvalidArgumentException(sprintf( - '%s declares no meta named "%s".', - $this->schema->declaringClass, - $name - )); - } - - private function read(MetaDefinition $definition): mixed - { - $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); - } catch (InvalidMetaValueException $invalidMetaValueException) { - return ($this->onUnreadable)($invalidMetaValueException, $definition); - } - } -} diff --git a/src/Meta/Domain/Models/MetaSchema.php b/src/Meta/Domain/Models/MetaSchema.php deleted file mode 100644 index 660e96e8..00000000 --- a/src/Meta/Domain/Models/MetaSchema.php +++ /dev/null @@ -1,97 +0,0 @@ - $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, - ) {} - - /** - * 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)); - } - - /** - * 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. - */ - public function exposesInRest(): bool - { - foreach ($this->definitions as $definition) { - if ($definition->showInRest) { - return true; - } - } - - return false; - } - - public function isEmpty(): bool - { - return $this->definitions === []; - } - - /** - * The definition of a meta, found by its property name or its key. - */ - public function find(string $propertyOrKey): ?MetaDefinition - { - if (isset($this->definitions[$propertyOrKey])) { - return $this->definitions[$propertyOrKey]; - } - - foreach ($this->definitions as $definition) { - if ($definition->key === $propertyOrKey) { - return $definition; - } - } - - return null; - } -} diff --git a/src/Meta/Domain/Services/MetaValueCaster.php b/src/Meta/Domain/Services/MetaValueCaster.php deleted file mode 100644 index 69ebee88..00000000 --- a/src/Meta/Domain/Services/MetaValueCaster.php +++ /dev/null @@ -1,320 +0,0 @@ -absentValue($definition); - } - - return $this->read($definition, $raw) ?? 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 $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|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, - MetaValueType::Number => is_int($value) || is_float($value) ? (string) $value : null, - 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): mixed - { - // Empty stays empty (an absent value), so sanitizing twice changes nothing. - if ($value === null || $value === '') { - return ''; - } - - try { - $phpValue = $value instanceof DateTimeInterface || $value instanceof BackedEnum || ($definition->valueType === MetaValueType::DataObject && is_object($value)) - ? $value - : $this->toPhp($definition, $value); - - return $this->toStorage($definition, $phpValue) ?? ''; - } catch (InvalidMetaValueException) { - return ''; - } - } - - /** - * The default as the REST API publishes it, or null when there is none. - */ - public function toRestDefault(MetaDefinition $definition): mixed - { - $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; - - return $value === false ? null : $value; - } - - private function readDate(MetaDefinition $definition, mixed $raw): ?DateTimeInterface - { - if (! is_string($raw)) { - return null; - } - - try { - $date = CarbonImmutable::parse($raw, 'UTC'); - } catch (Throwable) { - return null; - } - - $class = (string) $definition->valueClass; - - // A property typed with an interface (DateTimeInterface, CarbonInterface) gets a CarbonImmutable. - return match (true) { - is_a($class, DateTimeImmutable::class, true), is_a($class, DateTime::class, true) => $class::createFromInterface($date), - default => $date, - }; - } - - private function readEnum(MetaDefinition $definition, mixed $raw): ?BackedEnum - { - if (! is_scalar($raw)) { - return null; - } - - /** @var class-string $enum */ - $enum = $definition->valueClass; - - if ($definition->wordPressType() === 'integer') { - $raw = $this->readInteger($raw); - - if ($raw === null) { - return null; - } - } - - return $enum::tryFrom($raw); - } - - private function formatDate(DateTimeInterface $date): string - { - return CarbonImmutable::instance($date)->setTimezone(new DateTimeZone('UTC'))->format(self::DATE_FORMAT); - } -} diff --git a/src/Meta/Infrastructure/Adapters/WordPressMetaInventory.php b/src/Meta/Infrastructure/Adapters/WordPressMetaInventory.php deleted file mode 100644 index 3c25bac5..00000000 --- a/src/Meta/Infrastructure/Adapters/WordPressMetaInventory.php +++ /dev/null @@ -1,116 +0,0 @@ -wpdb(); - [$from, $where, $params] = $this->scope($wpdb, $objectType, $subtypes); - - /** @var list $rows */ - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT m.{$this->column($objectType)} AS object_id, m.meta_value FROM {$from} WHERE m.meta_key = %s{$where} ORDER BY m.{$this->idColumn($objectType)} LIMIT %d", - ...[$key, ...$params, max(1, $limit)] - )); - - $values = []; - - foreach ($rows as $row) { - $values[(int) $row->object_id][] = $this->unserialize($row->meta_value); - } - - return $values; - } - - public function keys(MetaObjectType $objectType, array $subtypes, int $limit): array - { - $wpdb = $this->wpdb(); - [$from, $where, $params] = $this->scope($wpdb, $objectType, $subtypes); - $column = $this->column($objectType); - - /** @var list $rows */ - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT m.meta_key, COUNT(DISTINCT m.{$column}) AS objects FROM {$from} WHERE m.meta_key NOT LIKE %s{$where} GROUP BY m.meta_key ORDER BY objects DESC, m.meta_key LIMIT %d", - ...['\_%', ...$params, max(1, $limit)] - )); - - $keys = []; - - foreach ($rows as $row) { - $keys[$row->meta_key] = (int) $row->objects; - } - - return $keys; - } - - /** - * The tables to read and the condition on the subtypes. - * - * @param list $subtypes - * @return array{0: string, 1: string, 2: list} - */ - private function scope(object $wpdb, MetaObjectType $objectType, array $subtypes): array - { - $placeholders = implode(', ', array_fill(0, count($subtypes), '%s')); - - return match ($objectType) { - MetaObjectType::Post => [ - "{$wpdb->postmeta} m INNER JOIN {$wpdb->posts} o ON o.ID = m.post_id", - " AND o.post_type <> 'revision'".($subtypes === [] ? '' : " AND o.post_type IN ({$placeholders})"), - $subtypes, - ], - MetaObjectType::Term => [ - "{$wpdb->termmeta} m".($subtypes === [] ? '' : " INNER JOIN {$wpdb->term_taxonomy} o ON o.term_id = m.term_id"), - $subtypes === [] ? '' : " AND o.taxonomy IN ({$placeholders})", - $subtypes, - ], - MetaObjectType::User => ["{$wpdb->usermeta} m", '', []], - MetaObjectType::Comment => ["{$wpdb->commentmeta} m", '', []], - }; - } - - private function column(MetaObjectType $objectType): string - { - return $objectType->value.'_id'; - } - - private function idColumn(MetaObjectType $objectType): string - { - return $objectType === MetaObjectType::User ? 'umeta_id' : 'meta_id'; - } - - /** - * A stored value as WordPress would return it, never instantiating a class. - */ - private function unserialize(?string $value): mixed - { - if ($value === null || ! \is_serialized($value)) { - return $value; - } - - return @unserialize(trim($value), ['allowed_classes' => false]); - } - - /** - * WordPress's database object, typed loosely: only its table names, - * prepare() and get_results() are used. - * - * @return \wpdb - */ - private function wpdb(): object - { - return $GLOBALS['wpdb']; - } -} diff --git a/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php b/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php deleted file mode 100644 index 253b1995..00000000 --- a/src/Meta/Infrastructure/Adapters/WordPressMetaRegistry.php +++ /dev/null @@ -1,133 +0,0 @@ - 0) { - $this->registerNow($schema); - - return; - } - - $this->action->add('init', fn () => $this->registerNow($schema), self::INIT_PRIORITY); - } - - 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) { - \register_meta($schema->objectType->value, $definition->key, $this->argumentsFor($schema, $definition, $subtype)); - } - } - } - - /** - * @return array - */ - private function argumentsFor(MetaSchema $schema, MetaDefinition $definition, string $subtype): array - { - $arguments = [ - 'object_subtype' => $subtype, - 'type' => $definition->wordPressType(), - 'single' => $definition->single, - 'sanitize_callback' => $definition->sanitize ?? $this->sanitizerFor($definition), - 'show_in_rest' => $definition->showInRest ? ['schema' => $definition->restSchema()] : false, - ]; - - if ($definition->label !== null) { - $arguments['label'] = $definition->label; - } - - if ($definition->description !== null) { - $arguments['description'] = $definition->description; - } - - $default = $this->caster->toRestDefault($definition); - - if ($default !== null) { - $arguments['default'] = $default; - } - - if ($definition->capability !== null) { - $capability = $definition->capability; - $arguments['auth_callback'] = static fn (bool $allowed, string $key, int $objectId, int $userId): bool => \user_can($userId, $capability); - } - - if ($definition->revisions && $schema->objectType === MetaObjectType::Post) { - $arguments['revisions_enabled'] = true; - } - - return $arguments; - } - - 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): 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 deleted file mode 100644 index bb967fea..00000000 --- a/src/Meta/Infrastructure/Adapters/WordPressMetaStore.php +++ /dev/null @@ -1,46 +0,0 @@ -value, $objectId, $key, true); - } - - 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/Adapters/WordPressRestMetaValidation.php b/src/Meta/Infrastructure/Adapters/WordPressRestMetaValidation.php deleted file mode 100644 index 337a9a9f..00000000 --- a/src/Meta/Infrastructure/Adapters/WordPressRestMetaValidation.php +++ /dev/null @@ -1,128 +0,0 @@ - $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/Services/MetaDiscovery.php b/src/Meta/Infrastructure/Services/MetaDiscovery.php deleted file mode 100644 index 8f87d2e6..00000000 --- a/src/Meta/Infrastructure/Services/MetaDiscovery.php +++ /dev/null @@ -1,119 +0,0 @@ -isAbstract) { - 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' => $class]); - - return; - } - } - - if ($structure->extends !== null && is_subclass_of($class, ColtPost::class)) { - $this->getItems()->add($location, ['class' => $class, 'model' => true]); - } - } - - public function apply(): void - { - foreach ($this->getItems() as $item) { - /** @var class-string $class */ - $class = $item['class']; - - if ($item['model'] ?? false) { - $this->registerPostModel($class); - - continue; - } - - try { - $schema = $this->builder->build($class); - - if ($schema->isEmpty()) { - continue; - } - - $this->schemas->add($schema); - $this->registry->register($schema); - } catch (\Throwable $throwable) { - $this->schemas->fail($class, $throwable->getMessage()); - $this->logger?->error(sprintf('Failed to register the meta of %s: %s', $class, $throwable->getMessage()), ['exception' => $throwable]); - } - } - } - - /** - * 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/Meta/Testing/Fixtures/EveryMetaKind.php b/src/Meta/Testing/Fixtures/EveryMetaKind.php deleted file mode 100644 index 6951c926..00000000 --- a/src/Meta/Testing/Fixtures/EveryMetaKind.php +++ /dev/null @@ -1,68 +0,0 @@ - ['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/InstantiatingContainer.php b/src/Meta/Testing/InstantiatingContainer.php deleted file mode 100644 index b4ec55e8..00000000 --- a/src/Meta/Testing/InstantiatingContainer.php +++ /dev/null @@ -1,32 +0,0 @@ -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 InstantiatingContainer))->build($driver, [$schema]); - } catch (Throwable $throwable) { - $violations[] = sprintf('register() throws for the meta it supports: %s', $throwable->getMessage()); - } - - return $violations; - } -} diff --git a/tests/Unit/Meta/MetaAccessorTest.php b/tests/Unit/Meta/MetaAccessorTest.php deleted file mode 100644 index 9e3aacdb..00000000 --- a/tests/Unit/Meta/MetaAccessorTest.php +++ /dev/null @@ -1,60 +0,0 @@ -shouldReceive('get')->andReturn($value); - - return $store; -} - -function metaAccessor(MetaStoreInterface $store, bool $debug = false, ?LoggerInterface $logger = null, ?MetaSchemaRepository $schemas = null): MetaAccessor -{ - return new MetaAccessor($schemas ?? new MetaSchemaRepository, new MetaSchemaBuilder, $store, new MetaValueCaster, $debug, $logger); -} - -it('gives the typed meta of an object', function (): void { - $record = metaAccessor(storeReturning('250'))->of(Event::class, 42); - - expect($record->capacity)->toBe(250) - ->and($record->objectId())->toBe(42); -}); - -it('uses the discovered schema when there is one', function (): void { - $store = Mockery::mock(MetaStoreInterface::class); - $store->shouldReceive('get')->with(MetaObjectType::Term, 7, 'color')->once()->andReturn('red'); - $schemas = new MetaSchemaRepository; - $schemas->add((new MetaSchemaBuilder)->build(BookGenre::class)); - - expect(metaAccessor($store, schemas: $schemas)->of(BookGenre::class, 7)->color)->toBe('red'); -}); - -it('refuses a class that declares no meta', function (): void { - metaAccessor(storeReturning(null))->of(NotADeclaration::class, 1); -})->throws(InvalidMetaDefinitionException::class); - -it('throws on an unreadable stored value in debug mode', function (): void { - metaAccessor(storeReturning('many'), debug: true)->of(Event::class, 42)->capacity; -})->throws(InvalidMetaValueException::class, 'The stored value of the meta "capacity" cannot be read as integer'); - -it('reads an unreadable stored value as the default in production, and logs it', function (): void { - $logger = Mockery::mock(LoggerInterface::class); - $logger->shouldReceive('warning')->once()->with(Mockery::pattern('/meta "capacity" cannot be read as integer/')); - - expect(metaAccessor(storeReturning('many'), logger: $logger)->of(Event::class, 42)->capacity)->toBe(0); -}); diff --git a/tests/Unit/Meta/MetaArraysAndObjectsTest.php b/tests/Unit/Meta/MetaArraysAndObjectsTest.php deleted file mode 100644 index 6ab3e570..00000000 --- a/tests/Unit/Meta/MetaArraysAndObjectsTest.php +++ /dev/null @@ -1,190 +0,0 @@ -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/MetaAuditorTest.php b/tests/Unit/Meta/MetaAuditorTest.php deleted file mode 100644 index 7e936705..00000000 --- a/tests/Unit/Meta/MetaAuditorTest.php +++ /dev/null @@ -1,46 +0,0 @@ -schemas = new MetaSchemaRepository; - $this->schemas->add((new MetaSchemaBuilder)->build(Event::class)); - $this->schemas->add((new MetaSchemaBuilder)->build(ArticleExtras::class)); - - $this->auditor = fn (array $rows): MetaAuditor => new MetaAuditor($this->schemas, new ArrayMetaInventory($rows), new MetaValueCaster); -}); - -it('names the stored values a #[Meta] cannot read as its type', function (): void { - $findings = ($this->auditor)(['post' => ['event' => [ - 10 => ['capacity' => ['250'], 'status' => ['draft']], - 11 => ['capacity' => ['a lot'], 'status' => ['postponed']], - 12 => ['capacity' => ['many']], - ]]])->unreadable(100); - - expect($findings)->toHaveCount(2) - ->and($findings[0])->toMatchArray(['class' => Event::class, 'property' => 'capacity', 'key' => 'capacity', 'owner' => 'post "event"', 'checked' => 3, 'unreadable' => 2, 'objects' => [11, 12]]) - ->and($findings[0]['example'])->toContain("'a lot'") - ->and($findings[1])->toMatchArray(['property' => 'status', 'unreadable' => 1, 'objects' => [11]]); -}); - -it('finds nothing when every stored value reads as its type', function (): void { - expect(($this->auditor)(['post' => ['event' => [10 => ['capacity' => ['250'], 'sold_out' => ['1']]]]])->unreadable(100))->toBe([]); -}); - -it('lists the keys stored on a declared post type that no #[Meta] declares', function (): void { - $findings = ($this->auditor)(['post' => [ - 'event' => [10 => ['capacity' => ['250'], 'old_capacity' => ['200'], '_edit_lock' => ['1']], 11 => ['old_capacity' => ['10']]], - 'post' => [20 => ['legacy_field' => ['x']]], - ]])->undeclared(100); - - // "post" is targeted by #[PostMeta], not declared by the project: its other keys belong to others - expect($findings)->toBe([['owner' => 'post "event"', 'key' => 'old_capacity', 'objects' => 2]]); -}); diff --git a/tests/Unit/Meta/MetaDefinitionTest.php b/tests/Unit/Meta/MetaDefinitionTest.php deleted file mode 100644 index 0c816b9c..00000000 --- a/tests/Unit/Meta/MetaDefinitionTest.php +++ /dev/null @@ -1,45 +0,0 @@ -definitions = (new MetaSchemaBuilder)->build(Event::class)->definitions; -}); - -it('maps each value type to its register_meta() type', function (string $property, string $type): void { - expect($this->definitions[$property]->wordPressType())->toBe($type); -})->with([ - ['summary', 'string'], - ['startsAt', 'string'], - ['capacity', 'integer'], - ['price', 'number'], - ['soldOut', 'boolean'], - ['status', 'string'], - ['priority', 'integer'], -]); - -it('publishes a REST schema with the date format and the enum values', function (): void { - expect($this->definitions['capacity']->restSchema())->toBe(['type' => 'integer']) - ->and($this->definitions['startsAt']->restSchema())->toBe(['type' => 'string', 'format' => 'date-time']) - ->and($this->definitions['status']->restSchema())->toBe(['type' => 'string', 'enum' => ['draft', 'published']]) - ->and($this->definitions['priority']->restSchema())->toBe(['type' => 'integer', 'enum' => [1, 2]]); -}); - -it('knows a key starting with an underscore is protected', function (): void { - expect($this->definitions['internalRef']->isProtected())->toBeTrue() - ->and($this->definitions['capacity']->isProtected())->toBeFalse(); -}); - -it('finds a definition by property name or by key', function (): void { - $schema = (new MetaSchemaBuilder)->build(Event::class); - - expect($schema->find('startsAt')?->key)->toBe('starts_at') - ->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(); -}); diff --git a/tests/Unit/Meta/MetaDiscoveryTest.php b/tests/Unit/Meta/MetaDiscoveryTest.php deleted file mode 100644 index 1390e08e..00000000 --- a/tests/Unit/Meta/MetaDiscoveryTest.php +++ /dev/null @@ -1,97 +0,0 @@ -registry = Mockery::mock(MetaRegistryInterface::class); - $this->schemas = new MetaSchemaRepository; - $this->logger = Mockery::mock(LoggerInterface::class); - $this->discovery = new MetaDiscovery(new MetaSchemaBuilder, $this->schemas, $this->registry, $this->logger); - $this->location = new DiscoveryLocation('Tests\\', __DIR__); - - $this->discover = function (string ...$classes): void { - foreach ($classes as $class) { - $this->discovery->discover($this->location, DiscoveredClass::fromReflection(new ReflectionClass($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, ArticleExtras::class, CategoryExtras::class, MemberProfile::class, ReviewMeta::class]); -}); - -it('stores and registers the schema of each declaring class', function (): void { - $registered = []; - $this->registry->shouldReceive('register')->andReturnUsing(function (MetaSchema $schema) use (&$registered): void { - $registered[] = $schema->declaringClass; - }); - ($this->discover)(Event::class, BookGenre::class, NoMeta::class); - - $this->discovery->apply(); - - expect($registered)->toBe([Event::class, BookGenre::class]) - ->and($this->schemas->forClass(Event::class))->toBeInstanceOf(MetaSchema::class) - ->and($this->schemas->forClass(NoMeta::class))->toBeNull(); -}); - -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: .*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); - - $this->discovery->apply(); - - expect(array_keys($this->schemas->failures()))->toBe([InvalidArray::class, EventExtras::class]) - ->and($this->schemas->failures()[InvalidArray::class])->toContain('say what the array holds'); -}); - -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/MetaRecordTest.php b/tests/Unit/Meta/MetaRecordTest.php deleted file mode 100644 index 9de2f6fd..00000000 --- a/tests/Unit/Meta/MetaRecordTest.php +++ /dev/null @@ -1,184 +0,0 @@ -reads++; - - return $this->stored[$key] ?? null; - } - - public function update(MetaObjectType $objectType, int $objectId, string $key, string|array $value): void - { - $this->writes[] = ['update', $objectType, $objectId, $key, $value]; - $this->stored[$key] = $value; - } - - public function delete(MetaObjectType $objectType, int $objectId, string $key): void - { - $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; - } - }; -} - -function eventRecord(MetaStoreInterface $store, ?Closure $onUnreadable = null): MetaRecord -{ - return new MetaRecord( - (new MetaSchemaBuilder)->build(Event::class), - 42, - $store, - new MetaValueCaster, - $onUnreadable ?? fn (InvalidMetaValueException $exception): never => throw $exception, - ); -} - -it('reads each meta with its declared type, by property name or key', function (): void { - $record = eventRecord(memoryMetaStore(['capacity' => '250', 'sold_out' => '1', 'starts_at' => '2026-11-14T09:00:00+00:00'])); - - expect($record->capacity)->toBe(250) - ->and($record->soldOut)->toBeTrue() - ->and($record->sold_out)->toBeTrue() - ->and($record->startsAt->toIso8601String())->toBe('2026-11-14T09:00:00+00:00') - ->and($record->objectId())->toBe(42); -}); - -it('reads an absent meta as the property default', function (): void { - $record = eventRecord(memoryMetaStore()); - - expect($record->capacity)->toBe(0) - ->and($record->status)->toBe(EventStatus::Draft) - ->and($record->startsAt)->toBeNull(); -}); - -it('reads each meta from the store once', function (): void { - $store = memoryMetaStore(['capacity' => '250']); - $record = eventRecord($store); - - $record->capacity; - $record->capacity; - - expect($store->reads)->toBe(1); -}); - -it('writes pending values on save, in their stored form', function (): void { - $store = memoryMetaStore(); - $record = eventRecord($store); - - $record->capacity = 250; - $record->fill(['status' => EventStatus::Published, 'sold_out' => true]); - - expect($store->writes)->toBe([]) - ->and($record->capacity)->toBe(250); - - $record->save(); - - expect($store->writes)->toBe([ - ['update', MetaObjectType::Post, 42, 'capacity', '250'], - ['update', MetaObjectType::Post, 42, 'status', 'published'], - ['update', MetaObjectType::Post, 42, 'sold_out', '1'], - ]); -}); - -it('deletes a nullable meta set to null', function (): void { - $store = memoryMetaStore(['starts_at' => '2026-11-14T09:00:00+00:00']); - - eventRecord($store)->set('startsAt', null)->save(); - - expect($store->writes)->toBe([['delete', MetaObjectType::Post, 42, 'starts_at']]); -}); - -it('does not write twice', function (): void { - $store = memoryMetaStore(); - $record = eventRecord($store)->set('capacity', 3)->save()->save(); - - expect($store->writes)->toHaveCount(1) - ->and($record->capacity)->toBe(3); -}); - -it('refuses a value of the wrong type before anything is written', function (): void { - $store = memoryMetaStore(); - - expect(fn (): MetaRecord => eventRecord($store)->fill(['capacity' => 'many']))->toThrow(InvalidMetaValueException::class) - ->and($store->writes)->toBe([]); -}); - -it('refuses a meta the class does not declare', function (): void { - eventRecord(memoryMetaStore())->unknown; -})->throws(InvalidArgumentException::class, 'Tests\Unit\Meta\Fixtures\Event declares no meta named "unknown".'); - -it('hands an unreadable stored value to the unreadable handler', function (): void { - $record = eventRecord( - memoryMetaStore(['capacity' => 'many']), - fn (InvalidMetaValueException $exception, MetaDefinition $definition): int => -1, - ); - - expect($record->capacity)->toBe(-1); -}); - -it('lists every meta by property name', function (): void { - $values = eventRecord(memoryMetaStore(['capacity' => '7']))->toArray(); - - expect($values)->toHaveKeys(['startsAt', 'capacity', 'status']) - ->and($values['capacity'])->toBe(7); -}); - -it('tells which meta are set', function (): void { - $record = eventRecord(memoryMetaStore(['capacity' => '7'])); - - expect(isset($record->capacity))->toBeTrue() - ->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/MetaSchemaBuilderTest.php b/tests/Unit/Meta/MetaSchemaBuilderTest.php deleted file mode 100644 index 53f2477f..00000000 --- a/tests/Unit/Meta/MetaSchemaBuilderTest.php +++ /dev/null @@ -1,159 +0,0 @@ -schema = (new MetaSchemaBuilder)->build(Event::class); - }); - - it('attaches the meta to the post type slug', function (): void { - expect($this->schema->objectType)->toBe(MetaObjectType::Post) - ->and($this->schema->subtypes)->toBe(['event']) - ->and($this->schema->declaringClass)->toBe(Event::class); - }); - - it('keeps only public instance properties carrying #[Meta]', function (): void { - expect(array_keys($this->schema->definitions))->toBe([ - 'startsAt', 'capacity', 'price', 'soldOut', 'status', 'priority', 'subtitle', 'internalRef', 'summary', 'endsAt', - ]); - }); - - it('derives the key from the property name, in snake_case', function (): void { - expect($this->schema->definitions['startsAt']->key)->toBe('starts_at') - ->and($this->schema->definitions['internalRef']->key)->toBe('_event_internal_ref'); - }); - - it('derives the value type from the property type', function (string $property, MetaValueType $type, ?string $class): void { - $definition = $this->schema->definitions[$property]; - - expect($definition->valueType)->toBe($type) - ->and($definition->valueClass)->toBe($class); - })->with([ - 'string' => ['summary', MetaValueType::String, null], - 'int' => ['capacity', MetaValueType::Integer, null], - 'float' => ['price', MetaValueType::Number, null], - 'bool' => ['soldOut', MetaValueType::Boolean, null], - 'date' => ['startsAt', MetaValueType::DateTime, CarbonImmutable::class], - 'string enum' => ['status', MetaValueType::Enum, EventStatus::class], - 'int enum' => ['priority', MetaValueType::Enum, Priority::class], - ]); - - it('takes the default from the initial value, null for a nullable property', function (): void { - expect($this->schema->definitions['capacity']->default)->toBe(0) - ->and($this->schema->definitions['status']->default)->toBe(EventStatus::Draft) - ->and($this->schema->definitions['startsAt']->default)->toBeNull() - ->and($this->schema->definitions['startsAt']->nullable)->toBeTrue() - ->and($this->schema->definitions['capacity']->nullable)->toBeFalse(); - }); - - it('carries the attribute options', function (): void { - $startsAt = $this->schema->definitions['startsAt']; - $internalRef = $this->schema->definitions['internalRef']; - - expect($startsAt->showInRest)->toBeTrue() - ->and($startsAt->label)->toBe('Start') - ->and($startsAt->description)->toBe('When the event starts') - ->and($internalRef->capability)->toBe('manage_options') - ->and($this->schema->definitions['subtitle']->revisions)->toBeTrue() - ->and($this->schema->definitions['summary']->sanitize)->toBe('wp_kses_post'); - }); -}); - -it('attaches the meta of a taxonomy to its slug', function (): void { - $schema = (new MetaSchemaBuilder)->build(BookGenre::class); - - expect($schema->objectType)->toBe(MetaObjectType::Term) - ->and($schema->subtypes)->toBe(['book-genre']) - ->and($schema->definitions['color']->key)->toBe('color'); -}); - -it('refuses a class that declares no meta owner', function (): void { - (new MetaSchemaBuilder)->build(NotADeclaration::class); -})->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)) - ->toThrow(InvalidMetaDefinitionException::class, $message); -})->with([ - 'union type' => [InvalidUnion::class, 'needs a single type'], - 'untyped property' => [InvalidUntyped::class, 'needs a single type'], - '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'], - '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'], - 'media on a string' => [InvalidMedia::class, 'media: true holds an attachment ID, so it needs an int property'], -]); - -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->declaresSubtypes)->toBeFalse() - ->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, []], -]); - -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(); -}); - -it('marks an attachment ID and a meta anyone may see', function (): void { - $class = new #[UserMeta] class - { - #[Meta(media: true, public: true)] - public ?int $avatarId = null; - - #[Meta] - public ?string $phone = null; - }; - $definitions = (new MetaSchemaBuilder)->build($class::class)->definitions; - - expect($definitions['avatarId']->media)->toBeTrue() - ->and($definitions['avatarId']->public)->toBeTrue() - ->and($definitions['avatarId']->control)->toBe(Control::Media) - ->and($definitions['phone']->media)->toBeFalse() - ->and($definitions['phone']->public)->toBeFalse(); -}); diff --git a/tests/Unit/Meta/MetaSchemaRepositoryTest.php b/tests/Unit/Meta/MetaSchemaRepositoryTest.php deleted file mode 100644 index 1d7b94b5..00000000 --- a/tests/Unit/Meta/MetaSchemaRepositoryTest.php +++ /dev/null @@ -1,77 +0,0 @@ -build(Event::class); - $genre = (new MetaSchemaBuilder)->build(BookGenre::class); - - $repository->add($event); - $repository->add($genre); - $repository->add($event); - - expect($repository->forClass(Event::class))->toBe($event) - ->and($repository->forClass(EventExtras::class))->toBeNull() - ->and($repository->all())->toBe([$event, $genre]); -}); - -it('refuses a key another class already declares on the same post type', function (): void { - $repository = new MetaSchemaRepository; - $repository->add((new MetaSchemaBuilder)->build(Event::class)); - - $repository->add((new MetaSchemaBuilder)->build(EventExtras::class)); -})->throws( - InvalidMetaDefinitionException::class, - '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); -}); - -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(); -}); diff --git a/tests/Unit/Meta/MetaUiDriverTest.php b/tests/Unit/Meta/MetaUiDriverTest.php index 0b76a0e4..831f7671 100644 --- a/tests/Unit/Meta/MetaUiDriverTest.php +++ b/tests/Unit/Meta/MetaUiDriverTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Illuminate\Config\Repository; -use Illuminate\Container\Container; use Illuminate\Events\Dispatcher; use Illuminate\Foundation\Application; use Illuminate\Translation\ArrayLoader; @@ -11,23 +10,17 @@ 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\Application\Services\MetaSchemaBuilder; use Pollora\Meta\Application\Services\MetaSchemaRepository; use Pollora\Meta\Application\Services\MetaUiDrivers; -use Pollora\Meta\Domain\Contracts\MetaStoreInterface; use Pollora\Meta\Domain\Contracts\MetaUiDriver; use Pollora\Meta\Domain\Enums\Control; -use Pollora\Meta\Domain\Enums\MetaValueType; use Pollora\Meta\Domain\Events\MetaSchemasRegistered; use Pollora\Meta\Domain\Models\MetaDefinition; use Pollora\Meta\Domain\Models\MetaSchema; -use Pollora\Meta\Domain\Services\MetaValueCaster; use Pollora\Meta\Infrastructure\Laravel\MetaServiceProvider; -use Pollora\Meta\Testing\MetaUiDriverConformance; use Psr\Log\LoggerInterface; use Tests\Unit\Meta\Fixtures\Event; -use Tests\Unit\Meta\Fixtures\MemberProfile; /** * A driver that "builds" a field per meta it supports, the way an ACF or Meta @@ -56,90 +49,6 @@ public function register(MetaSchema $schema): void } } -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()); diff --git a/tests/Unit/Meta/MetaValueCasterTest.php b/tests/Unit/Meta/MetaValueCasterTest.php deleted file mode 100644 index 0edcfbc8..00000000 --- a/tests/Unit/Meta/MetaValueCasterTest.php +++ /dev/null @@ -1,172 +0,0 @@ -caster = new MetaValueCaster; - $this->definitions = (new MetaSchemaBuilder)->build(Event::class)->definitions; - $this->definition = fn (string $property): MetaDefinition => $this->definitions[$property]; -}); - -describe('toPhp()', function (): void { - it('returns the default for an absent or empty value', function (mixed $raw): void { - expect($this->caster->toPhp(($this->definition)('capacity'), $raw))->toBe(0) - ->and($this->caster->toPhp(($this->definition)('status'), $raw))->toBe(EventStatus::Draft) - ->and($this->caster->toPhp(($this->definition)('startsAt'), $raw))->toBeNull(); - })->with([null, '']); - - it('reads each type from its stored form', function (string $property, mixed $raw, mixed $expected): void { - expect($this->caster->toPhp(($this->definition)($property), $raw))->toBe($expected); - })->with([ - 'string' => ['summary', 'Hello', 'Hello'], - 'string from a number' => ['summary', 42, '42'], - 'int' => ['capacity', '250', 250], - 'negative int' => ['capacity', '-3', -3], - 'float' => ['price', '12.75', 12.75], - 'string enum' => ['status', 'published', EventStatus::Published], - 'int enum' => ['priority', '2', Priority::High], - ]); - - it('reads booleans tolerantly', function (mixed $raw, bool $expected): void { - expect($this->caster->toPhp(($this->definition)('soldOut'), $raw))->toBe($expected); - })->with([ - ['1', true], ['true', true], ['yes', true], ['on', true], [true, true], - ['0', false], ['false', false], ['no', false], ['off', false], [false, false], - ]); - - it('reads a date as the declared class, in UTC', function (): void { - $startsAt = $this->caster->toPhp(($this->definition)('startsAt'), '2026-11-14T09:00:00+00:00'); - $endsAt = $this->caster->toPhp(($this->definition)('endsAt'), '2026-11-14 18:30:00'); - - expect($startsAt)->toBeInstanceOf(CarbonImmutable::class) - ->and($startsAt->toIso8601String())->toBe('2026-11-14T09:00:00+00:00') - ->and($endsAt)->toBeInstanceOf(CarbonImmutable::class) - ->and($endsAt->toIso8601String())->toBe('2026-11-14T18:30:00+00:00'); - }); - - it('reads a date into a mutable class when the property asks for one', function (): void { - $definition = new MetaDefinition('at', 'at', MetaValueType::DateTime, Carbon::class, true, null); - - expect($this->caster->toPhp($definition, '2026-01-02T03:04:05+00:00'))->toBeInstanceOf(Carbon::class); - }); - - it('refuses a stored value it cannot read as the declared type', function (string $property, mixed $raw): void { - expect(fn () => $this->caster->toPhp(($this->definition)($property), $raw)) - ->toThrow(InvalidMetaValueException::class, 'cannot be read as'); - })->with([ - 'array for a string' => ['summary', ['a']], - 'text for an int' => ['capacity', 'many'], - 'decimal for an int' => ['capacity', '1.5'], - 'array for an int' => ['capacity', ['1']], - 'text for a float' => ['price', 'cheap'], - 'text for a bool' => ['soldOut', 'maybe'], - 'array for a bool' => ['soldOut', ['1']], - 'garbage for a date' => ['startsAt', 'not a date'], - 'number for a date' => ['startsAt', 12], - 'unknown enum case' => ['status', 'archived'], - 'array for an enum' => ['status', ['draft']], - 'text for an int enum' => ['priority', 'high'], - 'unknown int enum case' => ['priority', '9'], - ]); -}); - -describe('toStorage()', function (): void { - it('stores each type as a string', function (string $property, mixed $value, string $expected): void { - expect($this->caster->toStorage(($this->definition)($property), $value))->toBe($expected); - })->with([ - 'string' => ['summary', 'Hello', 'Hello'], - 'stringable' => ['summary', new class implements Stringable - { - public function __toString(): string - { - return 'Stringable'; - } - }, 'Stringable'], - 'int' => ['capacity', 250, '250'], - 'float' => ['price', 12.5, '12.5'], - 'int for a float' => ['price', 3, '3'], - 'true' => ['soldOut', true, '1'], - 'false' => ['soldOut', false, '0'], - 'string enum' => ['status', EventStatus::Published, 'published'], - 'int enum' => ['priority', Priority::High, '2'], - ]); - - it('stores dates in ISO 8601, in UTC', function (): void { - $date = new DateTimeImmutable('2026-11-14 10:00:00', new DateTimeZone('Europe/Paris')); - - expect($this->caster->toStorage(($this->definition)('startsAt'), $date))->toBe('2026-11-14T09:00:00+00:00'); - }); - - it('turns null into a deletion for a nullable meta', function (): void { - expect($this->caster->toStorage(($this->definition)('startsAt'), null))->toBeNull(); - }); - - it('refuses a value of the wrong type', function (string $property, mixed $value, string $message): void { - expect(fn () => $this->caster->toStorage(($this->definition)($property), $value)) - ->toThrow(InvalidMetaValueException::class, $message); - })->with([ - 'null for a non-nullable meta' => ['capacity', null, 'The meta "capacity" expects integer, null given.'], - 'string for an int' => ['capacity', '250', 'expects integer, string given'], - 'int for a string' => ['summary', 12, 'expects string, int given'], - 'int for a bool' => ['soldOut', 1, 'expects boolean, int given'], - 'string for a date' => ['startsAt', '2026-11-14', 'expects ?Carbon\CarbonImmutable, string given'], - 'another enum' => ['status', Priority::High, 'expects Tests\Unit\Meta\Fixtures\EventStatus'], - 'string for a float' => ['price', '1.5', 'expects number, string given'], - ]); -}); - -describe('sanitize()', function (): void { - it('normalizes values written through WordPress to the stored form', function (string $property, mixed $value, string $expected): void { - expect($this->caster->sanitize(($this->definition)($property), $value))->toBe($expected); - })->with([ - 'numeric string for an int' => ['capacity', '42', '42'], - 'yes for a bool' => ['soldOut', 'yes', '1'], - 'bool for a bool' => ['soldOut', false, '0'], - 'enum value' => ['status', 'published', 'published'], - 'enum case' => ['status', EventStatus::Published, 'published'], - 'date string' => ['startsAt', '2026-11-14 10:00:00+01:00', '2026-11-14T09:00:00+00:00'], - 'date object' => ['startsAt', new DateTimeImmutable('2026-11-14T09:00:00+00:00'), '2026-11-14T09:00:00+00:00'], - 'empty for a nullable meta' => ['startsAt', '', ''], - 'empty for a meta with a default' => ['capacity', '', ''], - 'null' => ['capacity', null, ''], - ]); - - it('gives the same result when applied twice', function (string $property, mixed $value): void { - $definition = ($this->definition)($property); - - expect($this->caster->sanitize($definition, $this->caster->sanitize($definition, $value))) - ->toBe($this->caster->sanitize($definition, $value)); - })->with([ - 'unreadable int' => ['capacity', '42abc'], - 'int' => ['capacity', '42'], - 'bool' => ['soldOut', 'yes'], - 'date' => ['startsAt', '2026-11-14 10:00:00+01:00'], - ]); - - it('empties a value it cannot read, which then reads as the default', function (string $property, mixed $value): void { - expect($this->caster->sanitize(($this->definition)($property), $value))->toBe(''); - })->with([ - 'text for an int' => ['capacity', 'many'], - 'unknown enum case' => ['status', 'archived'], - 'wrong enum' => ['status', Priority::Low], - 'date for an int' => ['capacity', new DateTimeImmutable], - ]); -}); - -it('publishes the default in its REST form', function (): void { - expect($this->caster->toRestDefault(($this->definition)('capacity')))->toBe(0) - ->and($this->caster->toRestDefault(($this->definition)('soldOut')))->toBeFalse() - ->and($this->caster->toRestDefault(($this->definition)('status')))->toBe('draft') - ->and($this->caster->toRestDefault(($this->definition)('startsAt')))->toBeNull(); -}); diff --git a/tests/Unit/Meta/WordPressMetaInventoryTest.php b/tests/Unit/Meta/WordPressMetaInventoryTest.php deleted file mode 100644 index 90ca5f25..00000000 --- a/tests/Unit/Meta/WordPressMetaInventoryTest.php +++ /dev/null @@ -1,80 +0,0 @@ -previousWpdb = $GLOBALS['wpdb'] ?? null; - $this->queries = []; - $this->results = []; - // Only what the adapter uses: table names, prepare() and get_results() - $GLOBALS['wpdb'] = new class($this) - { - public string $postmeta = 'wp_postmeta'; - - public string $posts = 'wp_posts'; - - public string $termmeta = 'wp_termmeta'; - - public string $term_taxonomy = 'wp_term_taxonomy'; - - public string $usermeta = 'wp_usermeta'; - - public string $commentmeta = 'wp_commentmeta'; - - public function __construct(private readonly object $test) {} - - public function prepare(string $query, mixed ...$args): string - { - $this->test->queries[] = [$query, $args]; - - return $query; - } - - public function get_results(string $query): array - { - return $this->test->results; - } - }; - Functions\when('is_serialized')->alias(fn (string $value): bool => str_starts_with($value, 'a:') || str_starts_with($value, 'O:')); -}); - -afterEach(function (): void { - $GLOBALS['wpdb'] = $this->previousWpdb; -}); - -it('reads the values of a key on the post types given, revisions left out, never into objects', function (): void { - $this->results = [ - (object) ['object_id' => '5', 'meta_value' => '250'], - (object) ['object_id' => '5', 'meta_value' => 'a:1:{i:0;s:4:"rock";}'], - (object) ['object_id' => '6', 'meta_value' => 'O:8:"stdClass":0:{}'], - ]; - - $values = (new WordPressMetaInventory)->values(MetaObjectType::Post, ['event', 'concert'], 'capacity', 100); - [$query, $args] = $this->queries[0]; - - expect($values[5])->toBe(['250', ['rock']]) - ->and($values[6][0])->toBeInstanceOf(__PHP_Incomplete_Class::class) - ->and($query)->toContain("FROM wp_postmeta m INNER JOIN wp_posts o ON o.ID = m.post_id WHERE m.meta_key = %s AND o.post_type <> 'revision' AND o.post_type IN (%s, %s)") - ->and($args)->toBe(['capacity', 'event', 'concert', 100]); -}); - -it('counts the keys stored on a taxonomy, protected ones left out', function (): void { - $this->results = [(object) ['meta_key' => 'color', 'objects' => '3']]; - - $keys = (new WordPressMetaInventory)->keys(MetaObjectType::Term, ['genre'], 50); - [$query, $args] = $this->queries[0]; - - expect($keys)->toBe(['color' => 3]) - ->and($query)->toContain('FROM wp_termmeta m INNER JOIN wp_term_taxonomy o ON o.term_id = m.term_id WHERE m.meta_key NOT LIKE %s AND o.taxonomy IN (%s)') - ->and($args)->toBe(['\_%', 'genre', 50]); -}); - -it('reads user meta without a subtype', function (): void { - (new WordPressMetaInventory)->values(MetaObjectType::User, [], 'job_title', 10); - - expect($this->queries[0][0])->toContain('SELECT m.user_id AS object_id, m.meta_value FROM wp_usermeta m WHERE m.meta_key = %s ORDER BY m.umeta_id'); -}); diff --git a/tests/Unit/Meta/WordPressMetaRegistryTest.php b/tests/Unit/Meta/WordPressMetaRegistryTest.php deleted file mode 100644 index 2e084d82..00000000 --- a/tests/Unit/Meta/WordPressMetaRegistryTest.php +++ /dev/null @@ -1,150 +0,0 @@ -}> - */ -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]; - - return true; - }); - - (new WordPressMetaRegistry(Mockery::mock(Action::class), new MetaValueCaster)) - ->register((new MetaSchemaBuilder)->build($class)); - - return $calls; -} - -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 { - Functions\expect('register_meta')->times(10); - $callback(); - - return $action; - }); - - (new WordPressMetaRegistry($action, new MetaValueCaster))->register((new MetaSchemaBuilder)->build(Event::class)); -}); - -it('registers each meta on its post type, single, with its type', function (): void { - [$objectType, $args] = registeredEventMeta()['capacity']; - - expect($objectType)->toBe('post') - ->and($args['object_subtype'])->toBe('event') - ->and($args['type'])->toBe('integer') - ->and($args['single'])->toBeTrue() - ->and($args['default'])->toBe(0) - ->and($args['show_in_rest'])->toBe(['schema' => ['type' => 'integer']]); -}); - -it('registers term meta on the taxonomy', function (): void { - [$objectType, $args] = registeredEventMeta(BookGenre::class)['color']; - - expect($objectType)->toBe('term') - ->and($args['object_subtype'])->toBe('book-genre'); -}); - -it('keeps a meta out of REST unless asked', function (): void { - expect(registeredEventMeta()['price'][1]['show_in_rest'])->toBeFalse(); -}); - -it('passes label and description only when given', function (): void { - $meta = registeredEventMeta(); - - expect($meta['starts_at'][1])->toMatchArray(['label' => 'Start', 'description' => 'When the event starts']) - ->and($meta['capacity'][1])->not->toHaveKeys(['label', 'description']); -}); - -it('leaves out the default of a nullable meta', function (): void { - expect(registeredEventMeta()['starts_at'][1])->not->toHaveKey('default'); -}); - -it('sanitizes strings with sanitize_text_field() and other types with the caster', function (): void { - $meta = registeredEventMeta(); - - expect($meta['subtitle'][1]['sanitize_callback'])->toBe('sanitize_text_field') - ->and(($meta['capacity'][1]['sanitize_callback'])('42abc'))->toBe('') - ->and(($meta['sold_out'][1]['sanitize_callback'])('yes'))->toBe('1'); -}); - -it('uses the declared sanitize callback instead', function (): void { - expect(registeredEventMeta()['summary'][1]['sanitize_callback'])->toBe('wp_kses_post'); -}); - -it('checks the declared capability for writes', function (): void { - $auth = registeredEventMeta()['_event_internal_ref'][1]['auth_callback']; - Functions\expect('user_can')->once()->with(3, 'manage_options')->andReturn(false); - - expect($auth(true, '_event_internal_ref', 42, 3))->toBeFalse(); -}); - -it('leaves write checks to WordPress without a capability', function (): void { - expect(registeredEventMeta()['capacity'][1])->not->toHaveKey('auth_callback'); -}); - -it('enables revisions when asked', function (): void { - $meta = registeredEventMeta(); - - 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('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']]; - - 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', ''], - ]); -}); - -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)); -}); diff --git a/tests/Unit/Meta/WordPressMetaStoreTest.php b/tests/Unit/Meta/WordPressMetaStoreTest.php deleted file mode 100644 index 1d757125..00000000 --- a/tests/Unit/Meta/WordPressMetaStoreTest.php +++ /dev/null @@ -1,29 +0,0 @@ -alias(fn (string $value): string => addslashes($value)); -}); - -it('reads the raw stored value, without the registered default', function (): void { - Functions\expect('get_metadata_raw')->once()->with('post', 42, 'capacity', true)->andReturn('250'); - - expect((new WordPressMetaStore)->get(MetaObjectType::Post, 42, 'capacity'))->toBe('250'); -}); - -it('writes through update_metadata(), slashed', function (): void { - Functions\expect('update_metadata')->once()->with('term', 7, 'path', 'C:\\\\dir'); - - (new WordPressMetaStore)->update(MetaObjectType::Term, 7, 'path', 'C:\\dir'); -}); - -it('deletes through delete_metadata()', function (): void { - Functions\expect('delete_metadata')->once()->with('post', 42, 'starts_at'); - - (new WordPressMetaStore)->delete(MetaObjectType::Post, 42, 'starts_at'); -}); diff --git a/tests/Unit/Meta/WordPressRestMetaValidationTest.php b/tests/Unit/Meta/WordPressRestMetaValidationTest.php deleted file mode 100644 index 6b770306..00000000 --- a/tests/Unit/Meta/WordPressRestMetaValidationTest.php +++ /dev/null @@ -1,75 +0,0 @@ - '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); -});