diff --git a/Classes/Command/AssetMetaDataCommandController.php b/Classes/Command/AssetMetaDataCommandController.php index 0da47b5..329fab7 100644 --- a/Classes/Command/AssetMetaDataCommandController.php +++ b/Classes/Command/AssetMetaDataCommandController.php @@ -10,6 +10,9 @@ use Neos\MetaData\Domain\Dto\MetaDataAssetReference; use Neos\MetaData\Domain\Dto\MetaDataDimensionSpacePoint; use Neos\MetaData\Domain\Dto\MetaDataPropertyName; +use Neos\MetaData\Maintenance\MetaDataRepair; +use Neos\MetaData\Maintenance\MetaDataRepairAction; +use Neos\MetaData\Maintenance\MetaDataRepairActionType; use Neos\MetaData\MetaDataManager; final class AssetMetaDataCommandController extends CommandController @@ -17,6 +20,7 @@ final class AssetMetaDataCommandController extends CommandController public function __construct( private readonly MetaDataManager $metaDataManager, + private readonly MetaDataRepair $metaDataRepair, ) { parent::__construct(); @@ -25,6 +29,9 @@ public function __construct( /** * Sets a metadata property for an asset to a specific value * + * For properties with a global scope the dimension space point is ignored, because such properties + * have a single value that is shared by all dimensions. + * * @param string $assetId ID of the asset to set the metadata property for * @param string $property name of the metadata property to set * @param string $value value of the metadata property @@ -41,9 +48,9 @@ public function setCommand(string $assetId, string $property, string $value, str $value, $dimensionSpacePointDecoded, ); - $message = sprintf('Metadata property "%s" of asset "%s" was set to "%s"', $property, $value, $assetId); + $message = sprintf('Metadata property "%s" of asset "%s" was set to "%s"', $property, $assetId, $value); if ($dimensionSpacePointDecoded !== null) { - $message .= sprintf(' for dimension space point "%s"', $dimensionSpacePointDecoded->hash); + $message .= sprintf(' for dimension space point "%s"', $dimensionSpacePointDecoded); } $this->outputLine("$message"); } @@ -67,7 +74,7 @@ public function unsetCommand(string $assetId, string $property, string|null $ass ); $message = sprintf('Metadata property "%s" of asset "%s" was unset', $property, $assetId); if ($dimensionSpacePointDecoded !== null) { - $message .= sprintf(' for dimension space point "%s"', $dimensionSpacePointDecoded->hash); + $message .= sprintf(' for dimension space point "%s"', $dimensionSpacePointDecoded); } $this->outputLine("$message"); } @@ -75,7 +82,9 @@ public function unsetCommand(string $assetId, string $property, string|null $ass /** * Lists all metadata properties for an asset * - * @param string $assetId ID of the asset to unset the metadata property for + * Values that stem from a fallback dimension are marked as inherited. + * + * @param string $assetId ID of the asset to list the metadata properties for * @param string|null $assetSource optional asset source - default = "neos" * @param string|null $dimensionSpacePoint optional dimension space point as JSON (e.g. `'{"language": "de"}') - default = the configured defaultDimensionSpacePoint */ @@ -89,12 +98,117 @@ public function listCommand(string $assetId, string|null $assetSource = null, st ); $message = sprintf('Metadata properties of asset "%s"', $assetId); if ($dimensionSpacePointDecoded !== null) { - $message .= sprintf(' for dimension space point "%s"', $dimensionSpacePointDecoded->hash); + $message .= sprintf(' for dimension space point "%s"', $dimensionSpacePointDecoded); } - $message .= ':'; - $this->outputLine($message); + $this->outputLine($message . ':'); foreach ($metaDataPropertyValues as $propertyName => $propertyValue) { - $this->outputLine(' %s: %s', [$propertyName, $propertyValue ?? '-']); + $line = sprintf(' %s: %s', $propertyName, $propertyValue->value ?? '-'); + if ($propertyValue->isInherited()) { + $line .= sprintf(' (inherited from %s)', $propertyValue->inheritedFrom); + } + $this->outputLine($line); + } + } + + /** + * Finds and fixes metadata values whose scope contradicts the current configuration + * + * Whether a property has a single shared value or one value per dimension is configured via + * `Neos.MetaData.metaDataProperties..globalScope`. Changing that leaves values behind that no + * longer match. Those are never returned when reading metadata, so this command is about tidying up + * rather than about fixing broken reads. + * + * Without `--force` nothing is changed and the pending changes are merely reported. + * + * @param bool $force apply the changes instead of only reporting them + * @param bool $prune also remove values of dimensions and of properties that are no longer configured + */ + public function repairCommand(bool $force = false, bool $prune = false): void + { + if (!$this->metaDataRepair->isSupported()) { + $this->outputLine('The configured metadata storage does not support repairing'); + $this->quit(1); + } + if ($prune && !$this->metaDataRepair->hasConfiguredDimensions()) { + $this->outputLine('Refusing to prune because no content dimension is configured'); + $this->outputLine('Every value stored for a dimension would look obsolete, which is also what a broken dimension configuration looks like.'); + $this->quit(1); + } + + $actions = $this->metaDataRepair->analyze(); + if ($actions === []) { + $this->outputLine('No metadata values need repairing'); + return; + } + + $this->outputScopeActions($actions); + $this->outputPruneActions($actions, $prune); + + $applicable = array_filter($actions, static fn (MetaDataRepairAction $action) => $prune || !$action->type->requiresPrune()); + if ($applicable === []) { + return; + } + if (!$force) { + $this->outputLine(); + $this->outputLine('Nothing was changed. Re-run with --force to apply.'); + return; + } + $deleted = $this->metaDataRepair->apply($actions, $prune); + $this->outputLine(); + $this->outputLine('Repaired metadata values, %d value(s) were removed', [$deleted]); + } + + // ----------------------- + + /** + * @param list $actions + */ + private function outputScopeActions(array $actions): void + { + $scopeActions = array_filter($actions, static fn (MetaDataRepairAction $action) => !$action->type->requiresPrune()); + if ($scopeActions === []) { + return; + } + $this->outputLine('Values with a scope that contradicts the property definition:'); + foreach ($scopeActions as $action) { + $storedValue = $action->storedValue; + $description = match ($action->type) { + MetaDataRepairActionType::promoteToGlobalScope => sprintf('keep "%s" as the shared value', $storedValue->value), + MetaDataRepairActionType::promoteToDefaultDimension => sprintf('store "%s" for the default dimension', $storedValue->value), + MetaDataRepairActionType::deleteWrongScope => sprintf('delete "%s" (%s)', $storedValue->value, $storedValue->global ? 'shared value' : 'dimension ' . $storedValue->dimensionHash), + default => '', + }; + $this->outputLine(sprintf(' %s / %s: %s', $storedValue->assetReference->assetId, $storedValue->propertyName, $description)); + } + } + + /** + * @param list $actions + */ + private function outputPruneActions(array $actions, bool $prune): void + { + $obsoleteDimensions = 0; + $undefinedProperties = 0; + foreach ($actions as $action) { + match ($action->type) { + MetaDataRepairActionType::deleteObsoleteDimension => $obsoleteDimensions++, + MetaDataRepairActionType::deleteUndefinedProperty => $undefinedProperties++, + default => null, + }; + } + if ($obsoleteDimensions === 0 && $undefinedProperties === 0) { + return; + } + $this->outputLine(); + $this->outputLine('Unreachable values:'); + if ($obsoleteDimensions > 0) { + $this->outputLine(sprintf(' %d value(s) stored for a dimension that is no longer configured', $obsoleteDimensions)); + } + if ($undefinedProperties > 0) { + $this->outputLine(sprintf(' %d value(s) of a property that is no longer defined', $undefinedProperties)); + } + if (!$prune) { + $this->outputLine(' Re-run with --prune to include them.'); } } diff --git a/Classes/Configuration/MetaDataConfigurationProviderYamlAdapter.php b/Classes/Configuration/MetaDataConfigurationProviderYamlAdapter.php index 4c57b6b..c326d60 100644 --- a/Classes/Configuration/MetaDataConfigurationProviderYamlAdapter.php +++ b/Classes/Configuration/MetaDataConfigurationProviderYamlAdapter.php @@ -24,6 +24,10 @@ public function getPropertyConfiguration(): MetaDataPropertyDefinitions { $propertyDefinitions = []; foreach ($this->propertyConfiguration as $propertyName => $propertyDefinition) { + if ($propertyDefinition === null) { + // allows to disable property definitions that are configured elsewhere + continue; + } $propertyDefinitions[] = new MetaDataPropertyDefinition( MetaDataPropertyName::fromString($propertyName), match ($propertyDefinition['type'] ?? null) { @@ -32,13 +36,13 @@ public function getPropertyConfiguration(): MetaDataPropertyDefinitions default => MetaDataPropertyType::string, }, $propertyDefinition['globalScope'] ?? false, - new MetaDataPropertyUiDefinition( + array_key_exists('ui', $propertyDefinition) ? new MetaDataPropertyUiDefinition( $this->translatePropertyName($propertyName, $propertyDefinition['ui']['label'] ?? null), MetaDataEditorDefinition::create( editorType: $propertyDefinition['ui']['inspector']['editor'] ?? null, options: $propertyDefinition['ui']['inspector']['editorOptions'] ?? [], ) - ) + ) : null, ); } return MetaDataPropertyDefinitions::create(...$propertyDefinitions); diff --git a/Classes/Domain/Dto/MetaDataAssetFilter.php b/Classes/Domain/Dto/MetaDataAssetFilter.php new file mode 100644 index 0000000..6d54bc7 --- /dev/null +++ b/Classes/Domain/Dto/MetaDataAssetFilter.php @@ -0,0 +1,58 @@ + + */ +final readonly class MetaDataPropertyNames implements IteratorAggregate, Countable +{ + /** + * @param list $propertyNames + */ + private function __construct( + private array $propertyNames, + ) { + } + + public static function create(MetaDataPropertyName|string ...$propertyNames): self + { + return new self(array_values(array_map( + static fn (MetaDataPropertyName|string $propertyName) => is_string($propertyName) + ? MetaDataPropertyName::fromString($propertyName) + : $propertyName, + $propertyNames, + ))); + } + + public static function createEmpty(): self + { + return new self([]); + } + + public function include(MetaDataPropertyName $propertyName): bool + { + foreach ($this->propertyNames as $existingPropertyName) { + if ($existingPropertyName->equals($propertyName->value)) { + return true; + } + } + return false; + } + + public function isEmpty(): bool + { + return $this->propertyNames === []; + } + + /** + * @template T + * @param Closure(MetaDataPropertyName): T $callback + * @return T[] + */ + public function map(Closure $callback): array + { + return array_map($callback, $this->propertyNames); + } + + public function getIterator(): Traversable + { + yield from $this->propertyNames; + } + + public function count(): int + { + return count($this->propertyNames); + } +} diff --git a/Classes/Domain/Dto/MetaDataPropertyType.php b/Classes/Domain/Dto/MetaDataPropertyType.php index 2f7a849..4548739 100644 --- a/Classes/Domain/Dto/MetaDataPropertyType.php +++ b/Classes/Domain/Dto/MetaDataPropertyType.php @@ -4,11 +4,90 @@ namespace Neos\MetaData\Domain\Dto; +use InvalidArgumentException; + /** - * Type of a custom asset metadata property + * Type of a custom asset metadata property. + * + * Values are stored as strings, so this is also what turns a value into its stored representation and + * back: {@see self::coerceForStorage()} on the way in, {@see self::fromStoredValue()} on the way out. + * + * The two directions are deliberately not equally strict. Writing rejects what it cannot interpret, + * because a caller passing "abc" for an integer property has made a mistake that should not be + * silently turned into 0. Reading cannot afford to throw – it meets values that were written before a + * property was given its current type – so it yields NULL, and the property reads as if it had no value. */ -enum MetaDataPropertyType { +enum MetaDataPropertyType +{ case string; case integer; case boolean; + + /** + * The given value in the representation it is stored as. + * + * Unambiguous conversions are applied, so that callers which only ever have strings – the command + * line, form input, Fusion – do not have to cast: "42" is a valid integer, "true", "on" and "yes" + * are a valid boolean, as are their negative counterparts. Anything else is rejected. + * + * @throws InvalidArgumentException if the value cannot be interpreted as this type + */ + public function coerceForStorage(string|int|bool $value): string + { + $coerced = $this->tryCoerce($value); + if ($coerced === null) { + throw new InvalidArgumentException(sprintf('Value %s cannot be interpreted as %s', json_encode($value), $this->name), 1785715201); + } + return is_bool($coerced) ? ($coerced ? '1' : '0') : (string)$coerced; + } + + /** + * The given stored value as this type, or NULL if it cannot be interpreted as one + */ + public function fromStoredValue(string|int|bool $value): string|int|bool|null + { + return $this->tryCoerce($value); + } + + // ----------------------- + + private function tryCoerce(string|int|bool $value): string|int|bool|null + { + return match ($this) { + self::string => is_bool($value) ? ($value ? '1' : '0') : (string)$value, + self::integer => self::toInteger($value), + self::boolean => self::toBoolean($value), + }; + } + + private static function toInteger(string|int|bool $value): ?int + { + if (is_int($value)) { + return $value; + } + if (is_bool($value)) { + return $value ? 1 : 0; + } + $trimmed = trim($value); + return preg_match('/^-?\d+$/', $trimmed) === 1 ? (int)$trimmed : null; + } + + private static function toBoolean(string|int|bool $value): ?bool + { + if (is_bool($value)) { + return $value; + } + if (is_int($value)) { + return match ($value) { + 0 => false, + 1 => true, + default => null, + }; + } + return match (strtolower(trim($value))) { + '1', 'true', 'on', 'yes' => true, + '0', 'false', 'off', 'no' => false, + default => null, + }; + } } diff --git a/Classes/Domain/Dto/MetaDataPropertyValue.php b/Classes/Domain/Dto/MetaDataPropertyValue.php new file mode 100644 index 0000000..ce2b93a --- /dev/null +++ b/Classes/Domain/Dto/MetaDataPropertyValue.php @@ -0,0 +1,78 @@ +ownValue !== null; + } + + /** Fusion getter access */ + public function getOwnValue(): string|int|bool|null + { + return $this->ownValue; + } + + /** + * Whether the effective value stems from a fallback dimension space point + */ + public function isInherited(): bool + { + return $this->ownValue === null && $this->inheritedValue !== null; + } +} diff --git a/Classes/Domain/Dto/MetaDataPropertyValues.php b/Classes/Domain/Dto/MetaDataPropertyValues.php index 9f2d691..091276f 100644 --- a/Classes/Domain/Dto/MetaDataPropertyValues.php +++ b/Classes/Domain/Dto/MetaDataPropertyValues.php @@ -4,17 +4,18 @@ namespace Neos\MetaData\Domain\Dto; +use InvalidArgumentException; use IteratorAggregate; use Traversable; /** - * Value of a custom asset metadata property - * @implements IteratorAggregate + * The values of all defined metadata properties, as seen from one {@see MetaDataDimensionSpacePoint} + * @implements IteratorAggregate */ -final class MetaDataPropertyValues implements IteratorAggregate { +final readonly class MetaDataPropertyValues implements IteratorAggregate { /** - * @param array $values + * @param array $values */ private function __construct( private array $values, @@ -26,11 +27,29 @@ public static function createEmpty(): self return new self([]); } - public function with(MetaDataPropertyName $propertyName, string|int|bool|null $value): self + public function with(MetaDataPropertyName $propertyName, MetaDataPropertyValue $value): self { return new self([...$this->values, $propertyName->value => $value]); } + public function get(MetaDataPropertyName $propertyName): MetaDataPropertyValue + { + if (!array_key_exists($propertyName->value, $this->values)) { + throw new InvalidArgumentException(sprintf('Metadata property "%s" is not defined', $propertyName), 1776278183); + } + return $this->values[$propertyName->value]; + } + + /** + * The effective values by property name, e.g. for rendering + * + * @return array + */ + public function toArray(): array + { + return array_map(static fn (MetaDataPropertyValue $value) => $value->value, $this->values); + } + public function getIterator(): Traversable { foreach ($this->values as $propertyName => $value) { diff --git a/Classes/Helper/AssetMetaDataHelper.php b/Classes/Helper/AssetMetaDataHelper.php index 3d9fd72..9f1b061 100644 --- a/Classes/Helper/AssetMetaDataHelper.php +++ b/Classes/Helper/AssetMetaDataHelper.php @@ -7,7 +7,6 @@ use Neos\Media\Domain\Model\Asset; use Neos\MetaData\Domain\Dto\MetaDataAssetReference; use Neos\MetaData\Domain\Dto\MetaDataDimensionSpacePoint; -use Neos\MetaData\Domain\Dto\MetaDataPropertyName; use Neos\MetaData\MetaDataManager; class AssetMetaDataHelper implements ProtectedContextAwareInterface @@ -19,25 +18,37 @@ public function __construct( { } + /** + * The effective metadata of the given asset by property name, with dimension fallbacks applied + * + * @param array $coordinates dimension coordinates, e.g. ['language' => 'de']. Empty = the default dimension + * @return array + */ public function getMetaData(Asset $asset, array $coordinates = []): array { - $propertyValues = $this->metaDataManager->getMetaDataPropertyValuesWithFallback( + return $this->metaDataManager->getMetaDataPropertyValues( MetaDataAssetReference::create($asset->assetSourceIdentifier, $asset->getIdentifier()), - MetaDataDimensionSpacePoint::fromCoordinates($coordinates), - ); - $result = []; - foreach ($propertyValues as $propertyName => $propertyValue) { - /** @var $propertyName MetaDataPropertyName */ - $result[$propertyName->value] = $propertyValue; - } - return $result; + $coordinates === [] ? null : MetaDataDimensionSpacePoint::fromCoordinates($coordinates), + )->toArray(); } /** - * @inheritDoc + * The effective metadata property value of the given asset and property name, with dimension fallbacks applied + * + * @param array $coordinates dimension coordinates, e.g. ['language' => 'de']. Empty = the default dimension + * @return string|int|bool|null Value of the metadata property or NULL if it was not set (or explicitly reset) */ - public function allowsCallOfMethod($methodName) + public function getMetaDataProperty(Asset $asset, string $propertyName, array $coordinates = []): string|int|bool|null + { + return $this->metaDataManager->getMetaDataPropertyValue( + MetaDataAssetReference::create($asset->assetSourceIdentifier, $asset->getIdentifier()), + $propertyName, + $coordinates === [] ? null : MetaDataDimensionSpacePoint::fromCoordinates($coordinates), + )?->value; + } + + public function allowsCallOfMethod($methodName): true { - return in_array($methodName, ['getMetaData']); + return true; } } diff --git a/Classes/Maintenance/MetaDataRepair.php b/Classes/Maintenance/MetaDataRepair.php new file mode 100644 index 0000000..98ca2f9 --- /dev/null +++ b/Classes/Maintenance/MetaDataRepair.php @@ -0,0 +1,208 @@ +.globalScope` leaves values behind that no longer match. Such + * values are never returned by {@see MetaDataManager} (reads only ever look up the scope a property is + * configured for), so this is a matter of hygiene rather than of correctness. + */ +final readonly class MetaDataRepair +{ + public function __construct( + private MetaDataManager $metaDataManager, + private DimensionSpacePointProvider $dimensionSpacePointProvider, + private MetaDataStorage $storage, + ) { + } + + /** + * Whether the configured storage allows its values to be inspected and removed at all + */ + public function isSupported(): bool + { + return $this->storage instanceof MetaDataStorageMaintenance; + } + + /** + * Whether any content dimension is configured. + * + * If none is, every stored value for a dimension looks obsolete – which is exactly what a broken or + * half-loaded dimension configuration looks like, so pruning must be refused in that case. + */ + public function hasConfiguredDimensions(): bool + { + foreach ($this->dimensionSpacePointProvider->getDimensionSpacePoints() as $dimensionSpacePoint) { + if ($dimensionSpacePoint->coordinates !== []) { + return true; + } + } + return false; + } + + /** + * @return list + */ + public function analyze(): array + { + $propertyDefinitions = $this->metaDataManager->getPropertyDefinitions(); + $validDimensionHashes = []; + foreach ($this->dimensionSpacePointProvider->getDimensionSpacePoints() as $dimensionSpacePoint) { + $validDimensionHashes[$dimensionSpacePoint->hash] = true; + } + $defaultChainHashes = $this->defaultChainHashes(); + $defaultDimensionHash = $defaultChainHashes[0] ?? null; + + $actions = []; + foreach ($this->groupedStoredValues() as $storedValues) { + $propertyName = $storedValues[0]->propertyName; + if (!$propertyDefinitions->include($propertyName)) { + foreach ($storedValues as $storedValue) { + $actions[] = new MetaDataRepairAction(MetaDataRepairActionType::deleteUndefinedProperty, $storedValue); + } + continue; + } + $globalValues = array_values(array_filter($storedValues, static fn (MetaDataStoredValue $v) => $v->global)); + $dimensionedValues = array_values(array_filter($storedValues, static fn (MetaDataStoredValue $v) => !$v->global)); + + if ($propertyDefinitions->get($propertyName)->globalScope) { + if ($dimensionedValues === []) { + continue; + } + // Only promote if there is no shared value yet – an existing one is what reads return, so it wins + if ($globalValues === []) { + $actions[] = new MetaDataRepairAction( + MetaDataRepairActionType::promoteToGlobalScope, + $this->pickWinner($dimensionedValues, $defaultChainHashes), + ); + } + foreach ($dimensionedValues as $storedValue) { + $actions[] = new MetaDataRepairAction(MetaDataRepairActionType::deleteWrongScope, $storedValue); + } + continue; + } + + $hasDefaultDimensionValue = false; + foreach ($dimensionedValues as $storedValue) { + if ($storedValue->dimensionHash === $defaultDimensionHash) { + $hasDefaultDimensionValue = true; + } + if (!array_key_exists($storedValue->dimensionHash, $validDimensionHashes)) { + $actions[] = new MetaDataRepairAction(MetaDataRepairActionType::deleteObsoleteDimension, $storedValue); + } + } + foreach ($globalValues as $storedValue) { + // Only promote if the default dimension has no value yet, so live data is never overwritten + if (!$hasDefaultDimensionValue) { + $actions[] = new MetaDataRepairAction(MetaDataRepairActionType::promoteToDefaultDimension, $storedValue); + } + $actions[] = new MetaDataRepairAction(MetaDataRepairActionType::deleteWrongScope, $storedValue); + } + } + return $actions; + } + + /** + * Carries out the given actions. Promotions are done before deletions, because a value that is + * promoted is usually stored in a row that is deleted afterwards. + * + * @param list $actions + * @return int the number of stored values that were removed + */ + public function apply(array $actions, bool $prune = false): int + { + if (!$this->storage instanceof MetaDataStorageMaintenance) { + throw new RuntimeException(sprintf('The configured metadata storage %s does not support repairing', $this->storage::class), 1776280001); + } + $actions = array_values(array_filter($actions, static fn (MetaDataRepairAction $action) => $prune || !$action->type->requiresPrune())); + + foreach ($actions as $action) { + match ($action->type) { + MetaDataRepairActionType::promoteToGlobalScope, + MetaDataRepairActionType::promoteToDefaultDimension => $this->metaDataManager->setMetaDataPropertyValue( + $action->storedValue->assetReference, + $action->storedValue->propertyName, + $action->storedValue->value, + ), + default => null, + }; + } + + $deletions = array_values(array_map( + static fn (MetaDataRepairAction $action) => $action->storedValue, + array_filter($actions, static fn (MetaDataRepairAction $action) => $action->type->isDeletion()), + )); + if ($deletions === []) { + return 0; + } + return $this->storage->deleteStoredValues(...$deletions); + } + + // ----------------------- + + /** + * All stored values grouped per asset and property + * + * @return iterable> + */ + private function groupedStoredValues(): iterable + { + assert($this->storage instanceof MetaDataStorageMaintenance); + $groups = []; + foreach ($this->storage->findAllStoredValues() as $storedValue) { + $key = implode("\0", [ + $storedValue->assetReference->assetSourceId, + $storedValue->assetReference->assetId, + $storedValue->propertyName->value, + ]); + $groups[$key][] = $storedValue; + } + return $groups; + } + + /** + * The value to keep when consolidating several localized values into a single shared one: the one + * the default dimension resolves to, or – if the value only exists in unrelated dimensions – the + * first in a stable order. + * + * @param non-empty-list $storedValues + * @param list $defaultChainHashes + */ + private function pickWinner(array $storedValues, array $defaultChainHashes): MetaDataStoredValue + { + foreach ($defaultChainHashes as $dimensionHash) { + foreach ($storedValues as $storedValue) { + if ($storedValue->dimensionHash === $dimensionHash) { + return $storedValue; + } + } + } + usort($storedValues, static fn (MetaDataStoredValue $a, MetaDataStoredValue $b) => $a->dimensionHash <=> $b->dimensionHash); + return $storedValues[0]; + } + + /** + * @return list + */ + private function defaultChainHashes(): array + { + $chain = $this->dimensionSpacePointProvider->getDimensionSpacePointChain( + $this->dimensionSpacePointProvider->getDefaultDimensionSpacePoint() + ); + return $chain->map(static fn (MetaDataDimensionSpacePoint $dimensionSpacePoint) => $dimensionSpacePoint->hash); + } +} diff --git a/Classes/Maintenance/MetaDataRepairAction.php b/Classes/Maintenance/MetaDataRepairAction.php new file mode 100644 index 0000000..d614b1e --- /dev/null +++ b/Classes/Maintenance/MetaDataRepairAction.php @@ -0,0 +1,19 @@ +dimensionSpacePointProvider->getDimensionSpacePoints(); } + /** + * Sets the value of a single metadata property. + * + * The value is coerced to the type the property is defined for, so that callers which only ever + * have strings – the command line, form input, Fusion – do not have to cast. A value that cannot be + * interpreted as that type is rejected rather than silently turned into a wrong one, + * see {@see MetaDataPropertyType::coerceForStorage()}. + */ public function setMetaDataPropertyValue( MetaDataAssetReference $assetReference, MetaDataPropertyName|string $propertyName, string|int|bool $value, ?MetaDataDimensionSpacePoint $dimensionSpacePoint = null, ): void { - $propertyName = $this->validatePropertyName($propertyName); - $dimensionSpacePoint = $this->validateDimensionSpacePoint($dimensionSpacePoint); + $propertyDefinition = $this->propertyDefinition($propertyName); - // TODO: ACL, convert value according to property definition - $this->storage->setMetaDataPropertyValue($assetReference, $propertyName, $value, $dimensionSpacePoint); + // TODO: ACL + $this->storage->setMetaDataPropertyValue( + $assetReference, + $propertyDefinition->name, + $propertyDefinition->type->coerceForStorage($value), + $this->writeScope($propertyDefinition, $dimensionSpacePoint), + ); } public function unsetMetaDataPropertyValue( MetaDataAssetReference $assetReference, MetaDataPropertyName|string $propertyName, - ?MetaDataDimensionSpacePoint $dimensionSpacePoint, + ?MetaDataDimensionSpacePoint $dimensionSpacePoint = null, ): void { - $propertyName = $this->validatePropertyName($propertyName); - $dimensionSpacePoint = $this->validateDimensionSpacePoint($dimensionSpacePoint); + $propertyDefinition = $this->propertyDefinition($propertyName); // TODO: ACL - $this->storage->unsetMetaDataPropertyValue($assetReference, $propertyName, $dimensionSpacePoint); + $this->storage->unsetMetaDataPropertyValue( + $assetReference, + $propertyDefinition->name, + $this->writeScope($propertyDefinition, $dimensionSpacePoint), + ); } + /** + * The value of a single metadata property, as seen from the given dimension space point. + * + * The result carries the own and the inherited value side by side, see {@see MetaDataPropertyValue}. + */ public function getMetaDataPropertyValue( MetaDataAssetReference $assetReference, MetaDataPropertyName|string $propertyName, - MetaDataDimensionSpacePoints $dimensionSpacePoints, - ): string|int|bool|null { - $propertyName = $this->validatePropertyName($propertyName); - - // TODO: ACL, convert value according to property definition - return $this->storage->getMetaDataPropertyValue($assetReference, $propertyName, $dimensionSpacePoints); + ?MetaDataDimensionSpacePoint $dimensionSpacePoint = null, + ): MetaDataPropertyValue { + return $this->resolvePropertyValue( + $assetReference, + $this->propertyDefinition($propertyName), + $dimensionSpacePoint, + ); } + /** + * The values of all defined metadata properties, as seen from the given dimension space point. + * + * Every defined property is contained in the result, properties without any stored value with an + * empty {@see MetaDataPropertyValue}. + */ public function getMetaDataPropertyValues( MetaDataAssetReference $assetReference, - ?MetaDataDimensionSpacePoint $dimensionSpacePoint, + ?MetaDataDimensionSpacePoint $dimensionSpacePoint = null, ): MetaDataPropertyValues { - $dimensionSpacePoint = $this->validateDimensionSpacePoint($dimensionSpacePoint); - $dimensionSpacePoints = MetaDataDimensionSpacePoints::create($dimensionSpacePoint); + $propertyValues = MetaDataPropertyValues::createEmpty(); + foreach ($this->propertyDefinitions as $propertyDefinition) { + $propertyValues = $propertyValues->with( + $propertyDefinition->name, + $this->resolvePropertyValue($assetReference, $propertyDefinition, $dimensionSpacePoint), + ); + } + return $propertyValues; + } + + /** + * References of all assets that have a matching metadata value, as seen from one dimension space + * point. + * + * A value counts only if it is the one that {@see self::getMetaDataPropertyValue()} would return for + * the filter's dimension space point, so the search agrees with what an editor working in that + * dimension sees: an asset whose caption is inherited from a fallback dimension is found, one whose + * inherited caption is overridden by a non matching value of its own is not. + * + * Properties with a global scope are matched on their shared value regardless of the dimension space + * point, just like they are read regardless of it. + * + * The result is lazily streamed and each asset is contained at most once. + * + * NOTE: This returns {@see MetaDataAssetReference}s – the identity of an asset within its asset + * source – not `Asset` objects. This package never touches the asset model. + * + * @return iterable + */ + public function findAssets(MetaDataAssetFilter $filter): iterable + { + $localizedPropertyNames = []; + $globalScopePropertyNames = []; + foreach ($this->filteredPropertyDefinitions($filter->propertyNames) as $propertyDefinition) { + if ($propertyDefinition->globalScope) { + $globalScopePropertyNames[] = $propertyDefinition->name; + } else { + $localizedPropertyNames[] = $propertyDefinition->name; + } + } - return $this->getMetaDataPropertyValuesByDimensionSpacePoints($assetReference, $dimensionSpacePoints); + return $this->storage->findAssets( + $filter->assetSourceId, + $filter->searchTerm, + MetaDataPropertyNames::create(...$localizedPropertyNames), + $this->dimensionSpacePointProvider->getDimensionSpacePointChain( + $this->validateDimensionSpacePoint($filter->dimensionSpacePoint) + ), + MetaDataPropertyNames::create(...$globalScopePropertyNames), + ); } - public function getMetaDataPropertyValuesWithFallback( - MetaDataAssetReference $assetReference, - ?MetaDataDimensionSpacePoint $dimensionSpacePoint = null, - ): MetaDataPropertyValues { - $dimensionSpacePoint = $this->validateDimensionSpacePoint($dimensionSpacePoint); - $dimensionSpacePoints = $this->dimensionSpacePointProvider->getDimensionSpacePointChain($dimensionSpacePoint); + // ----------------------- - return $this->getMetaDataPropertyValuesByDimensionSpacePoints($assetReference, $dimensionSpacePoints); + /** + * The definitions of the given property names, or all of them if no names are given. + * + * @return iterable + */ + private function filteredPropertyDefinitions(?MetaDataPropertyNames $propertyNames): iterable + { + if ($propertyNames === null) { + return $this->propertyDefinitions; + } + return array_map($this->propertyDefinition(...), iterator_to_array($propertyNames)); } - public function getMetaDataPropertyValuesOfParentWithFallback( + /** + * Resolves the own and the inherited value of a single property with one storage lookup + */ + private function resolvePropertyValue( MetaDataAssetReference $assetReference, - ?MetaDataDimensionSpacePoint $dimensionSpacePoint = null, - ): MetaDataPropertyValues { - $dimensionSpacePoint = $this->validateDimensionSpacePoint($dimensionSpacePoint); - $dimensionSpacePoints = $this->dimensionSpacePointProvider->getDimensionSpacePointChain($dimensionSpacePoint); - - if ($dimensionSpacePoints->count() > 1) { - $dimensionSpacePointsWithoutCurrent = iterator_to_array($dimensionSpacePoints); - array_shift($dimensionSpacePointsWithoutCurrent); - $dimensionSpacePoints = MetaDataDimensionSpacePoints::create(...$dimensionSpacePointsWithoutCurrent); - } else { - return MetaDataPropertyValues::createEmpty(); + MetaDataPropertyDefinition $propertyDefinition, + ?MetaDataDimensionSpacePoint $dimensionSpacePoint, + ): MetaDataPropertyValue { + // TODO: ACL + if ($propertyDefinition->globalScope) { + return $this->resolveGlobalPropertyValue($assetReference, $propertyDefinition); } - return $this->getMetaDataPropertyValuesByDimensionSpacePoints($assetReference, $dimensionSpacePoints); + $candidates = $this->dimensionSpacePointProvider->getDimensionSpacePointChain( + $this->validateDimensionSpacePoint($dimensionSpacePoint) + ); + $storedValues = $this->storage->getMetaDataPropertyValues($assetReference, $propertyDefinition->name, $candidates); + if ($storedValues === []) { + return MetaDataPropertyValue::createEmpty(); + } + $ownValue = null; + foreach ($candidates as $index => $candidate) { + if (!array_key_exists($candidate->hash, $storedValues)) { + continue; + } + $value = $propertyDefinition->type->fromStoredValue($storedValues[$candidate->hash]); + // A value that cannot be interpreted as the configured type is treated like an absent one, + // so that it neither surfaces nor shadows a fallback that is still readable + if ($value === null) { + continue; + } + // The first candidate is the dimension space point that was asked for, all others are fallbacks + if ($index === 0) { + $ownValue = $value; + continue; + } + return MetaDataPropertyValue::create($ownValue, $value, $candidate); + } + return MetaDataPropertyValue::create($ownValue); } - private function getMetaDataPropertyValuesByDimensionSpacePoints(MetaDataAssetReference $assetReference, MetaDataDimensionSpacePoints $dimensionSpacePoints): MetaDataPropertyValues - { - $propertyValues = MetaDataPropertyValues::createEmpty(); - - // TODO: ACL, convert values according to property definition - foreach ($this->propertyDefinitions as $propertyDefinition) { - $propertyValues = $propertyValues->with($propertyDefinition->name, $this->getMetaDataPropertyValue($assetReference, $propertyDefinition->name, $dimensionSpacePoints)); + /** + * A value of a global scope is shared by all dimensions, so it is never inherited + */ + private function resolveGlobalPropertyValue( + MetaDataAssetReference $assetReference, + MetaDataPropertyDefinition $propertyDefinition, + ): MetaDataPropertyValue { + $storedValues = $this->storage->getMetaDataPropertyValues( + $assetReference, + $propertyDefinition->name, + MetaDataGlobalScope::create(), + ); + if ($storedValues === []) { + return MetaDataPropertyValue::createEmpty(); } - return $propertyValues; + return MetaDataPropertyValue::create($propertyDefinition->type->fromStoredValue(reset($storedValues))); } - // ----------------------- + /** + * The scope a value of the given property is written to. + * + * For properties of a global scope the dimension space point is ignored on purpose: callers pass the + * dimension they are currently working in without having to know which properties are localized. + */ + private function writeScope( + MetaDataPropertyDefinition $propertyDefinition, + ?MetaDataDimensionSpacePoint $dimensionSpacePoint, + ): MetaDataDimensionSpacePoint|MetaDataGlobalScope { + if ($propertyDefinition->globalScope) { + return MetaDataGlobalScope::create(); + } + return $this->validateDimensionSpacePoint($dimensionSpacePoint); + } - private function validatePropertyName(MetaDataPropertyName|string $propertyName): MetaDataPropertyName + private function propertyDefinition(MetaDataPropertyName|string $propertyName): MetaDataPropertyDefinition { if (is_string($propertyName)) { $propertyName = MetaDataPropertyName::fromString($propertyName); @@ -129,7 +267,7 @@ private function validatePropertyName(MetaDataPropertyName|string $propertyName) if (!$this->propertyDefinitions->include($propertyName)) { throw new InvalidArgumentException(sprintf('Metadata property "%s" is not defined', $propertyName), 1776278047); } - return $propertyName; + return $this->propertyDefinitions->get($propertyName); } private function validateDimensionSpacePoint(?MetaDataDimensionSpacePoint $dimensionSpacePoint): MetaDataDimensionSpacePoint diff --git a/Classes/Storage/MetaDataStorage.php b/Classes/Storage/MetaDataStorage.php index 9853f24..25e8655 100644 --- a/Classes/Storage/MetaDataStorage.php +++ b/Classes/Storage/MetaDataStorage.php @@ -7,15 +7,71 @@ use Neos\MetaData\Domain\Dto\MetaDataAssetReference; use Neos\MetaData\Domain\Dto\MetaDataDimensionSpacePoint; use Neos\MetaData\Domain\Dto\MetaDataDimensionSpacePoints; +use Neos\MetaData\Domain\Dto\MetaDataGlobalScope; use Neos\MetaData\Domain\Dto\MetaDataPropertyName; +use Neos\MetaData\Domain\Dto\MetaDataPropertyNames; +/** + * Persistence for metadata property values. + * + * Implementations are deliberately dumb: they must not invent any resolution rules of their own. Where + * precedence between dimension space points matters, it is stated explicitly by the method in question + * – see the individual docblocks below. + */ interface MetaDataStorage { - public function setMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, string|int|bool $propertyValue, MetaDataDimensionSpacePoint $dimensionSpacePoint): void; + public function setMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, string|int|bool $propertyValue, MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): void; - public function unsetMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoint $dimensionSpacePoint): void; + public function unsetMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): void; - public function getMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoints $dimensionSpacePoints): string|int|bool|null; + /** + * All values stored for the given property within the given scope, in no particular order. + * + * The order of the given dimension space points is meaningless here – the {@see MetaDataManager} + * decides which of the returned values wins. + * + * The keys are opaque handles identifying the dimension space point a value is stored for; they can + * be compared with {@see MetaDataDimensionSpacePoint::$hash}. Scopes without a stored value are + * absent from the result. + * + * @return array + */ + public function getMetaDataPropertyValues(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoints|MetaDataGlobalScope $scope): array; + + /** + * References of all assets that have a matching value for at least one of the given properties. + * + * Unlike {@see self::getMetaDataPropertyValues()} the order of $dimensionSpacePointChain *is* + * meaningful: it runs from the most to the least specific dimension space point, and only the + * closest stored value of a property counts. A value stored for a dimension space point further + * down the chain must be ignored if the same property also has a value further up – it is shadowed + * and never surfaces for the dimension that was asked for. This is not a resolution rule that + * implementations get to choose; it is the ranking they are handed. + * + * Localized and global scope properties are given separately because their values live in different + * scopes: the ones named in $localizedPropertyNames are looked up along the chain, the ones named in + * $globalScopePropertyNames in the global scope, which no dimension space point applies to. Either + * set may be empty. Values stored in the respective other scope – e.g. left behind after a change of + * {@see MetaDataPropertyDefinition::$globalScope} – must not be matched. + * + * The search term matches if it is contained anywhere in a value, case insensitively. NULL matches + * every stored value, i.e. every asset that has any value for the given properties at all. An asset + * that matches several times is returned once. + * + * Implementations should stream rather than materialize the whole result, as it can cover every + * asset that has metadata. + * + * @param string|null $assetSourceId NULL matches assets of every asset source + * @param string|null $searchTerm NULL matches every stored value + * @return iterable ordered by asset source id, then asset id + */ + public function findAssets( + ?string $assetSourceId, + ?string $searchTerm, + MetaDataPropertyNames $localizedPropertyNames, + MetaDataDimensionSpacePoints $dimensionSpacePointChain, + MetaDataPropertyNames $globalScopePropertyNames, + ): iterable; } diff --git a/Classes/Storage/MetaDataStorageMaintenance.php b/Classes/Storage/MetaDataStorageMaintenance.php new file mode 100644 index 0000000..88fcb39 --- /dev/null +++ b/Classes/Storage/MetaDataStorageMaintenance.php @@ -0,0 +1,31 @@ + + */ + public function findAllStoredValues(): iterable; + + /** + * @return int the number of values that were removed + */ + public function deleteStoredValues(MetaDataStoredValue ...$storedValues): int; +} diff --git a/Classes/Storage/MetaDataStorageProviderDbalAdapter.php b/Classes/Storage/MetaDataStorageProviderDbalAdapter.php index cc34446..1595554 100644 --- a/Classes/Storage/MetaDataStorageProviderDbalAdapter.php +++ b/Classes/Storage/MetaDataStorageProviderDbalAdapter.php @@ -9,25 +9,34 @@ use Neos\MetaData\Domain\Dto\MetaDataAssetReference; use Neos\MetaData\Domain\Dto\MetaDataDimensionSpacePoint; use Neos\MetaData\Domain\Dto\MetaDataDimensionSpacePoints; +use Neos\MetaData\Domain\Dto\MetaDataGlobalScope; use Neos\MetaData\Domain\Dto\MetaDataPropertyName; +use Neos\MetaData\Domain\Dto\MetaDataPropertyNames; -final readonly class MetaDataStorageProviderDbalAdapter implements MetaDataStorage +final readonly class MetaDataStorageProviderDbalAdapter implements MetaDataStorage, MetaDataStorageMaintenance { + private const TABLE_NAME = 'neos_metadata_value'; + + /** + * Dimension hash for values of a global scope. A real dimension hash is an MD5 hex string, so this + * sentinel can never collide with one. + */ + private const GLOBAL_DIMENSION_HASH = 'global'; public function __construct( private Connection $connection, ) { } - public function setMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, string|int|bool $propertyValue, MetaDataDimensionSpacePoint $dimensionSpacePoint): void + public function setMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, string|int|bool $propertyValue, MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): void { - $statement = <<connection->executeStatement( $statement, [ @@ -35,26 +44,30 @@ public function setMetaDataPropertyValue(MetaDataAssetReference $assetReference, 'assetId' => $assetReference->assetId, 'propertyName' => $propertyName->value, 'propertyValue' => $propertyValue, - 'dimensionHash' => $dimensionSpacePoint->hash, + 'dimensionHash' => self::dimensionHash($scope), ] ); } - public function unsetMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoint $dimensionSpacePoint): void + public function unsetMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): void { - $this->connection->delete('neos_metadata_value', [ + $this->connection->delete(self::TABLE_NAME, [ 'asset_source_id' => $assetReference->assetSourceId, 'asset_id' => $assetReference->assetId, 'property_name' => $propertyName->value, - 'dimension_hash' => $dimensionSpacePoint->hash, + 'dimension_hash' => self::dimensionHash($scope), ]); } - public function getMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoints $dimensionSpacePoints): string|int|bool|null + public function getMetaDataPropertyValues(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoints|MetaDataGlobalScope $scope): array { + $dimensionHashes = self::dimensionHashes($scope); + if ($dimensionHashes === []) { + return []; + } $query = $this->connection->createQueryBuilder(); - $query->select('property_value') - ->from('neos_metadata_value') + $query->select('dimension_hash', 'property_value') + ->from(self::TABLE_NAME) ->where( $query->expr()->and( $query->expr()->eq('asset_source_id', ':assetSourceId'), @@ -62,16 +75,171 @@ public function getMetaDataPropertyValue(MetaDataAssetReference $assetReference, $query->expr()->eq('property_name', ':propertyName'), $query->expr()->in('dimension_hash', ':dimensionHashes'), ) - // Orders the results by dimension order - )->orderBy('FIELD(`dimension_hash`, :dimensionHashes)', 'ASC') + ) + // NOTE: No ordering – which of the values wins is a domain decision that is made by the MetaDataManager ->setParameters([ 'assetSourceId' => $assetReference->assetSourceId, 'assetId' => $assetReference->assetId, 'propertyName' => $propertyName->value, - 'dimensionHashes' => $dimensionSpacePoints->map(fn($spacePoint) => $spacePoint->hash), + 'dimensionHashes' => $dimensionHashes, ], [ 'dimensionHashes' => ArrayParameterType::STRING, ]); - return $query->fetchOne(); + + $values = []; + foreach ($query->executeQuery()->iterateAssociative() as $row) { + $values[$row['dimension_hash']] = $row['property_value']; + } + return $values; + } + + public function findAssets( + ?string $assetSourceId, + ?string $searchTerm, + MetaDataPropertyNames $localizedPropertyNames, + MetaDataDimensionSpacePoints $dimensionSpacePointChain, + MetaDataPropertyNames $globalScopePropertyNames, + ): iterable { + $parameters = []; + $scopeConditions = []; + + $chainHashes = $dimensionSpacePointChain->map(static fn (MetaDataDimensionSpacePoint $dimensionSpacePoint) => $dimensionSpacePoint->hash); + if (!$localizedPropertyNames->isEmpty() && $chainHashes !== []) { + $chainPlaceholders = self::bindList($parameters, 'dsp', $chainHashes); + $namePlaceholders = self::bindList($parameters, 'localizedProperty', $localizedPropertyNames->map(static fn (MetaDataPropertyName $propertyName) => $propertyName->value)); + // The value must be the closest one along the chain – a value further down is shadowed and + // never surfaces for the dimension space point that was asked for. + // NOTE: the identity columns are nullable, so the correlation uses the NULL safe `<=>` + $scopeConditions[] = sprintf(<<<'MYSQL' + ( + v.property_name IN (%1$s) AND v.dimension_hash IN (%2$s) AND NOT EXISTS ( + SELECT 1 FROM %3$s v2 + WHERE v2.asset_source_id <=> v.asset_source_id + AND v2.asset_id <=> v.asset_id + AND v2.property_name = v.property_name + AND v2.dimension_hash IN (%2$s) + AND FIELD(v2.dimension_hash, %2$s) < FIELD(v.dimension_hash, %2$s) + ) + ) + MYSQL, $namePlaceholders, $chainPlaceholders, self::TABLE_NAME); + } + + if (!$globalScopePropertyNames->isEmpty()) { + $namePlaceholders = self::bindList($parameters, 'globalProperty', $globalScopePropertyNames->map(static fn (MetaDataPropertyName $propertyName) => $propertyName->value)); + $parameters['globalDimensionHash'] = self::GLOBAL_DIMENSION_HASH; + $scopeConditions[] = sprintf('(v.property_name IN (%s) AND v.dimension_hash = :globalDimensionHash)', $namePlaceholders); + } + + if ($scopeConditions === []) { + return []; + } + + $conditions = [sprintf('(%s)', implode(' OR ', $scopeConditions))]; + if ($assetSourceId !== null) { + $conditions[] = 'v.asset_source_id = :assetSourceId'; + $parameters['assetSourceId'] = $assetSourceId; + } + if ($searchTerm !== null) { + $conditions[] = "v.property_value LIKE :searchTerm ESCAPE '\\\\'"; + $parameters['searchTerm'] = '%' . self::escapeLikeWildcards($searchTerm) . '%'; + } + + $statement = sprintf(<<<'MYSQL' + SELECT DISTINCT v.asset_source_id, v.asset_id + FROM %s v + WHERE %s + ORDER BY v.asset_source_id, v.asset_id + MYSQL, self::TABLE_NAME, implode(' AND ', $conditions)); + + return $this->streamAssetReferences($statement, $parameters); + } + + public function findAllStoredValues(): iterable + { + $query = $this->connection->createQueryBuilder(); + $query->select('asset_source_id', 'asset_id', 'property_name', 'property_value', 'dimension_hash') + ->from(self::TABLE_NAME); + foreach ($query->executeQuery()->iterateAssociative() as $row) { + yield new MetaDataStoredValue( + MetaDataAssetReference::create($row['asset_source_id'], $row['asset_id']), + MetaDataPropertyName::fromString($row['property_name']), + $row['dimension_hash'], + $row['dimension_hash'] === self::GLOBAL_DIMENSION_HASH, + $row['property_value'], + ); + } + } + + public function deleteStoredValues(MetaDataStoredValue ...$storedValues): int + { + $deleted = 0; + foreach ($storedValues as $storedValue) { + $deleted += $this->connection->delete(self::TABLE_NAME, [ + 'asset_source_id' => $storedValue->assetReference->assetSourceId, + 'asset_id' => $storedValue->assetReference->assetId, + 'property_name' => $storedValue->propertyName->value, + 'dimension_hash' => $storedValue->dimensionHash, + ]); + } + return $deleted; + } + + // ----------------------- + + /** + * Binds the given values as individually named parameters and returns the corresponding placeholder + * list for an `IN (...)` or `FIELD(...)` expression. + * + * The placeholders are named rather than expanded from an array parameter, because the dimension + * hashes occur multiple times within the same statement. + * + * @param array $parameters mutated in place + * @param list $values + */ + private static function bindList(array &$parameters, string $prefix, array $values): string + { + $placeholders = []; + foreach ($values as $index => $value) { + $parameterName = $prefix . $index; + $parameters[$parameterName] = $value; + $placeholders[] = ':' . $parameterName; + } + return implode(', ', $placeholders); + } + + /** + * Escapes the characters that are wildcards within a LIKE pattern, so that a search for "50%" does + * not match every value + */ + private static function escapeLikeWildcards(string $searchTerm): string + { + return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $searchTerm); + } + + /** + * @param array $parameters + * @return iterable + */ + private function streamAssetReferences(string $statement, array $parameters): iterable + { + foreach ($this->connection->executeQuery($statement, $parameters)->iterateAssociative() as $row) { + yield MetaDataAssetReference::create($row['asset_source_id'], $row['asset_id']); + } + } + + private static function dimensionHash(MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): string + { + return $scope instanceof MetaDataGlobalScope ? self::GLOBAL_DIMENSION_HASH : $scope->hash; + } + + /** + * @return list + */ + private static function dimensionHashes(MetaDataDimensionSpacePoints|MetaDataGlobalScope $scope): array + { + if ($scope instanceof MetaDataGlobalScope) { + return [self::GLOBAL_DIMENSION_HASH]; + } + return $scope->map(static fn (MetaDataDimensionSpacePoint $dimensionSpacePoint) => $dimensionSpacePoint->hash); } } diff --git a/Classes/Storage/MetaDataStoredValue.php b/Classes/Storage/MetaDataStoredValue.php new file mode 100644 index 0000000..23f527b --- /dev/null +++ b/Classes/Storage/MetaDataStoredValue.php @@ -0,0 +1,32 @@ +` in `Neos.MetaData:Main`; any other value is used verbatim | | `ui.inspector.editor` | Editor to use for this property in the Neos UI inspector | | `ui.inspector.editorOptions` | Editor specific options | -The package ships with three properties out of the box: `copyright`, `altText` and `caption`. +The package ships with three properties out of the box: `copyright` (global scope), `altText` and +`caption`. -> **Note:** `type` and `globalScope` are parsed into the property definitions and exposed to consumers, -> but value conversion and scope handling are not yet enforced by `MetaDataManager` – values are -> currently written and read as provided. +A property that is set to `null` is skipped, which allows to disable properties that are configured +elsewhere: + +```yaml +Neos: + MetaData: + metaDataProperties: + 'copyright': ~ +``` Dimensions are *not* configured in this package. They are taken from the Content Repository content dimension presets (`Neos.ContentRepository.contentDimensions`) via `DimensionSpacePointProviderContentRepositoryAdapter`. If no content dimensions are configured, the only valid dimension space point is the empty one. +Changing `globalScope` of a property that already has values stored leaves values behind that no longer +match its scope. Those are never returned when reading, see [`assetmetadata:repair`](#command-line). + +### Property types + +Values are coerced to the `type` a property is declared with – on the way in, so that nothing but a +value of that type is ever stored, and on the way out, so that a reader gets a value of that type back. +`MetaDataPropertyValue::$value` therefore means what its `string|int|bool|null` signature says. + +Unambiguous conversions are applied, so that callers which only ever have strings – the command line, +form input, Fusion – do not have to cast: + +| Type | Accepted | Stored as | +|-----------|--------------------------------------------------------------------------------------------------|------------------| +| `string` | anything | as provided | +| `integer` | an `int`, an optionally signed decimal string like `"-42"`, or a boolean | decimal | +| `boolean` | a `bool`, `"true"`/`"on"`/`"yes"`/`"1"` and `"false"`/`"off"`/`"no"`/`"0"` (any case), or `1`/`0` | `1` or `0` | + +Anything else is rejected with an `InvalidArgumentException` rather than silently turned into a wrong +value – `"abc"` is not `0`. Surrounding whitespace is tolerated for `integer` and `boolean` but kept +verbatim for `string`. + +Reading is deliberately more forgiving, because it meets values that were written before a property was +given its current type: a stored value that cannot be interpreted reads as `NULL`, i.e. the property +behaves as if it had no value for that dimension – and does not shadow a fallback that is still +readable. Note that the search of `findAssets()` matches the *stored* representation, so a `boolean` is +matched as `1`/`0` rather than as `true`/`false`. + ## Usage ### PHP API `Neos\MetaData\MetaDataManager` is the central entry point. An asset is addressed by a `MetaDataAssetReference` (asset source id + asset id), a dimension by a `MetaDataDimensionSpacePoint` -(coordinates like `['language' => 'de']`). +(coordinates like `['language' => 'de']`). Wherever a dimension space point can be passed, `null` means +the default one. ```php use Neos\MetaData\Domain\Dto\MetaDataAssetReference; @@ -83,44 +120,119 @@ use Neos\MetaData\MetaDataManager; protected MetaDataManager $metaDataManager; $assetReference = MetaDataAssetReference::create($asset->assetSourceIdentifier, $asset->getIdentifier()); -$dimensionSpacePoint = MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'de']); +$german = MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'de']); + +$this->metaDataManager->setMetaDataPropertyValue($assetReference, 'caption', 'Eine Katze', $german); +$values = $this->metaDataManager->getMetaDataPropertyValues($assetReference, $german); +``` + +| Method | Description | +|-----------------------------------------|-----------------------------------------------------------------------------------------------| +| `getPropertyDefinitions()` | All configured property definitions (name, type, scope, UI definition) | +| `getDimensionSpacePointConfiguration()` | All dimension space points resulting from the configured dimension presets | +| `setMetaDataPropertyValue()` | Sets a single property value | +| `unsetMetaDataPropertyValue()` | Removes a single property value | +| `getMetaDataPropertyValue()` | The value of one property, as a `MetaDataPropertyValue` | +| `getMetaDataPropertyValues()` | The values of all defined properties, as `MetaDataPropertyValues` | +| `findAssets()` | References of the assets matching a `MetaDataAssetFilter` | + +Unknown property names, dimension space points that are not allowed by the configured preset +constraints and values that do not match the type a property is declared with lead to an +`InvalidArgumentException`. + +### Reading values: own, inherited and effective + +There is a single read, because the three things one usually wants to know are three views of the same +answer. `MetaDataPropertyValue` carries them side by side: + +| Field | Use case | +|-------------------------|--------------------------------------------------------------------------------------------------| +| `ownValue` | Editing. The value stored for *this* dimension, so an input field does not show a fallback value that the editor did not enter | +| `inheritedValue`, `inheritedFrom` | The translation hint below that input field: what this dimension falls back to, and where it comes from | +| `value` | Rendering to visitors: `ownValue ?? inheritedValue` | + +`hasOwnValue()` and `isInherited()` are convenience predicates on top of those. + +```php +$value = $this->metaDataManager->getMetaDataPropertyValue($assetReference, 'caption', $german); -$this->metaDataManager->setMetaDataPropertyValue($assetReference, 'caption', 'Ein Bild', $dimensionSpacePoint); -$values = $this->metaDataManager->getMetaDataPropertyValuesWithFallback($assetReference, $dimensionSpacePoint); +$value->ownValue; // 'Eine Katze', or NULL if only a fallback exists +$value->inheritedValue; // 'A cat' +$value->inheritedFrom; // MetaDataDimensionSpacePoint for ['language' => 'en'] +$value->value; // 'Eine Katze' ``` -Available methods: +For properties with a **global scope** the value is shared by all dimensions, so it is never inherited: +`ownValue` is the shared value and `inheritedValue` is always `null`. The dimension space point that is +passed in is ignored for such properties – callers can always pass the dimension they are working in +without having to know which properties are localized. -| Method | Description | -|-------------------------------------------------|---------------------------------------------------------------------------------------------------------| -| `getPropertyDefinitions()` | All configured property definitions (name, type, scope, UI definition) | -| `getDimensionSpacePointConfiguration()` | All dimension space points resulting from the configured dimension presets | -| `setMetaDataPropertyValue()` | Sets a single property value for one dimension space point | -| `unsetMetaDataPropertyValue()` | Removes a single property value for one dimension space point | -| `getMetaDataPropertyValue()` | Reads a single property, resolved along the given set of dimension space points (first match wins) | -| `getMetaDataPropertyValues()` | Reads all properties for exactly one dimension space point – *without* fallback | -| `getMetaDataPropertyValuesWithFallback()` | Reads all properties, falling back along the dimension preset fallback chain | -| `getMetaDataPropertyValuesOfParentWithFallback()` | Like the above, but skips the given dimension space point – useful to display inherited values in an editor | +### Finding assets -If the dimension space point argument is `null`, the default dimension space point (built from the -`default` value of every configured dimension) is used. Unknown property names and dimension space -points that are not allowed by the configured preset constraints lead to an `InvalidArgumentException`. +`findAssets()` returns the assets that have a matching metadata value. All criteria of a +`MetaDataAssetFilter` are optional and are combined with AND: + +```php +use Neos\MetaData\Domain\Dto\MetaDataAssetFilter; +use Neos\MetaData\Domain\Dto\MetaDataPropertyNames; + +$filter = MetaDataAssetFilter::create( + searchTerm: 'cat', + dimensionSpacePoint: MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'de']), + propertyNames: MetaDataPropertyNames::create('caption', 'altText'), +); + +foreach ($this->metaDataManager->findAssets($filter) as $assetReference) { + $assetReference->assetSourceId; + $assetReference->assetId; +} +``` + +| Criterion | Omitted means | +|-----------------------|--------------------------------------------------------------------------------------------| +| `assetSourceId` | assets of every asset source | +| `dimensionSpacePoint` | the *default* dimension space point – as everywhere else in this package, **not** "any dimension" | +| `searchTerm` | every asset that has a value for the filtered properties at all | +| `propertyNames` | all defined properties | + +The search term matches if it is contained anywhere in a value, ignoring case and accents. `%` and `_` +are matched literally rather than as wildcards. A term that is empty or consists of whitespace only is +treated like an omitted one, so clearing a search field behaves like not having searched. + +A value only counts if it is the one `getMetaDataPropertyValue()` would return for the filter's +dimension space point, so the search agrees with what an editor working in that dimension sees. Given +an asset with the English caption `A cat`, searching for `cat` in German finds it as long as German +inherits that caption – and stops finding it as soon as a German caption of its own is set. Properties +with a global scope are matched on their shared value regardless of the dimension space point, just +like they are read regardless of it. + +The result is lazily streamed, contains each asset at most once and is ordered by asset source id and +asset id. It carries `MetaDataAssetReference`s – the identity of an asset within its asset source – not +`Asset` objects; resolving those is up to the caller, this package never touches the asset model. +Unknown property names and dimension space points that are not allowed by the configured preset +constraints lead to an `InvalidArgumentException`, as they do everywhere else. ### Fusion / Eel -The Eel helper `AssetMetaData` is registered in the default Fusion context: +The Eel helper `AssetMetaData` is registered in the default Fusion context and returns the *effective* +values: ``` -caption = ${AssetMetaData.getMetaData(asset, {language: 'de'}).caption} +caption = ${AssetMetaData.getMetaDataProperty(asset, 'caption', {language: 'de'})} +allMetaData = ${AssetMetaData.getMetaData(asset, {language: 'de'})} ``` -`getMetaData(asset, coordinates = [])` returns an array of all configured property names mapped to -their values, resolved with dimension fallbacks. +| Method | Description | +|-----------------------------------------------------|-------------------------------------------------------------------------------------------| +| `getMetaDataProperty(asset, propertyName, coordinates = [])` | The effective value of a single property, or `NULL` if it is not set | +| `getMetaData(asset, coordinates = [])` | An array of all configured property names mapped to their effective values | + +Empty coordinates mean the default dimension. ### Command line ```bash -# List all meta data properties of an asset (without fallback) +# List all meta data properties of an asset, marking inherited values ./flow assetmetadata:list --asset-id [--asset-source neos] [--dimension-space-point '{"language":"de"}'] # Set a single property @@ -131,14 +243,36 @@ their values, resolved with dimension fallbacks. ``` `--asset-source` defaults to `neos`, `--dimension-space-point` to the default dimension space point. +For properties with a global scope the dimension space point is ignored. To move the caption and copyright notice already stored on existing `Asset` models into this package's -storage (written to the default dimension space point): +storage: ```bash ./flow assetmetadatamigration:migrateexistingassetproperties ``` +To find and fix values whose scope contradicts the current configuration – e.g. after changing +`globalScope` of a property: + +```bash +# Report only, nothing is changed +./flow assetmetadata:repair + +# Apply the reported changes +./flow assetmetadata:repair --force + +# Also remove values of dimensions and properties that are no longer configured +./flow assetmetadata:repair --force --prune +``` + +When a property became global, the value the default dimension resolves to is kept as the shared one +and the remaining ones are removed. When a property became localized, the shared value is stored for +the default dimension space point. Existing values are never overwritten. Values of unconfigured +dimensions and of undefined properties are unreachable rather than wrong, so they are only reported +until `--prune` is given – and pruning is refused altogether while no content dimension is configured, +because a broken dimension configuration would otherwise look like every value being obsolete. + ## Architecture and extension points The `MetaDataManager` is assembled by `MetaDataManagerFactory` from three interfaces, each wired to a @@ -150,17 +284,72 @@ default implementation in `Configuration/Objects.yaml`. Replace any of them to c | `DimensionSpacePointProvider\DimensionSpacePointProvider` | `DimensionSpacePointProviderContentRepositoryAdapter` | Provides valid dimension space points, the default one and the fallback chain | | `Configuration\MetaDataConfigurationProvider` | `MetaDataConfigurationProviderYamlAdapter` | Turns the YAML settings into `MetaDataPropertyDefinitions` | -The value objects below `Classes/Domain/Dto` (`MetaDataAssetReference`, `MetaDataDimensionSpacePoint`, -`MetaDataDimensionSpacePoints`, `MetaDataPropertyName`, `MetaDataPropertyType`, -`MetaDataPropertyDefinition(s)`, `MetaDataPropertyUiDefinition`, `MetaDataEditorDefinition`, -`MetaDataPropertyValues`) are excluded from Flow's object management, so they are never proxied. +Storage implementations are deliberately dumb: they look values up by scope and must not invent any +resolution rules. Which of the returned values wins, and whether it counts as an own or an inherited +one, is decided by the `MetaDataManager`. `findAssets()` is the one place where precedence has to be +applied inside the query, because resolving it per asset in PHP would mean a query per candidate – so +the manager hands the storage the fallback chain *ordered*, from the most to the least specific +dimension space point, and the storage applies that ranking rather than deriving one. Its docblock +states so explicitly; for every other method the order is meaningless. + +`Storage\MetaDataStorageMaintenance` is an *optional* interface that allows stored values to be listed +and removed regardless of scope. Only `assetmetadata:repair` needs it; a storage that does not implement +it works fine, the command reports that repairing is unsupported. + +The value objects below `Classes/Domain/Dto` are excluded from Flow's object management, so they are +never proxied. ### Storage format -All values live in a single table `neos_metadata_value` with a unique index over -`asset_source_id`, `asset_id`, `property_name` and `dimension_hash`. The `dimension_hash` is the md5 of -the JSON encoded, key-sorted dimension coordinates. Rows are deleted together with their asset via a +All values live in a single table `neos_metadata_value` with a unique index over `asset_source_id`, +`asset_id`, `property_name` and `dimension_hash`. Rows are deleted together with their asset via a foreign key with `ON DELETE CASCADE`. -Reading a value with fallback resolves the dimension space point chain (ordered from most specific to -most generic by fallback distance) and returns the first stored value found in that order. +For localized properties the `dimension_hash` is the md5 of the JSON encoded, key-sorted dimension +coordinates. For properties with a global scope it is the literal string `global` – an md5 is always 32 +hex characters, so the two can never collide. + +For a given asset and property the table therefore holds *either* one shared value *or* one value per +dimension space point, never both. Reads always look up the scope a property is configured for, so +values of the respective other shape are unreachable and cannot surface after a configuration change. + +Reading a localized property looks up the whole fallback chain in one query. The chain is ordered from +most specific to most generic by fallback distance; the first stored value along it is the effective +one, the first one after the requested dimension space point is the inherited one. + +Searching works on the same chain, in a single query per search: candidate rows are matched with a +`LIKE` and then reduced to the ones that are not shadowed, using a `NOT EXISTS` anti-join that looks +for a stored value closer along the chain. Localized and global scope properties are searched in the +same statement, as two alternatives of one condition, so that an asset matching in both is still +returned once. The leading wildcard of the `LIKE` means the index cannot be used – if that ever becomes +a problem, a `FULLTEXT` index is the way out, and nothing in the public API would have to change. + +## Tests + +Tests are part of the regular Flow test suites: + +```bash +./bin/phpunit -c Build/BuildEssentials/PhpUnit/UnitTests.xml --filter 'Neos\\MetaData' +./bin/phpunit -c Build/BuildEssentials/PhpUnit/FunctionalTests.xml --filter 'Neos\\MetaData' +``` + +They are split along the seams the package is built on: + +| Test | Subject | +|-------------------------------------------------------|-----------------------------------------------------------------------------------| +| `MetaDataManagerTest` | The resolution rules, with the storage and the dimension space point provider as test doubles | +| `MetaDataRepairTest` | Which stored values contradict the configuration and what is done about them | +| `MetaDataPropertyTypeTest` | Coercing values to a declared type and back | +| `MetaDataStorageProviderDbalAdapterTest` | The `MetaDataStorage` implementation, **functional** | +| `DimensionSpacePointProviderContentRepositoryAdapterTest` | The `DimensionSpacePointProvider` implementation | +| `MetaDataConfigurationProviderYamlAdapterTest` | The `MetaDataConfigurationProvider` implementation | + +The manager tests state the stored values they resolve from rather than writing them first, so they say +what a rule *is* instead of demonstrating it through a round trip. What a storage does with a lookup is +its own business, and is covered once per implementation. + +The storage adapter is deliberately MySQL specific – the upsert, the fallback ranking and the null safe +correlation all use MySQL syntax, and the matching semantics of the search are those of +`utf8mb4_unicode_ci`. Its tests therefore need a MySQL or MariaDB test database and are skipped on other +platforms; they are the only ones that do. That test also covers the `MetaDataStorageMaintenance` +surface that `assetmetadata:repair` is built on. diff --git a/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php b/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php new file mode 100644 index 0000000..255be96 --- /dev/null +++ b/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php @@ -0,0 +1,516 @@ +connection = $this->objectManager->get(EntityManagerInterface::class)->getConnection(); + if (!$this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform) { + self::markTestSkipped('The metadata storage adapter requires MySQL or MariaDB'); + } + $this->connection->executeStatement('CREATE TABLE IF NOT EXISTS neos_metadata_value ( + `asset_source_id` VARCHAR(255) DEFAULT NULL, + `asset_id` VARCHAR(40) DEFAULT NULL, + `property_name` VARCHAR(40) NOT NULL, + `property_value` VARCHAR(250) NOT NULL, + `dimension_hash` VARCHAR(250) NOT NULL, + UNIQUE INDEX idx_unique (`asset_source_id`, `asset_id`, `property_name`, `dimension_hash`) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->connection->executeStatement('DELETE FROM neos_metadata_value'); + + $this->storage = new MetaDataStorageProviderDbalAdapter($this->connection); + $this->asset = MetaDataAssetReference::create('neos', 'some-asset'); + $this->caption = MetaDataPropertyName::fromString('caption'); + $this->de = MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'de']); + $this->en = MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'en']); + } + + public function tearDown(): void + { + $this->connection->executeStatement('DELETE FROM neos_metadata_value'); + parent::tearDown(); + } + + /** + * @test + */ + public function valuesAreStoredAndLookedUpByDimensionHash(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Eine Katze', $this->de); + + self::assertSame( + [$this->de->hash => 'Eine Katze'], + $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->de)), + ); + } + + /** + * @test + */ + public function storingAValueTwiceReplacesItInsteadOfDuplicatingIt(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Eine Katze', $this->de); + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Ein Kater', $this->de); + + self::assertSame([$this->de->hash => 'Ein Kater'], $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->de))); + self::assertSame(1, (int)$this->connection->fetchOne('SELECT COUNT(*) FROM neos_metadata_value')); + } + + /** + * @test + */ + public function allMatchingValuesAreReturnedForSeveralDimensionSpacePoints(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Eine Katze', $this->de); + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + + $values = $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->de, $this->en)); + self::assertCount(2, $values); + self::assertSame('Eine Katze', $values[$this->de->hash]); + self::assertSame('A cat', $values[$this->en->hash]); + } + + /** + * @test + */ + public function dimensionSpacePointsWithoutAValueAreAbsentFromTheResult(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + + self::assertSame( + [$this->en->hash => 'A cat'], + $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->de, $this->en)), + ); + } + + /** + * @test + */ + public function anEmptyScopeIsNotQueried(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + + self::assertSame([], $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create())); + } + + /** + * @test + */ + public function globalValuesAreStoredSeparatelyFromLocalizedOnes(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Shared', MetaDataGlobalScope::create()); + + self::assertSame( + [$this->en->hash => 'A cat'], + $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->en)), + 'a localized lookup must not see the shared value', + ); + self::assertSame(['global' => 'Shared'], $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataGlobalScope::create())); + } + + /** + * @test + */ + public function unsettingAValueOnlyAffectsTheGivenScope(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Eine Katze', $this->de); + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + $this->storage->unsetMetaDataPropertyValue($this->asset, $this->caption, $this->de); + + self::assertSame( + [$this->en->hash => 'A cat'], + $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->de, $this->en)), + ); + } + + /** + * @test + */ + public function storedValuesOfAllAssetsCanBeIterated(): void + { + $otherAsset = MetaDataAssetReference::create('other-source', 'other-asset'); + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + $this->storage->setMetaDataPropertyValue($otherAsset, $this->caption, 'Shared', MetaDataGlobalScope::create()); + + $storedValues = iterator_to_array($this->storage->findAllStoredValues(), false); + usort($storedValues, static fn (MetaDataStoredValue $a, MetaDataStoredValue $b) => $a->assetReference->assetId <=> $b->assetReference->assetId); + + self::assertCount(2, $storedValues); + self::assertSame('other-asset', $storedValues[0]->assetReference->assetId); + self::assertSame('other-source', $storedValues[0]->assetReference->assetSourceId); + self::assertTrue($storedValues[0]->global); + self::assertSame('Shared', $storedValues[0]->value); + self::assertFalse($storedValues[1]->global); + self::assertSame($this->en->hash, $storedValues[1]->dimensionHash); + } + + /** + * @test + */ + public function deletingStoredValuesRemovesExactlyThoseRows(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Eine Katze', $this->de); + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Shared', MetaDataGlobalScope::create()); + + $toDelete = array_values(array_filter( + iterator_to_array($this->storage->findAllStoredValues(), false), + static fn (MetaDataStoredValue $storedValue) => $storedValue->global, + )); + self::assertSame(1, $this->storage->deleteStoredValues(...$toDelete)); + + self::assertSame([], $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataGlobalScope::create())); + self::assertCount(2, $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->de, $this->en))); + } + + // ----------------------- asset isolation + + /** + * @test + */ + public function valuesOfOtherAssetsAreNotReturned(): void + { + $otherAsset = MetaDataAssetReference::create('neos', 'other-asset'); + $this->storage->setMetaDataPropertyValue($otherAsset, $this->caption, 'A cat', $this->en); + + self::assertSame([], $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->en))); + } + + /** + * @test + */ + public function valuesOfOtherAssetSourcesAreNotReturned(): void + { + $sameAssetInAnotherSource = MetaDataAssetReference::create('other-source', 'some-asset'); + $this->storage->setMetaDataPropertyValue($sameAssetInAnotherSource, $this->caption, 'A cat', $this->en); + + self::assertSame([], $this->storage->getMetaDataPropertyValues($this->asset, $this->caption, MetaDataDimensionSpacePoints::create($this->en))); + } + + /** + * @test + */ + public function unsettingAValueOfOneAssetLeavesTheOtherAssetsAlone(): void + { + $otherAsset = MetaDataAssetReference::create('neos', 'other-asset'); + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + $this->storage->setMetaDataPropertyValue($otherAsset, $this->caption, 'Another cat', $this->en); + + $this->storage->unsetMetaDataPropertyValue($this->asset, $this->caption, $this->en); + + self::assertSame( + [$this->en->hash => 'Another cat'], + $this->storage->getMetaDataPropertyValues($otherAsset, $this->caption, MetaDataDimensionSpacePoints::create($this->en)), + ); + } + + // ----------------------- maintenance + + /** + * @test + */ + public function deletingAValueThatIsNotStoredChangesNothing(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + $absent = new MetaDataStoredValue($this->asset, $this->caption, $this->de->hash, false, 'Eine Katze'); + + self::assertSame(0, $this->storage->deleteStoredValues($absent)); + self::assertCount(1, $this->storedValues()); + } + + /** + * @test + */ + public function deletingWithoutAnyValuesChangesNothing(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + + self::assertSame(0, $this->storage->deleteStoredValues()); + self::assertCount(1, $this->storedValues()); + } + + /** + * The dimension hash of a stored value can be handed straight back to the storage, which is what + * `assetmetadata:repair` relies on + * + * @test + */ + public function storedValuesCanBeDeletedByWhatWasIterated(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Shared', MetaDataGlobalScope::create()); + + self::assertSame(2, $this->storage->deleteStoredValues(...$this->storedValues())); + self::assertSame([], $this->storedValues()); + } + + /** + * @test + */ + public function storedValuesOfUnconfiguredDimensionsAndUndefinedPropertiesAreIterated(): void + { + $this->addRawValue($this->asset, 'formerProperty', $this->en->hash, 'obsolete'); + $this->addRawValue($this->asset, 'caption', 'some-obsolete-hash', 'Un gato'); + + $storedValues = $this->storedValues(); + usort($storedValues, static fn (MetaDataStoredValue $a, MetaDataStoredValue $b) => $a->propertyName->value <=> $b->propertyName->value); + + self::assertCount(2, $storedValues, 'repairing must be able to see values that reads can never return'); + self::assertSame('caption', $storedValues[0]->propertyName->value); + self::assertSame('some-obsolete-hash', $storedValues[0]->dimensionHash); + self::assertFalse($storedValues[0]->global); + self::assertSame('formerProperty', $storedValues[1]->propertyName->value); + } + + /** + * @test + */ + public function anEmptyTableIteratesToNothing(): void + { + self::assertSame([], $this->storedValues()); + } + + // ----------------------- searching + + /** + * @test + */ + public function assetsAreFoundBySearchTermInLocalizedAndGlobalProperties(): void + { + $other = MetaDataAssetReference::create('neos', 'other-asset'); + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + $this->storage->setMetaDataPropertyValue($other, MetaDataPropertyName::fromString('copyright'), '© Cat Photos', MetaDataGlobalScope::create()); + + self::assertSame(['neos:other-asset', 'neos:some-asset'], $this->find('cat'), 'and ordered by asset source id, then asset id'); + } + + /** + * @test + */ + public function theSearchTermMatchesAnywhereInAValueAndIgnoresCase(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A CATalogue picture', $this->en); + + self::assertSame(['neos:some-asset'], $this->find('cat')); + } + + /** + * @test + */ + public function inheritedValuesAreFound(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + + self::assertSame(['neos:some-asset'], $this->find('cat', chain: [$this->de, $this->en])); + } + + /** + * @test + */ + public function shadowedValuesAreNotFound(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Eine Katze', $this->de); + + self::assertSame([], $this->find('cat', chain: [$this->de, $this->en]), 'the German value overrides the English one'); + self::assertSame(['neos:some-asset'], $this->find('cat', chain: [$this->en])); + } + + /** + * @test + */ + public function valuesOutsideTheChainAreNotFound(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'Un chat', MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'fr'])); + + self::assertSame([], $this->find('chat', chain: [$this->de, $this->en])); + } + + /** + * @test + */ + public function globalValuesAreFoundRegardlessOfTheChain(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, MetaDataPropertyName::fromString('copyright'), '© Acme', MetaDataGlobalScope::create()); + + self::assertSame(['neos:some-asset'], $this->find('acme', chain: [$this->de, $this->en])); + self::assertSame(['neos:some-asset'], $this->find('acme', chain: [$this->en])); + } + + /** + * @test + */ + public function anAssetMatchingSeveralTimesIsReturnedOnce(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + $this->storage->setMetaDataPropertyValue($this->asset, MetaDataPropertyName::fromString('copyright'), '© Cat Photos', MetaDataGlobalScope::create()); + + self::assertSame(['neos:some-asset'], $this->find('cat')); + } + + /** + * @test + */ + public function theSearchCanBeRestrictedToAnAssetSource(): void + { + $sameAssetInAnotherSource = MetaDataAssetReference::create('other-source', 'some-asset'); + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + $this->storage->setMetaDataPropertyValue($sameAssetInAnotherSource, $this->caption, 'A cat', $this->en); + + self::assertSame(['other-source:some-asset'], $this->find('cat', assetSourceId: 'other-source')); + } + + /** + * @test + */ + public function anOmittedSearchTermMatchesEveryAssetWithAValue(): void + { + $other = MetaDataAssetReference::create('neos', 'other-asset'); + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + $this->storage->setMetaDataPropertyValue($other, MetaDataPropertyName::fromString('copyright'), '© Acme', MetaDataGlobalScope::create()); + + self::assertSame(['neos:other-asset', 'neos:some-asset'], $this->find(null)); + } + + /** + * @test + */ + public function likeWildcardsInTheSearchTermAreEscaped(): void + { + $discounted = MetaDataAssetReference::create('neos', 'discounted'); + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + $this->storage->setMetaDataPropertyValue($discounted, $this->caption, 'Reduced by 50%', $this->en); + + self::assertSame(['neos:discounted'], $this->find('50%')); + self::assertSame([], $this->find('c_t')); + self::assertSame([], $this->find('\\')); + } + + /** + * @test + */ + public function valuesOfAScopeThatContradictsTheSearchedOneAreNotFound(): void + { + $this->addRawValue($this->asset, 'copyright', $this->en->hash, '© Stale'); + $this->addRawValue($this->asset, 'caption', 'global', 'Stale caption'); + + self::assertSame([], $this->find('stale', chain: [$this->en])); + } + + /** + * @test + */ + public function searchingWithoutAnyPropertyNamesReturnsNothing(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + + self::assertSame([], iterator_to_array($this->storage->findAssets( + null, + 'cat', + MetaDataPropertyNames::createEmpty(), + MetaDataDimensionSpacePoints::create($this->en), + MetaDataPropertyNames::createEmpty(), + ), false), 'an empty IN () would be a SQL error, so no query must be issued at all'); + } + + /** + * @test + */ + public function searchingWithAnEmptyChainIgnoresLocalizedProperties(): void + { + $this->storage->setMetaDataPropertyValue($this->asset, $this->caption, 'A cat', $this->en); + + self::assertSame([], iterator_to_array($this->storage->findAssets( + null, + 'cat', + MetaDataPropertyNames::create($this->caption), + MetaDataDimensionSpacePoints::create(), + MetaDataPropertyNames::createEmpty(), + ), false)); + } + + // ----------------------- + + /** + * Searches `caption` as a localized and `copyright` as a global scope property, which is how the + * manager splits the default configuration. + * + * @param list|null $chain ordered from the most to the least specific, defaults to English only + * @return list the matched asset references as ":" + */ + private function find(?string $searchTerm, ?array $chain = null, ?string $assetSourceId = null): array + { + $matches = []; + $assetReferences = $this->storage->findAssets( + $assetSourceId, + $searchTerm, + MetaDataPropertyNames::create('caption'), + MetaDataDimensionSpacePoints::create(...($chain ?? [$this->en])), + MetaDataPropertyNames::create('copyright'), + ); + foreach ($assetReferences as $assetReference) { + $matches[] = $assetReference->assetSourceId . ':' . $assetReference->assetId; + } + return $matches; + } + + private function addRawValue(MetaDataAssetReference $assetReference, string $propertyName, string $dimensionHash, string $value): void + { + $this->connection->insert('neos_metadata_value', [ + 'asset_source_id' => $assetReference->assetSourceId, + 'asset_id' => $assetReference->assetId, + 'property_name' => $propertyName, + 'property_value' => $value, + 'dimension_hash' => $dimensionHash, + ]); + } + + /** + * @return list + */ + private function storedValues(): array + { + return iterator_to_array($this->storage->findAllStoredValues(), false); + } +} diff --git a/Tests/Unit/Configuration/MetaDataConfigurationProviderYamlAdapterTest.php b/Tests/Unit/Configuration/MetaDataConfigurationProviderYamlAdapterTest.php new file mode 100644 index 0000000..f968a8e --- /dev/null +++ b/Tests/Unit/Configuration/MetaDataConfigurationProviderYamlAdapterTest.php @@ -0,0 +1,180 @@ +translator = $this->createMock(Translator::class); + } + + /** + * @test + */ + public function propertiesAreKeyedByTheirName(): void + { + $definitions = $this->definitionsFor(['caption' => [], 'copyright' => []]); + + self::assertTrue($definitions->include(MetaDataPropertyName::fromString('caption'))); + self::assertTrue($definitions->include(MetaDataPropertyName::fromString('copyright'))); + } + + /** + * @return iterable + */ + public static function types(): iterable + { + yield 'string' => ['configuredType' => 'string', 'expectedType' => MetaDataPropertyType::string]; + yield 'integer' => ['configuredType' => 'integer', 'expectedType' => MetaDataPropertyType::integer]; + yield 'boolean' => ['configuredType' => 'boolean', 'expectedType' => MetaDataPropertyType::boolean]; + yield 'omitted defaults to string' => ['configuredType' => null, 'expectedType' => MetaDataPropertyType::string]; + yield 'unknown defaults to string' => ['configuredType' => 'float', 'expectedType' => MetaDataPropertyType::string]; + } + + /** + * @dataProvider types + * @test + */ + public function theTypeIsParsed(?string $configuredType, MetaDataPropertyType $expectedType): void + { + $configuration = $configuredType === null ? [] : ['type' => $configuredType]; + + self::assertSame($expectedType, $this->definitionFor($configuration)->type); + } + + /** + * @test + */ + public function theScopeIsLocalizedUnlessConfiguredOtherwise(): void + { + self::assertFalse($this->definitionFor([])->globalScope); + self::assertFalse($this->definitionFor(['globalScope' => false])->globalScope); + self::assertTrue($this->definitionFor(['globalScope' => true])->globalScope); + } + + /** + * @test + */ + public function aLabelIsUsedVerbatim(): void + { + $this->translator->expects(self::never())->method('translateById'); + + self::assertSame('Some label', $this->definitionFor(['ui' => ['label' => 'Some label']])->ui->label); + } + + /** + * @test + */ + public function theLiteralLabelI18nIsTranslated(): void + { + $this->translator->expects(self::once()) + ->method('translateById') + ->with('properties.caption', [], null, null, 'Main', 'Neos.MetaData') + ->willReturn('Bildunterschrift'); + + self::assertSame('Bildunterschrift', $this->definitionFor(['ui' => ['label' => 'i18n']])->ui->label); + } + + /** + * @test + */ + public function anUntranslatedLabelFallsBackToThePropertyName(): void + { + $this->translator->method('translateById')->willReturn(null); + + self::assertSame('caption', $this->definitionFor(['ui' => ['label' => 'i18n']])->ui->label); + } + + /** + * @test + */ + public function theEditorAndItsOptionsAreParsed(): void + { + $definition = $this->definitionFor([ + 'ui' => [ + 'inspector' => [ + 'editor' => 'Neos.Neos/Inspector/Editors/TextAreaEditor', + 'editorOptions' => ['rows' => 7], + ], + ], + ]); + + self::assertSame('Neos.Neos/Inspector/Editors/TextAreaEditor', $definition->ui->editorDefinition->editorType); + self::assertSame(['rows' => 7], $definition->ui->editorDefinition->options); + } + + /** + * A property without any `ui` configuration must not break the parsing + * + * @test + */ + public function propertiesWithoutUiConfigurationAreParsed(): void + { + $definition = $this->definitionFor([]); + + self::assertSame('caption', $definition->name->value); + } + + /** + * @test + */ + public function aMissingUiConfigLeadsToAnEmptyUiDefinition(): void + { + $definition = $this->definitionFor(['type' => 'string']); + self::assertNull($definition->ui); + } + + /** + * @test + */ + public function anEmptyConfigurationLeadsToNoDefinitions(): void + { + self::assertSame([], iterator_to_array($this->definitionsFor([]))); + } + + /** + * @test + */ + public function propertiesConfiguredToNullAreSkipped(): void + { + $definitions = $this->definitionsFor(['caption' => [], 'copyright' => null]); + + self::assertTrue($definitions->include(MetaDataPropertyName::fromString('caption'))); + self::assertFalse($definitions->include(MetaDataPropertyName::fromString('copyright'))); + } + + // ----------------------- + + /** + * @param array $configuration configuration of a single property named "caption" + */ + private function definitionFor(array $configuration): MetaDataPropertyDefinition + { + return $this->definitionsFor(['caption' => $configuration])->get(MetaDataPropertyName::fromString('caption')); + } + + /** + * @param array|null> $configuration + */ + private function definitionsFor(array $configuration): MetaDataPropertyDefinitions + { + return (new MetaDataConfigurationProviderYamlAdapter($configuration, $this->translator))->getPropertyConfiguration(); + } +} diff --git a/Tests/Unit/DimensionSpacePointProvider/DimensionSpacePointProviderContentRepositoryAdapterTest.php b/Tests/Unit/DimensionSpacePointProvider/DimensionSpacePointProviderContentRepositoryAdapterTest.php new file mode 100644 index 0000000..b4f04df --- /dev/null +++ b/Tests/Unit/DimensionSpacePointProvider/DimensionSpacePointProviderContentRepositoryAdapterTest.php @@ -0,0 +1,193 @@ +adapter([ + 'language' => ['default' => 'en', 'defaultPreset' => 'en', 'presets' => ['en' => ['values' => ['en']], 'de' => ['values' => ['de', 'en']]]], + 'country' => ['default' => 'us', 'defaultPreset' => 'us', 'presets' => ['us' => ['values' => ['us']], 'at' => ['values' => ['at', 'us']]]], + ]); + + self::assertSame(['language' => 'en', 'country' => 'us'], $adapter->getDefaultDimensionSpacePoint()->coordinates); + } + + /** + * @test + */ + public function theChainStartsWithTheDimensionSpacePointItself(): void + { + $adapter = $this->adapter(['language' => ['default' => 'en', 'defaultPreset' => 'en', 'presets' => ['en' => ['values' => ['en']], 'de' => ['values' => ['de', 'en']]]]]); + + $chain = $adapter->getDimensionSpacePointChain(MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'de'])); + + self::assertSame([['language' => 'de'], ['language' => 'en']], self::coordinates($chain)); + } + + /** + * @test + */ + public function aDimensionSpacePointWithoutFallbacksIsItsOwnChain(): void + { + $adapter = $this->adapter(['language' => ['default' => 'en', 'defaultPreset' => 'en', 'presets' => ['en' => ['values' => ['en']], 'de' => ['values' => ['de', 'en']]]]]); + + $chain = $adapter->getDimensionSpacePointChain(MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'en'])); + + self::assertSame([['language' => 'en']], self::coordinates($chain)); + } + + /** + * @test + */ + public function chainsOfSeveralDimensionsAreOrderedByTotalFallbackDistance(): void + { + $adapter = $this->adapter([ + 'language' => ['default' => 'en', 'defaultPreset' => 'en', 'presets' => ['en' => ['values' => ['en']], 'de' => ['values' => ['de', 'en']]]], + 'country' => ['default' => 'us', 'defaultPreset' => 'us', 'presets' => ['us' => ['values' => ['us']], 'at' => ['values' => ['at', 'us']]]], + ]); + + $chain = $adapter->getDimensionSpacePointChain(MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'de', 'country' => 'at'])); + + self::assertSame([ + ['language' => 'de', 'country' => 'at'], + ['language' => 'de', 'country' => 'us'], + ['language' => 'en', 'country' => 'at'], + ['language' => 'en', 'country' => 'us'], + ], self::coordinates($chain), 'the most specific combination must come first, the most generic last'); + } + + /** + * @test + */ + public function withoutContentDimensionsTheOnlyDimensionSpacePointIsTheEmptyOne(): void + { + $adapter = $this->adapter([]); + + self::assertSame([], $adapter->getDefaultDimensionSpacePoint()->coordinates); + self::assertTrue($adapter->isDimensionSpacePointValid(MetaDataDimensionSpacePoint::fromCoordinates([]))); + self::assertFalse($adapter->isDimensionSpacePointValid(MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'de']))); + } + + /** + * @test + */ + public function unknownDimensionValuesAreNotValid(): void + { + $adapter = $this->adapter(['language' => ['default' => 'en', 'defaultPreset' => 'en', 'presets' => ['en' => ['values' => ['en']], 'de' => ['values' => ['de', 'en']]]]]); + + self::assertTrue($adapter->isDimensionSpacePointValid(MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'de']))); + self::assertFalse($adapter->isDimensionSpacePointValid(MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'es']))); + self::assertFalse($adapter->isDimensionSpacePointValid(MetaDataDimensionSpacePoint::fromCoordinates([])), 'a dimension must not be omitted'); + } + + /** + * A preset identifier does not have to equal the primary value of that preset. Everything else in + * the adapter works with values, so enumerating by identifier would produce dimension space points + * that cannot be validated or resolved. + * + * @test + */ + public function dimensionSpacePointsAreEnumeratedByPresetValueRatherThanByPresetIdentifier(): void + { + $adapter = $this->adapter([ + 'language' => [ + 'default' => 'en', + 'defaultPreset' => 'english', + 'presets' => ['english' => ['values' => ['en']], 'german' => ['values' => ['de', 'en']]], + ], + ]); + + self::assertSame([['language' => 'en'], ['language' => 'de']], self::coordinates($adapter->getDimensionSpacePoints())); + } + + /** + * @test + */ + public function everyEnumeratedDimensionSpacePointIsValid(): void + { + $adapter = $this->adapter([ + 'language' => [ + 'default' => 'en', + 'defaultPreset' => 'english', + 'presets' => ['english' => ['values' => ['en']], 'german' => ['values' => ['de', 'en']]], + ], + ]); + + foreach ($adapter->getDimensionSpacePoints() as $dimensionSpacePoint) { + self::assertTrue( + $adapter->isDimensionSpacePointValid($dimensionSpacePoint), + sprintf('%s was enumerated but is not considered valid', $dimensionSpacePoint), + ); + } + self::assertTrue( + $adapter->getDimensionSpacePoints()->include($adapter->getDefaultDimensionSpacePoint()), + 'the default dimension space point must be among the enumerated ones', + ); + } + + /** + * @test + */ + public function presetsWithoutValuesAreNotEnumerated(): void + { + $adapter = $this->adapter([ + 'language' => ['default' => 'en', 'defaultPreset' => 'en', 'presets' => ['en' => ['values' => ['en']], 'broken' => []]], + ]); + + self::assertSame([['language' => 'en']], self::coordinates($adapter->getDimensionSpacePoints())); + } + + // ----------------------- + + /** + * @param array $presets + */ + private function adapter(array $presets): DimensionSpacePointProviderContentRepositoryAdapter + { + $presetSource = new ConfigurationContentDimensionPresetSource(); + $presetSource->setConfiguration($presets); + return new DimensionSpacePointProviderContentRepositoryAdapter($presetSource); + } + + /** + * @return list> + */ + private static function coordinates(iterable $dimensionSpacePoints): array + { + $coordinates = []; + foreach ($dimensionSpacePoints as $dimensionSpacePoint) { + $coordinates[] = $dimensionSpacePoint->coordinates; + } + return $coordinates; + } +} diff --git a/Tests/Unit/Domain/Dto/MetaDataPropertyTypeTest.php b/Tests/Unit/Domain/Dto/MetaDataPropertyTypeTest.php new file mode 100644 index 0000000..2cbd3d7 --- /dev/null +++ b/Tests/Unit/Domain/Dto/MetaDataPropertyTypeTest.php @@ -0,0 +1,120 @@ + + */ + public static function coercibleValues(): iterable + { + yield 'string from string' => ['type' => MetaDataPropertyType::string, 'value' => 'A cat', 'expected' => 'A cat']; + yield 'string from integer' => ['type' => MetaDataPropertyType::string, 'value' => 42, 'expected' => '42']; + yield 'string from boolean' => ['type' => MetaDataPropertyType::string, 'value' => true, 'expected' => '1']; + yield 'string is not trimmed' => ['type' => MetaDataPropertyType::string, 'value' => ' padded ', 'expected' => ' padded ']; + + yield 'integer from integer' => ['type' => MetaDataPropertyType::integer, 'value' => 42, 'expected' => '42']; + yield 'integer from numeric string' => ['type' => MetaDataPropertyType::integer, 'value' => '42', 'expected' => '42']; + yield 'integer from padded string' => ['type' => MetaDataPropertyType::integer, 'value' => ' 42 ', 'expected' => '42']; + yield 'negative integer' => ['type' => MetaDataPropertyType::integer, 'value' => '-42', 'expected' => '-42']; + yield 'integer from boolean' => ['type' => MetaDataPropertyType::integer, 'value' => true, 'expected' => '1']; + + yield 'boolean from boolean' => ['type' => MetaDataPropertyType::boolean, 'value' => true, 'expected' => '1']; + yield 'boolean from false' => ['type' => MetaDataPropertyType::boolean, 'value' => false, 'expected' => '0']; + yield 'boolean from "true"' => ['type' => MetaDataPropertyType::boolean, 'value' => 'true', 'expected' => '1']; + yield 'boolean from "TRUE"' => ['type' => MetaDataPropertyType::boolean, 'value' => 'TRUE', 'expected' => '1']; + yield 'boolean from "on"' => ['type' => MetaDataPropertyType::boolean, 'value' => 'on', 'expected' => '1']; + yield 'boolean from "yes"' => ['type' => MetaDataPropertyType::boolean, 'value' => 'yes', 'expected' => '1']; + yield 'boolean from "false"' => ['type' => MetaDataPropertyType::boolean, 'value' => 'false', 'expected' => '0']; + yield 'boolean from "no"' => ['type' => MetaDataPropertyType::boolean, 'value' => 'no', 'expected' => '0']; + yield 'boolean from 1' => ['type' => MetaDataPropertyType::boolean, 'value' => 1, 'expected' => '1']; + yield 'boolean from 0' => ['type' => MetaDataPropertyType::boolean, 'value' => 0, 'expected' => '0']; + } + + /** + * @dataProvider coercibleValues + * @test + */ + public function valuesAreCoercedToTheirStoredRepresentation(MetaDataPropertyType $type, string|int|bool $value, string $expected): void + { + self::assertSame($expected, $type->coerceForStorage($value)); + } + + /** + * @return iterable + */ + public static function incoercibleValues(): iterable + { + yield 'integer from words' => ['type' => MetaDataPropertyType::integer, 'value' => 'abc']; + yield 'integer from empty string' => ['type' => MetaDataPropertyType::integer, 'value' => '']; + yield 'integer from decimal' => ['type' => MetaDataPropertyType::integer, 'value' => '4.2']; + yield 'integer from partially numeric' => ['type' => MetaDataPropertyType::integer, 'value' => '42px']; + + yield 'boolean from words' => ['type' => MetaDataPropertyType::boolean, 'value' => 'maybe']; + yield 'boolean from empty string' => ['type' => MetaDataPropertyType::boolean, 'value' => '']; + yield 'boolean from other integer' => ['type' => MetaDataPropertyType::boolean, 'value' => 2]; + } + + /** + * @dataProvider incoercibleValues + * @test + */ + public function valuesThatCannotBeInterpretedAreRejected(MetaDataPropertyType $type, string|int|bool $value): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionCode(1785715201); + $type->coerceForStorage($value); + } + + /** + * @return iterable + */ + public static function storedValues(): iterable + { + yield 'string' => ['type' => MetaDataPropertyType::string, 'value' => 'A cat', 'expected' => 'A cat']; + yield 'integer' => ['type' => MetaDataPropertyType::integer, 'value' => '42', 'expected' => 42]; + yield 'negative integer' => ['type' => MetaDataPropertyType::integer, 'value' => '-42', 'expected' => -42]; + yield 'true' => ['type' => MetaDataPropertyType::boolean, 'value' => '1', 'expected' => true]; + yield 'false' => ['type' => MetaDataPropertyType::boolean, 'value' => '0', 'expected' => false]; + } + + /** + * @dataProvider storedValues + * @test + */ + public function storedValuesAreReadBackAsTheirType(MetaDataPropertyType $type, string $value, string|int|bool $expected): void + { + self::assertSame($expected, $type->fromStoredValue($value)); + } + + /** + * @test + */ + public function everyCoercibleValueSurvivesTheRoundTrip(): void + { + foreach (self::coercibleValues() as $name => $case) { + $stored = $case['type']->coerceForStorage($case['value']); + self::assertNotNull($case['type']->fromStoredValue($stored), sprintf('"%s" is not readable again', $name)); + } + } + + /** + * Reading must not throw - it meets values that were written before a property was given its + * current type + * + * @test + */ + public function storedValuesThatCannotBeInterpretedAreReadAsNull(): void + { + self::assertNull(MetaDataPropertyType::integer->fromStoredValue('abc')); + self::assertNull(MetaDataPropertyType::boolean->fromStoredValue('maybe')); + self::assertSame('42', MetaDataPropertyType::string->fromStoredValue('42'), 'anything is readable as a string'); + } +} diff --git a/Tests/Unit/Fixtures/DimensionSpacePointProviderMocks.php b/Tests/Unit/Fixtures/DimensionSpacePointProviderMocks.php new file mode 100644 index 0000000..a3264ff --- /dev/null +++ b/Tests/Unit/Fixtures/DimensionSpacePointProviderMocks.php @@ -0,0 +1,76 @@ + en, fr -> en and en, and en as the + * default one + */ + protected function createLanguageDimensions(): DimensionSpacePointProvider&MockObject + { + $en = self::language('en'); + $de = self::language('de'); + $fr = self::language('fr'); + return $this->createDimensions( + $en, + MetaDataDimensionSpacePoints::create($en, $de, $fr), + [ + $en->hash => [$en], + $de->hash => [$de, $en], + $fr->hash => [$fr, $en], + ], + ); + } + + /** + * No content dimensions at all: the only valid dimension space point is the empty one + */ + protected function createEmptyDimensions(): DimensionSpacePointProvider&MockObject + { + $empty = MetaDataDimensionSpacePoint::fromCoordinates([]); + return $this->createDimensions($empty, MetaDataDimensionSpacePoints::create($empty), [$empty->hash => [$empty]]); + } + + protected static function language(string $value): MetaDataDimensionSpacePoint + { + return MetaDataDimensionSpacePoint::fromCoordinates(['language' => $value]); + } + + /** + * @param array> $chainsByHash ordered from the most to the least specific + */ + private function createDimensions( + MetaDataDimensionSpacePoint $defaultDimensionSpacePoint, + MetaDataDimensionSpacePoints $dimensionSpacePoints, + array $chainsByHash, + ): DimensionSpacePointProvider&MockObject { + $provider = $this->createMock(DimensionSpacePointProvider::class); + $provider->method('getDimensionSpacePoints')->willReturn($dimensionSpacePoints); + $provider->method('getDefaultDimensionSpacePoint')->willReturn($defaultDimensionSpacePoint); + $provider->method('isDimensionSpacePointValid')->willReturnCallback( + static fn (MetaDataDimensionSpacePoint $dimensionSpacePoint) => array_key_exists($dimensionSpacePoint->hash, $chainsByHash) + ); + $provider->method('getDimensionSpacePointChain')->willReturnCallback( + static fn (MetaDataDimensionSpacePoint $dimensionSpacePoint) => MetaDataDimensionSpacePoints::create( + ...($chainsByHash[$dimensionSpacePoint->hash] ?? [$dimensionSpacePoint]) + ) + ); + return $provider; + } +} diff --git a/Tests/Unit/Fixtures/MaintainableMetaDataStorage.php b/Tests/Unit/Fixtures/MaintainableMetaDataStorage.php new file mode 100644 index 0000000..3f25714 --- /dev/null +++ b/Tests/Unit/Fixtures/MaintainableMetaDataStorage.php @@ -0,0 +1,18 @@ + $globalScopeByPropertyName + */ + public static function create(array $globalScopeByPropertyName): MetaDataPropertyDefinitions + { + $definitions = []; + foreach ($globalScopeByPropertyName as $propertyName => $globalScope) { + $definitions[] = self::definition($propertyName, MetaDataPropertyType::string, $globalScope); + } + return MetaDataPropertyDefinitions::create(...$definitions); + } + + /** + * `copyright` is shared by all dimensions, `caption` is localized + */ + public static function default(): MetaDataPropertyDefinitions + { + return self::create(['copyright' => true, 'caption' => false]); + } + + /** + * The default definitions plus a localized `width` of type integer and a localized `featured` of + * type boolean + */ + public static function typed(): MetaDataPropertyDefinitions + { + return MetaDataPropertyDefinitions::create( + self::definition('copyright', MetaDataPropertyType::string, true), + self::definition('caption', MetaDataPropertyType::string, false), + self::definition('width', MetaDataPropertyType::integer, false), + self::definition('featured', MetaDataPropertyType::boolean, false), + ); + } + + // ----------------------- + + private static function definition(string $propertyName, MetaDataPropertyType $type, bool $globalScope): MetaDataPropertyDefinition + { + return new MetaDataPropertyDefinition( + MetaDataPropertyName::fromString($propertyName), + $type, + $globalScope, + new MetaDataPropertyUiDefinition($propertyName, MetaDataEditorDefinition::default()), + ); + } +} diff --git a/Tests/Unit/Maintenance/MetaDataRepairTest.php b/Tests/Unit/Maintenance/MetaDataRepairTest.php new file mode 100644 index 0000000..d3d29d0 --- /dev/null +++ b/Tests/Unit/Maintenance/MetaDataRepairTest.php @@ -0,0 +1,338 @@ + + */ + private array $calls = []; + + public function setUp(): void + { + $this->de = self::language('de'); + $this->en = self::language('en'); + $this->fr = self::language('fr'); + $this->asset = MetaDataAssetReference::create('neos', 'some-asset'); + + $dimensions = $this->createLanguageDimensions(); + $this->storage = $this->createMock(MaintainableMetaDataStorage::class); + $this->storage->method('setMetaDataPropertyValue')->willReturnCallback( + function (MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, string|int|bool $value, MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): void { + $this->calls[] = sprintf( + 'set %s of %s to "%s" in %s', + $propertyName->value, + $assetReference->assetId, + $value, + $scope instanceof MetaDataGlobalScope ? 'global' : $scope->coordinates['language'], + ); + } + ); + $this->storage->method('deleteStoredValues')->willReturnCallback( + function (MetaDataStoredValue ...$storedValues): int { + foreach ($storedValues as $storedValue) { + $this->calls[] = sprintf('delete %s of %s', $storedValue->propertyName->value, $storedValue->assetReference->assetId); + } + return count($storedValues); + } + ); + + $this->metaDataManager = new MetaDataManager($dimensions, PropertyDefinitionsFixture::default(), $this->storage); + $this->metaDataRepair = new MetaDataRepair($this->metaDataManager, $dimensions, $this->storage); + } + + /** + * @test + */ + public function consistentDataNeedsNoRepair(): void + { + $this->storageContains( + self::localizedValue($this->asset, 'caption', $this->en, 'A cat'), + self::globalValue($this->asset, 'copyright', '© Acme'), + ); + + self::assertSame([], $this->metaDataRepair->analyze()); + } + + /** + * @test + */ + public function localizedValuesOfAGlobalPropertyAreConsolidatedIntoTheDefaultChainWinner(): void + { + $this->storageContains( + self::localizedValue($this->asset, 'copyright', $this->de, '© Acme'), + self::localizedValue($this->asset, 'copyright', $this->en, '© Acme Inc'), + ); + + $actions = $this->metaDataRepair->analyze(); + $promotions = self::actionsOfType($actions, MetaDataRepairActionType::promoteToGlobalScope); + self::assertCount(1, $promotions); + self::assertSame('© Acme Inc', $promotions[0]->storedValue->value, 'the value of the default dimension wins'); + self::assertCount(2, self::actionsOfType($actions, MetaDataRepairActionType::deleteWrongScope)); + + self::assertSame(2, $this->metaDataRepair->apply($actions)); + self::assertSame( + [ + 'set copyright of some-asset to "© Acme Inc" in global', + 'delete copyright of some-asset', + 'delete copyright of some-asset', + ], + $this->calls, + 'the value is promoted before the rows it came from are deleted', + ); + } + + /** + * @test + */ + public function theOnlyLocalizedValueOfAGlobalPropertyIsKeptEvenIfItIsNotOnTheDefaultChain(): void + { + $this->storageContains(self::localizedValue($this->asset, 'copyright', $this->fr, '© Foto Meier')); + + $this->metaDataRepair->apply($this->metaDataRepair->analyze()); + + self::assertContains('set copyright of some-asset to "© Foto Meier" in global', $this->calls); + } + + /** + * @test + */ + public function anExistingSharedValueWinsOverStaleLocalizedOnes(): void + { + $this->storageContains( + self::globalValue($this->asset, 'copyright', '© Current'), + self::localizedValue($this->asset, 'copyright', $this->en, '© Stale'), + ); + + $actions = $this->metaDataRepair->analyze(); + self::assertSame([], self::actionsOfType($actions, MetaDataRepairActionType::promoteToGlobalScope), 'live data must not be overwritten'); + + self::assertSame(1, $this->metaDataRepair->apply($actions)); + self::assertSame(['delete copyright of some-asset'], $this->calls, 'only the stale row is removed'); + } + + /** + * @test + */ + public function aSharedValueOfALocalizedPropertyIsPromotedToTheDefaultDimension(): void + { + $this->storageContains(self::globalValue($this->asset, 'caption', 'A cat')); + + $actions = $this->metaDataRepair->analyze(); + self::assertCount(1, self::actionsOfType($actions, MetaDataRepairActionType::promoteToDefaultDimension)); + + $this->metaDataRepair->apply($actions); + self::assertSame( + ['set caption of some-asset to "A cat" in en', 'delete caption of some-asset'], + $this->calls, + ); + } + + /** + * @test + */ + public function aSharedValueIsNotPromotedIfTheDefaultDimensionAlreadyHasAValue(): void + { + $this->storageContains( + self::localizedValue($this->asset, 'caption', $this->en, 'A cat'), + self::globalValue($this->asset, 'caption', 'Stale'), + ); + + $actions = $this->metaDataRepair->analyze(); + self::assertSame([], self::actionsOfType($actions, MetaDataRepairActionType::promoteToDefaultDimension)); + + $this->metaDataRepair->apply($actions); + self::assertSame(['delete caption of some-asset'], $this->calls); + } + + /** + * @test + */ + public function valuesOfUnconfiguredDimensionsAreOnlyRemovedWhenPruning(): void + { + $this->storageContains(self::localizedValue($this->asset, 'caption', self::language('es'), 'Un gato')); + + $actions = $this->metaDataRepair->analyze(); + self::assertCount(1, self::actionsOfType($actions, MetaDataRepairActionType::deleteObsoleteDimension)); + + self::assertSame(0, $this->metaDataRepair->apply($actions)); + self::assertSame([], $this->calls, 'nothing is touched without pruning'); + + self::assertSame(1, $this->metaDataRepair->apply($actions, prune: true)); + self::assertSame(['delete caption of some-asset'], $this->calls); + } + + /** + * @test + */ + public function valuesOfUndefinedPropertiesAreOnlyRemovedWhenPruning(): void + { + $this->storageContains(self::localizedValue($this->asset, 'formerProperty', $this->en, 'obsolete')); + + $actions = $this->metaDataRepair->analyze(); + self::assertCount(1, self::actionsOfType($actions, MetaDataRepairActionType::deleteUndefinedProperty)); + + self::assertSame(0, $this->metaDataRepair->apply($actions)); + self::assertSame(1, $this->metaDataRepair->apply($actions, prune: true)); + self::assertSame(['delete formerProperty of some-asset'], $this->calls); + } + + /** + * @test + */ + public function valuesOfOtherAssetsAreNotAffected(): void + { + $otherAsset = MetaDataAssetReference::create('neos', 'other-asset'); + $this->storageContains( + self::localizedValue($this->asset, 'copyright', $this->en, '© Acme'), + self::localizedValue($otherAsset, 'caption', $this->en, 'A cat'), + ); + + $this->metaDataRepair->apply($this->metaDataRepair->analyze()); + + foreach ($this->calls as $call) { + self::assertStringNotContainsString('other-asset', $call); + } + } + + /** + * Values of the same property but of different assets must not be consolidated into one another + * + * @test + */ + public function eachAssetIsRepairedOnItsOwn(): void + { + $otherAsset = MetaDataAssetReference::create('neos', 'other-asset'); + $this->storageContains( + self::localizedValue($this->asset, 'copyright', $this->en, '© Acme'), + self::localizedValue($otherAsset, 'copyright', $this->en, '© Other'), + ); + + $promotions = self::actionsOfType($this->metaDataRepair->analyze(), MetaDataRepairActionType::promoteToGlobalScope); + + self::assertCount(2, $promotions); + self::assertSame( + ['© Acme', '© Other'], + array_map(static fn (MetaDataRepairAction $action) => $action->storedValue->value, $promotions), + ); + } + + /** + * @test + */ + public function pruningIsRefusedWithoutConfiguredDimensions(): void + { + $dimensions = $this->createEmptyDimensions(); + $metaDataRepair = new MetaDataRepair( + new MetaDataManager($dimensions, PropertyDefinitionsFixture::default(), $this->storage), + $dimensions, + $this->storage, + ); + + self::assertFalse($metaDataRepair->hasConfiguredDimensions()); + self::assertTrue($this->metaDataRepair->hasConfiguredDimensions()); + } + + /** + * @test + */ + public function repairingIsSupportedByStoragesImplementingTheMaintenanceInterface(): void + { + self::assertTrue($this->metaDataRepair->isSupported()); + } + + /** + * @test + */ + public function repairingIsUnsupportedByStoragesNotImplementingTheMaintenanceInterface(): void + { + self::assertFalse($this->repairWithPlainStorage()->isSupported()); + } + + /** + * @test + */ + public function applyingWithAStorageThatCannotBeMaintainedThrows(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(1776280001); + $this->repairWithPlainStorage()->apply([]); + } + + // ----------------------- + + private function repairWithPlainStorage(): MetaDataRepair + { + $dimensions = $this->createLanguageDimensions(); + $storage = $this->createMock(MetaDataStorage::class); + return new MetaDataRepair( + new MetaDataManager($dimensions, PropertyDefinitionsFixture::default(), $storage), + $dimensions, + $storage, + ); + } + + private function storageContains(MetaDataStoredValue ...$storedValues): void + { + $this->storage->method('findAllStoredValues')->willReturn($storedValues); + } + + private static function localizedValue(MetaDataAssetReference $assetReference, string $propertyName, MetaDataDimensionSpacePoint $dimensionSpacePoint, string $value): MetaDataStoredValue + { + return new MetaDataStoredValue($assetReference, MetaDataPropertyName::fromString($propertyName), $dimensionSpacePoint->hash, false, $value); + } + + private static function globalValue(MetaDataAssetReference $assetReference, string $propertyName, string $value): MetaDataStoredValue + { + return new MetaDataStoredValue($assetReference, MetaDataPropertyName::fromString($propertyName), 'global', true, $value); + } + + /** + * @param list $actions + * @return list + */ + private static function actionsOfType(array $actions, MetaDataRepairActionType $type): array + { + return array_values(array_filter($actions, static fn (MetaDataRepairAction $action) => $action->type === $type)); + } +} diff --git a/Tests/Unit/MetaDataManagerTest.php b/Tests/Unit/MetaDataManagerTest.php new file mode 100644 index 0000000..016bec3 --- /dev/null +++ b/Tests/Unit/MetaDataManagerTest.php @@ -0,0 +1,661 @@ +de = self::language('de'); + $this->en = self::language('en'); + $this->fr = self::language('fr'); + $this->es = self::language('es'); + + $this->storage = $this->createMock(MetaDataStorage::class); + $this->dimensionSpacePointProvider = $this->createLanguageDimensions(); + $this->metaDataManager = $this->managerFor(PropertyDefinitionsFixture::default()); + $this->asset = MetaDataAssetReference::create('neos', 'some-asset'); + } + + // ----------------------- reading + + /** + * @test + */ + public function localizedValueWithoutFallbackIsItsOwnValue(): void + { + $this->storageContains(['caption' => [$this->de->hash => 'Eine Katze']]); + + $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de); + self::assertSame('Eine Katze', $value->value); + self::assertSame('Eine Katze', $value->ownValue); + self::assertNull($value->inheritedValue); + self::assertNull($value->inheritedFrom); + self::assertTrue($value->hasOwnValue()); + self::assertFalse($value->isInherited()); + } + + /** + * @test + */ + public function localizedValueFallsBackToTheFallbackDimension(): void + { + $this->storageContains(['caption' => [$this->en->hash => 'A cat']]); + + $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de); + self::assertSame('A cat', $value->value); + self::assertNull($value->ownValue, 'the editing use case must not see the fallback value'); + self::assertSame('A cat', $value->inheritedValue); + self::assertTrue($value->inheritedFrom?->equals($this->en)); + self::assertTrue($value->isInherited()); + } + + /** + * @test + */ + public function ownAndInheritedValueAreReturnedSideBySide(): void + { + $this->storageContains(['caption' => [$this->en->hash => 'A cat', $this->de->hash => 'Eine Katze']]); + + $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de); + self::assertSame('Eine Katze', $value->value, 'the own value wins'); + self::assertSame('Eine Katze', $value->ownValue); + self::assertSame('A cat', $value->inheritedValue, 'the translation hint is available even though the value is overridden'); + self::assertTrue($value->inheritedFrom?->equals($this->en)); + self::assertFalse($value->isInherited()); + } + + /** + * @test + */ + public function onlyTheClosestFallbackIsInherited(): void + { + $this->storageContains(['caption' => [$this->en->hash => 'A cat', $this->fr->hash => 'Un chat']]); + + $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de); + self::assertSame('A cat', $value->inheritedValue); + self::assertTrue($value->inheritedFrom?->equals($this->en), 'French is not on the German fallback chain'); + } + + /** + * The candidates are looked up in one go, so the manager must not rely on the storage to return + * them in the order of the chain + * + * @test + */ + public function resolutionDoesNotDependOnTheOrderTheStorageReturnsValuesIn(): void + { + $this->storageContains(['caption' => [$this->de->hash => 'Eine Katze', $this->en->hash => 'A cat']]); + + $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de); + self::assertSame('Eine Katze', $value->ownValue); + self::assertSame('A cat', $value->inheritedValue); + } + + /** + * @test + */ + public function valuesOfUnrelatedDimensionsAreNotInherited(): void + { + $this->storageContains(['caption' => [$this->fr->hash => 'Un chat']]); + + $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de); + self::assertNull($value->value); + self::assertNull($value->ownValue); + self::assertNull($value->inheritedValue); + } + + /** + * @test + */ + public function missingValuesResolveToEmpty(): void + { + $this->storageContains([]); + + $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de); + self::assertNull($value->value); + self::assertNull($value->ownValue); + self::assertNull($value->inheritedValue); + self::assertNull($value->inheritedFrom); + self::assertFalse($value->hasOwnValue()); + self::assertFalse($value->isInherited()); + } + + /** + * @test + */ + public function omittedDimensionSpacePointRefersToTheDefaultOne(): void + { + $this->storageContains(['caption' => [$this->en->hash => 'A cat']]); + + self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption')->ownValue); + } + + /** + * @test + */ + public function localizedValuesAreLookedUpAlongTheWholeChain(): void + { + $this->storage->expects(self::once()) + ->method('getMetaDataPropertyValues') + ->with( + $this->asset, + self::callback(static fn (MetaDataPropertyName $name) => $name->equals('caption')), + self::callback(fn (MetaDataDimensionSpacePoints $scope) => self::hashesOf($scope) === [$this->de->hash, $this->en->hash]), + ) + ->willReturn([]); + + $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de); + } + + /** + * @test + */ + public function allDefinedPropertiesArePresentInTheResult(): void + { + $this->storageContains([]); + + self::assertSame( + ['copyright' => null, 'caption' => null], + $this->metaDataManager->getMetaDataPropertyValues($this->asset, $this->de)->toArray(), + ); + } + + /** + * @test + */ + public function readingAnUndefinedPropertyThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionCode(1776278047); + $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'unknown', $this->de); + } + + // ----------------------- global scope + + /** + * @test + */ + public function globalValuesAreLookedUpInTheGlobalScope(): void + { + $this->storage->expects(self::once()) + ->method('getMetaDataPropertyValues') + ->with($this->asset, self::anything(), self::isInstanceOf(MetaDataGlobalScope::class)) + ->willReturn(['global' => '© Acme']); + + self::assertSame('© Acme', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright', $this->de)->value); + } + + /** + * @test + */ + public function globalValueIsSharedByAllDimensions(): void + { + $this->storageContains(['copyright' => ['global' => '© Acme']]); + + foreach ([$this->de, $this->en, $this->fr, null] as $dimensionSpacePoint) { + $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright', $dimensionSpacePoint); + self::assertSame('© Acme', $value->value); + self::assertSame('© Acme', $value->ownValue); + } + } + + /** + * @test + */ + public function globalValueIsNeverInherited(): void + { + $this->storageContains(['copyright' => ['global' => '© Acme']]); + + $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright', $this->de); + self::assertSame('© Acme', $value->ownValue, 'a shared value is always an own value'); + self::assertNull($value->inheritedValue, 'a shared value has nothing to inherit from'); + self::assertNull($value->inheritedFrom); + self::assertFalse($value->isInherited()); + } + + /** + * @test + */ + public function readingAGlobalValueAcceptsAnyDimensionSpacePoint(): void + { + $this->storageContains(['copyright' => ['global' => '© Acme']]); + + self::assertSame('© Acme', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright', $this->es)->value); + } + + // ----------------------- writing + + /** + * @test + */ + public function settingALocalizedValueWritesItToTheGivenDimension(): void + { + $this->storage->expects(self::once()) + ->method('setMetaDataPropertyValue') + ->with( + $this->asset, + self::callback(static fn (MetaDataPropertyName $name) => $name->equals('caption')), + 'Eine Katze', + self::callback(fn (MetaDataDimensionSpacePoint $scope) => $scope->equals($this->de)), + ); + + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de); + } + + /** + * @test + */ + public function settingAValueWithoutADimensionSpacePointWritesItToTheDefaultOne(): void + { + $this->storage->expects(self::once()) + ->method('setMetaDataPropertyValue') + ->with(self::anything(), self::anything(), self::anything(), self::callback(fn (MetaDataDimensionSpacePoint $scope) => $scope->equals($this->en))); + + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat'); + } + + /** + * @test + */ + public function settingAGlobalValueWritesItToTheGlobalScope(): void + { + $this->storage->expects(self::once()) + ->method('setMetaDataPropertyValue') + ->with(self::anything(), self::anything(), '© Acme', self::isInstanceOf(MetaDataGlobalScope::class)); + + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->de); + } + + /** + * The dimension space point is ignored for global properties, so it is not validated either + * + * @test + */ + public function writingAGlobalValueAcceptsAnyDimensionSpacePoint(): void + { + $this->storage->expects(self::once()) + ->method('setMetaDataPropertyValue') + ->with(self::anything(), self::anything(), self::anything(), self::isInstanceOf(MetaDataGlobalScope::class)); + + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->es); + } + + /** + * @test + */ + public function unsettingALocalizedValueOnlyAffectsTheGivenDimension(): void + { + $this->storage->expects(self::once()) + ->method('unsetMetaDataPropertyValue') + ->with($this->asset, self::anything(), self::callback(fn (MetaDataDimensionSpacePoint $scope) => $scope->equals($this->de))); + + $this->metaDataManager->unsetMetaDataPropertyValue($this->asset, 'caption', $this->de); + } + + /** + * @test + */ + public function unsettingAGlobalValueIgnoresTheDimensionSpacePoint(): void + { + $this->storage->expects(self::once()) + ->method('unsetMetaDataPropertyValue') + ->with(self::anything(), self::anything(), self::isInstanceOf(MetaDataGlobalScope::class)); + + $this->metaDataManager->unsetMetaDataPropertyValue($this->asset, 'copyright', $this->de); + } + + /** + * @test + */ + public function writingAnUndefinedPropertyThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionCode(1776278047); + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'unknown', 'whatever', $this->de); + } + + /** + * @test + */ + public function writingAnUnconfiguredDimensionSpacePointThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionCode(1776279083); + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Hola', $this->es); + } + + // ----------------------- property types + + /** + * @test + */ + public function valuesAreCoercedToTheDefinedTypeBeforeTheyAreStored(): void + { + $manager = $this->managerFor(PropertyDefinitionsFixture::typed()); + $written = []; + $this->storage->method('setMetaDataPropertyValue') + ->willReturnCallback(static function (MetaDataAssetReference $ref, MetaDataPropertyName $name, string|int|bool $value) use (&$written): void { + $written[$name->value] = $value; + }); + + $manager->setMetaDataPropertyValue($this->asset, 'width', '42', $this->de); + $manager->setMetaDataPropertyValue($this->asset, 'featured', 'yes', $this->de); + + self::assertSame(['width' => '42', 'featured' => '1'], $written, 'string input from the command line or a form is coerced'); + } + + /** + * @test + */ + public function writingAValueThatDoesNotMatchTheTypeThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionCode(1785715201); + $this->managerFor(PropertyDefinitionsFixture::typed())->setMetaDataPropertyValue($this->asset, 'width', 'abc', $this->de); + } + + /** + * @test + */ + public function storedValuesAreReadBackAsTheDefinedType(): void + { + $manager = $this->managerFor(PropertyDefinitionsFixture::typed()); + $this->storageContains([ + 'width' => [$this->de->hash => '42'], + 'featured' => [$this->de->hash => '1'], + ]); + + self::assertSame(42, $manager->getMetaDataPropertyValue($this->asset, 'width', $this->de)->value); + self::assertTrue($manager->getMetaDataPropertyValue($this->asset, 'featured', $this->de)->value); + } + + /** + * @test + */ + public function falseAndZeroAreDistinguishableFromAnAbsentValue(): void + { + $manager = $this->managerFor(PropertyDefinitionsFixture::typed()); + $this->storageContains([ + 'width' => [$this->de->hash => '0'], + 'featured' => [$this->de->hash => '0'], + ]); + + $width = $manager->getMetaDataPropertyValue($this->asset, 'width', $this->de); + self::assertSame(0, $width->value); + self::assertTrue($width->hasOwnValue()); + + $featured = $manager->getMetaDataPropertyValue($this->asset, 'featured', $this->de); + self::assertFalse($featured->value); + self::assertTrue($featured->hasOwnValue(), 'FALSE is a value, not the absence of one'); + } + + /** + * @test + */ + public function storedValuesThatDoNotMatchTheTypeAreTreatedLikeAbsentOnes(): void + { + $manager = $this->managerFor(PropertyDefinitionsFixture::typed()); + $this->storageContains(['width' => [$this->de->hash => 'abc']]); + + $value = $manager->getMetaDataPropertyValue($this->asset, 'width', $this->de); + self::assertNull($value->value); + self::assertFalse($value->hasOwnValue()); + } + + /** + * @test + */ + public function anUnreadableValueDoesNotShadowAReadableFallback(): void + { + $manager = $this->managerFor(PropertyDefinitionsFixture::typed()); + $this->storageContains(['width' => [$this->de->hash => 'abc', $this->en->hash => '42']]); + + $value = $manager->getMetaDataPropertyValue($this->asset, 'width', $this->de); + self::assertSame(42, $value->value, 'the English value is still readable'); + self::assertNull($value->ownValue); + self::assertTrue($value->isInherited()); + } + + /** + * @test + */ + public function globalValuesAreCoercedAsWell(): void + { + $manager = $this->managerFor(PropertyDefinitionsFixture::create(['copyright' => true, 'caption' => false])); + $this->storageContains(['copyright' => ['global' => '42']]); + + self::assertSame('42', $manager->getMetaDataPropertyValue($this->asset, 'copyright')->value); + } + + // ----------------------- finding assets + + /** + * @test + */ + public function findingAssetsSplitsThePropertiesByScopeAndPassesTheOrderedChain(): void + { + $this->storage->expects(self::once()) + ->method('findAssets') + ->with( + 'neos', + 'cat', + self::callback(static fn (MetaDataPropertyNames $names) => self::valuesOf($names) === ['caption']), + self::callback(fn (MetaDataDimensionSpacePoints $chain) => self::hashesOf($chain) === [$this->de->hash, $this->en->hash]), + self::callback(static fn (MetaDataPropertyNames $names) => self::valuesOf($names) === ['copyright']), + ) + ->willReturn([]); + + iterator_to_array($this->metaDataManager->findAssets(MetaDataAssetFilter::create( + assetSourceId: 'neos', + dimensionSpacePoint: $this->de, + searchTerm: 'cat', + )), false); + } + + /** + * @test + */ + public function findingAssetsWithoutAPropertyFilterSearchesAllDefinedProperties(): void + { + $this->storage->expects(self::once()) + ->method('findAssets') + ->with( + null, + null, + self::callback(static fn (MetaDataPropertyNames $names) => self::valuesOf($names) === ['caption']), + self::anything(), + self::callback(static fn (MetaDataPropertyNames $names) => self::valuesOf($names) === ['copyright']), + ) + ->willReturn([]); + + iterator_to_array($this->metaDataManager->findAssets(MetaDataAssetFilter::create()), false); + } + + /** + * @test + */ + public function findingAssetsCanBeRestrictedToProperties(): void + { + $this->storage->expects(self::once()) + ->method('findAssets') + ->with( + self::anything(), + self::anything(), + self::callback(static fn (MetaDataPropertyNames $names) => self::valuesOf($names) === ['caption']), + self::anything(), + self::callback(static fn (MetaDataPropertyNames $names) => self::valuesOf($names) === []), + ) + ->willReturn([]); + + iterator_to_array($this->metaDataManager->findAssets(MetaDataAssetFilter::create( + propertyNames: MetaDataPropertyNames::create('caption'), + )), false); + } + + /** + * @test + */ + public function findingAssetsWithoutADimensionSpacePointUsesTheDefaultOne(): void + { + $this->storage->expects(self::once()) + ->method('findAssets') + ->with( + self::anything(), + self::anything(), + self::anything(), + self::callback(fn (MetaDataDimensionSpacePoints $chain) => self::hashesOf($chain) === [$this->en->hash]), + self::anything(), + ) + ->willReturn([]); + + iterator_to_array($this->metaDataManager->findAssets(MetaDataAssetFilter::create()), false); + } + + /** + * @test + */ + public function findingAssetsReturnsWhatTheStorageFound(): void + { + $match = MetaDataAssetReference::create('neos', 'some-asset'); + $this->storage->method('findAssets')->willReturn([$match]); + + self::assertSame([$match], iterator_to_array($this->metaDataManager->findAssets(MetaDataAssetFilter::create()), false)); + } + + /** + * @test + */ + public function findingAssetsForAnUndefinedPropertyThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionCode(1776278047); + $this->metaDataManager->findAssets(MetaDataAssetFilter::create(propertyNames: MetaDataPropertyNames::create('unknown'))); + } + + /** + * @test + */ + public function findingAssetsInAnUnconfiguredDimensionSpacePointThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionCode(1776279083); + $this->metaDataManager->findAssets(MetaDataAssetFilter::create(dimensionSpacePoint: $this->es)); + } + + // ----------------------- dimension configuration + + /** + * @test + */ + public function theDimensionSpacePointConfigurationIsPassedThrough(): void + { + self::assertSame( + [$this->en->hash, $this->de->hash, $this->fr->hash], + self::hashesOf($this->metaDataManager->getDimensionSpacePointConfiguration()), + ); + } + + /** + * @test + */ + public function resultShapeDoesNotDependOnTheDimensionConfiguration(): void + { + $this->dimensionSpacePointProvider = $this->createEmptyDimensions(); + $manager = $this->managerFor(PropertyDefinitionsFixture::default()); + $this->storageContains([]); + + self::assertSame(['copyright' => null, 'caption' => null], $manager->getMetaDataPropertyValues($this->asset)->toArray()); + } + + /** + * @test + */ + public function thePropertyDefinitionsArePassedThrough(): void + { + $definitions = PropertyDefinitionsFixture::typed(); + + self::assertSame($definitions, $this->managerFor($definitions)->getPropertyDefinitions()); + } + + // ----------------------- + + private function managerFor(MetaDataPropertyDefinitions $propertyDefinitions): MetaDataManager + { + return new MetaDataManager($this->dimensionSpacePointProvider, $propertyDefinitions, $this->storage); + } + + /** + * Declares the values the storage holds, by property name and dimension hash. Global values are + * keyed by the literal "global", as the storage does. + * + * @param array> $valuesByPropertyName + */ + private function storageContains(array $valuesByPropertyName): void + { + $this->storage->method('getMetaDataPropertyValues')->willReturnCallback( + static function (MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoints|MetaDataGlobalScope $scope) use ($valuesByPropertyName): array { + $values = $valuesByPropertyName[$propertyName->value] ?? []; + $requestedHashes = $scope instanceof MetaDataGlobalScope + ? ['global'] + : self::hashesOf($scope); + return array_intersect_key($values, array_flip($requestedHashes)); + } + ); + } + + /** + * @return list + */ + private static function hashesOf(MetaDataDimensionSpacePoints $dimensionSpacePoints): array + { + return $dimensionSpacePoints->map(static fn (MetaDataDimensionSpacePoint $dimensionSpacePoint) => $dimensionSpacePoint->hash); + } + + /** + * @return list + */ + private static function valuesOf(MetaDataPropertyNames $propertyNames): array + { + return $propertyNames->map(static fn (MetaDataPropertyName $propertyName) => $propertyName->value); + } +} diff --git a/composer.json b/composer.json index ec5b1e2..d75d29a 100644 --- a/composer.json +++ b/composer.json @@ -12,11 +12,19 @@ "suggest": { "neos/content-repository": "^8.3" }, + "require-dev": { + "phpunit/phpunit": "~9.1" + }, "autoload": { "psr-4": { "Neos\\MetaData\\": "Classes" } }, + "autoload-dev": { + "psr-4": { + "Neos\\MetaData\\Tests\\": "Tests" + } + }, "extra": { "neos": { "package-key": "Neos.MetaData"