From 516c0c31f07a6cfc99aa2f87ffcec2ad318203fb Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Fri, 31 Jul 2026 13:58:31 +0200 Subject: [PATCH 1/9] !!! FEATURE: Respect `globalScope` of metadata properties Properties declared with `globalScope: true` now have a single value that is shared by all dimensions instead of the flag being ignored. Such values are stored under the reserved dimension hash `global`, so that for a given asset and property the storage 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, which makes values of the respective other shape unreachable rather than wrong after a configuration change. The four read methods of the MetaDataManager are replaced by one, because they were three views of the same answer rather than three questions: `MetaDataPropertyValue` now carries the own value (for editing, without shine-through), the inherited value and its origin (for the translation hint) and the effective value (for rendering) side by side. This also halves the number of queries an inspector needs. Resolution rules move out of the storage: it no longer picks a winner via a MySQL specific `FIELD()` ordering but returns all values of the requested scope, leaving fallback priority to the MetaDataManager. Adds `assetmetadata:repair` to consolidate values whose scope contradicts the current configuration, plus unit and functional test suites. --- .../AssetMetaDataCommandController.php | 130 ++++++++- Classes/Domain/Dto/MetaDataGlobalScope.php | 31 +++ Classes/Domain/Dto/MetaDataPropertyValue.php | 72 +++++ Classes/Domain/Dto/MetaDataPropertyValues.php | 29 +- Classes/Helper/AssetMetaDataHelper.php | 19 +- Classes/Maintenance/MetaDataRepair.php | 208 ++++++++++++++ Classes/Maintenance/MetaDataRepairAction.php | 19 ++ .../Maintenance/MetaDataRepairActionType.php | 53 ++++ Classes/MetaDataManager.php | 168 +++++++---- Classes/Storage/MetaDataStorage.php | 23 +- .../Storage/MetaDataStorageMaintenance.php | 31 +++ .../MetaDataStorageProviderDbalAdapter.php | 97 +++++-- Classes/Storage/MetaDataStoredValue.php | 32 +++ Configuration/Objects.yaml | 3 + Readme.md | 152 +++++++--- ...MetaDataStorageProviderDbalAdapterTest.php | 200 +++++++++++++ ...ntProviderContentRepositoryAdapterTest.php | 136 +++++++++ Tests/Unit/Fixtures/DimensionsFixture.php | 79 ++++++ .../Unit/Fixtures/InMemoryMetaDataStorage.php | 125 +++++++++ .../Fixtures/PropertyDefinitionsFixture.php | 40 +++ Tests/Unit/Maintenance/MetaDataRepairTest.php | 211 ++++++++++++++ Tests/Unit/MetaDataManagerTest.php | 262 ++++++++++++++++++ composer.json | 8 + 23 files changed, 1997 insertions(+), 131 deletions(-) create mode 100644 Classes/Domain/Dto/MetaDataGlobalScope.php create mode 100644 Classes/Domain/Dto/MetaDataPropertyValue.php create mode 100644 Classes/Maintenance/MetaDataRepair.php create mode 100644 Classes/Maintenance/MetaDataRepairAction.php create mode 100644 Classes/Maintenance/MetaDataRepairActionType.php create mode 100644 Classes/Storage/MetaDataStorageMaintenance.php create mode 100644 Classes/Storage/MetaDataStoredValue.php create mode 100644 Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php create mode 100644 Tests/Unit/DimensionSpacePointProvider/DimensionSpacePointProviderContentRepositoryAdapterTest.php create mode 100644 Tests/Unit/Fixtures/DimensionsFixture.php create mode 100644 Tests/Unit/Fixtures/InMemoryMetaDataStorage.php create mode 100644 Tests/Unit/Fixtures/PropertyDefinitionsFixture.php create mode 100644 Tests/Unit/Maintenance/MetaDataRepairTest.php create mode 100644 Tests/Unit/MetaDataManagerTest.php 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/Domain/Dto/MetaDataGlobalScope.php b/Classes/Domain/Dto/MetaDataGlobalScope.php new file mode 100644 index 0000000..f926f8a --- /dev/null +++ b/Classes/Domain/Dto/MetaDataGlobalScope.php @@ -0,0 +1,31 @@ +ownValue !== null; + } + + /** + * 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..810d6f9 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,18 +18,18 @@ public function __construct( { } + /** + * The effective metadata of the given asset by property name, i.e. 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(); } /** 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 @@ +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); + $this->storage->setMetaDataPropertyValue( + $assetReference, + $propertyDefinition->name, + $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, - ): MetaDataPropertyValues { - $dimensionSpacePoint = $this->validateDimensionSpacePoint($dimensionSpacePoint); - $dimensionSpacePoints = MetaDataDimensionSpacePoints::create($dimensionSpacePoint); - - return $this->getMetaDataPropertyValuesByDimensionSpacePoints($assetReference, $dimensionSpacePoints); - } - - public function getMetaDataPropertyValuesWithFallback( MetaDataAssetReference $assetReference, ?MetaDataDimensionSpacePoint $dimensionSpacePoint = null, ): MetaDataPropertyValues { - $dimensionSpacePoint = $this->validateDimensionSpacePoint($dimensionSpacePoint); - $dimensionSpacePoints = $this->dimensionSpacePointProvider->getDimensionSpacePointChain($dimensionSpacePoint); - - return $this->getMetaDataPropertyValuesByDimensionSpacePoints($assetReference, $dimensionSpacePoints); + $propertyValues = MetaDataPropertyValues::createEmpty(); + foreach ($this->propertyDefinitions as $propertyDefinition) { + $propertyValues = $propertyValues->with( + $propertyDefinition->name, + $this->resolvePropertyValue($assetReference, $propertyDefinition, $dimensionSpacePoint), + ); + } + return $propertyValues; } - 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, convert values according to property definition + 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; + } + // The first candidate is the dimension space point that was asked for, all others are fallbacks + if ($index === 0) { + $ownValue = $storedValues[$candidate->hash]; + continue; + } + return MetaDataPropertyValue::create($ownValue, $storedValues[$candidate->hash], $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(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 +195,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..3ad428a 100644 --- a/Classes/Storage/MetaDataStorage.php +++ b/Classes/Storage/MetaDataStorage.php @@ -7,15 +7,32 @@ 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; +/** + * Persistence for metadata property values. + * + * Implementations are deliberately dumb: they store and look up values by scope and must not implement + * any resolution rules. In particular the order of the given dimension space points is meaningless to + * them – the {@see MetaDataManager} decides which of the returned values wins. + */ 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 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; } 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..9c93183 100644 --- a/Classes/Storage/MetaDataStorageProviderDbalAdapter.php +++ b/Classes/Storage/MetaDataStorageProviderDbalAdapter.php @@ -9,25 +9,33 @@ 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; -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 +43,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 +74,69 @@ 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 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; + } + + // ----------------------- + + 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. +> **Note:** `type` is parsed into the property definitions and exposed to consumers, but values are not +> converted according to it yet – they are written and read as provided. 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). + ## 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 +88,67 @@ 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', 'Ein Bild', $dimensionSpacePoint); -$values = $this->metaDataManager->getMetaDataPropertyValuesWithFallback($assetReference, $dimensionSpacePoint); +$this->metaDataManager->setMetaDataPropertyValue($assetReference, 'caption', 'Eine Katze', $german); +$values = $this->metaDataManager->getMetaDataPropertyValues($assetReference, $german); ``` -Available methods: +| 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` | + +Unknown property names and dimension space points that are not allowed by the configured preset +constraints 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: -| 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 | +| 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` | -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`. +`hasOwnValue()` and `isInherited()` are convenience predicates on top of those. + +```php +$value = $this->metaDataManager->getMetaDataPropertyValue($assetReference, 'caption', $german); + +$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' +``` + +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. ### 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} ``` `getMetaData(asset, coordinates = [])` returns an array of all configured property names mapped to -their values, resolved with dimension fallbacks. +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 +159,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 +200,43 @@ 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 implement 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`. + +`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. + +## Tests + +Unit 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' +``` + +The functional tests exercise the SQL of the storage adapter and require a MySQL or MariaDB test +database; they are skipped on other platforms. diff --git a/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php b/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php new file mode 100644 index 0000000..e750de8 --- /dev/null +++ b/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php @@ -0,0 +1,200 @@ +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))); + } +} diff --git a/Tests/Unit/DimensionSpacePointProvider/DimensionSpacePointProviderContentRepositoryAdapterTest.php b/Tests/Unit/DimensionSpacePointProvider/DimensionSpacePointProviderContentRepositoryAdapterTest.php new file mode 100644 index 0000000..ffef174 --- /dev/null +++ b/Tests/Unit/DimensionSpacePointProvider/DimensionSpacePointProviderContentRepositoryAdapterTest.php @@ -0,0 +1,136 @@ +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'); + } + + // ----------------------- + + /** + * @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/Fixtures/DimensionsFixture.php b/Tests/Unit/Fixtures/DimensionsFixture.php new file mode 100644 index 0000000..1bffc22 --- /dev/null +++ b/Tests/Unit/Fixtures/DimensionsFixture.php @@ -0,0 +1,79 @@ + $chainsByHash + */ + private function __construct( + private readonly MetaDataDimensionSpacePoint $defaultDimensionSpacePoint, + private readonly MetaDataDimensionSpacePoints $dimensionSpacePoints, + private readonly array $chainsByHash, + ) { + } + + /** + * A single "language" dimension with the fallback chains de -> en, fr -> en and en + */ + public static function languages(): self + { + $de = self::language('de'); + $en = self::language('en'); + $fr = self::language('fr'); + return new self( + $en, + MetaDataDimensionSpacePoints::create($en, $de, $fr), + [ + $en->hash => MetaDataDimensionSpacePoints::create($en), + $de->hash => MetaDataDimensionSpacePoints::create($de, $en), + $fr->hash => MetaDataDimensionSpacePoints::create($fr, $en), + ], + ); + } + + /** + * No content dimensions at all: the only valid dimension space point is the empty one + */ + public static function none(): self + { + $empty = MetaDataDimensionSpacePoint::fromCoordinates([]); + return new self($empty, MetaDataDimensionSpacePoints::create($empty), [$empty->hash => MetaDataDimensionSpacePoints::create($empty)]); + } + + public static function language(string $value): MetaDataDimensionSpacePoint + { + return MetaDataDimensionSpacePoint::fromCoordinates(['language' => $value]); + } + + public function getDimensionSpacePoints(): MetaDataDimensionSpacePoints + { + return $this->dimensionSpacePoints; + } + + public function getDefaultDimensionSpacePoint(): MetaDataDimensionSpacePoint + { + return $this->defaultDimensionSpacePoint; + } + + public function getDimensionSpacePointChain(MetaDataDimensionSpacePoint $dimensionSpacePoint): MetaDataDimensionSpacePoints + { + return $this->chainsByHash[$dimensionSpacePoint->hash] ?? MetaDataDimensionSpacePoints::create($dimensionSpacePoint); + } + + public function isDimensionSpacePointValid(MetaDataDimensionSpacePoint $dimensionSpacePoint): bool + { + return $this->dimensionSpacePoints->include($dimensionSpacePoint); + } +} diff --git a/Tests/Unit/Fixtures/InMemoryMetaDataStorage.php b/Tests/Unit/Fixtures/InMemoryMetaDataStorage.php new file mode 100644 index 0000000..57ebc32 --- /dev/null +++ b/Tests/Unit/Fixtures/InMemoryMetaDataStorage.php @@ -0,0 +1,125 @@ + + */ + private array $rows = []; + + public function setMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, string|int|bool $propertyValue, MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): void + { + $storedValue = new MetaDataStoredValue( + $assetReference, + $propertyName, + self::dimensionHash($scope), + $scope instanceof MetaDataGlobalScope, + $propertyValue, + ); + $this->rows[self::key($storedValue)] = $storedValue; + } + + public function unsetMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): void + { + unset($this->rows[implode("\0", [$assetReference->assetSourceId, $assetReference->assetId, $propertyName->value, self::dimensionHash($scope)])]); + } + + public function getMetaDataPropertyValues(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoints|MetaDataGlobalScope $scope): array + { + $dimensionHashes = $scope instanceof MetaDataGlobalScope + ? [self::GLOBAL_DIMENSION_HASH] + : $scope->map(static fn (MetaDataDimensionSpacePoint $dimensionSpacePoint) => $dimensionSpacePoint->hash); + + $values = []; + foreach ($this->rows as $row) { + if ($row->assetReference->assetSourceId !== $assetReference->assetSourceId + || $row->assetReference->assetId !== $assetReference->assetId + || !$row->propertyName->equals($propertyName->value) + || !in_array($row->dimensionHash, $dimensionHashes, true)) { + continue; + } + $values[$row->dimensionHash] = $row->value; + } + return $values; + } + + public function findAllStoredValues(): iterable + { + return array_values($this->rows); + } + + public function deleteStoredValues(MetaDataStoredValue ...$storedValues): int + { + $deleted = 0; + foreach ($storedValues as $storedValue) { + $key = self::key($storedValue); + if (array_key_exists($key, $this->rows)) { + unset($this->rows[$key]); + $deleted++; + } + } + return $deleted; + } + + /** + * Test helper: adds a value for an arbitrary dimension hash, including ones that are not (or no + * longer) configured + */ + public function addRawValue(MetaDataAssetReference $assetReference, string $propertyName, string $dimensionHash, string|int|bool $value): void + { + $storedValue = new MetaDataStoredValue( + $assetReference, + MetaDataPropertyName::fromString($propertyName), + $dimensionHash, + $dimensionHash === self::GLOBAL_DIMENSION_HASH, + $value, + ); + $this->rows[self::key($storedValue)] = $storedValue; + } + + /** + * @return list + */ + public function all(): array + { + return array_values($this->rows); + } + + // ----------------------- + + private static function dimensionHash(MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): string + { + return $scope instanceof MetaDataGlobalScope ? self::GLOBAL_DIMENSION_HASH : $scope->hash; + } + + private static function key(MetaDataStoredValue $storedValue): string + { + return implode("\0", [ + $storedValue->assetReference->assetSourceId, + $storedValue->assetReference->assetId, + $storedValue->propertyName->value, + $storedValue->dimensionHash, + ]); + } +} diff --git a/Tests/Unit/Fixtures/PropertyDefinitionsFixture.php b/Tests/Unit/Fixtures/PropertyDefinitionsFixture.php new file mode 100644 index 0000000..62d8aff --- /dev/null +++ b/Tests/Unit/Fixtures/PropertyDefinitionsFixture.php @@ -0,0 +1,40 @@ + $globalScopeByPropertyName + */ + public static function create(array $globalScopeByPropertyName): MetaDataPropertyDefinitions + { + $definitions = []; + foreach ($globalScopeByPropertyName as $propertyName => $globalScope) { + $definitions[] = new MetaDataPropertyDefinition( + MetaDataPropertyName::fromString($propertyName), + MetaDataPropertyType::string, + $globalScope, + new MetaDataPropertyUiDefinition($propertyName, MetaDataEditorDefinition::default()), + ); + } + 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]); + } +} diff --git a/Tests/Unit/Maintenance/MetaDataRepairTest.php b/Tests/Unit/Maintenance/MetaDataRepairTest.php new file mode 100644 index 0000000..8311372 --- /dev/null +++ b/Tests/Unit/Maintenance/MetaDataRepairTest.php @@ -0,0 +1,211 @@ +storage = new InMemoryMetaDataStorage(); + $this->metaDataManager = new MetaDataManager($dimensions, PropertyDefinitionsFixture::default(), $this->storage); + $this->metaDataRepair = new MetaDataRepair($this->metaDataManager, $dimensions, $this->storage); + $this->asset = MetaDataAssetReference::create('neos', 'some-asset'); + $this->de = DimensionsFixture::language('de'); + $this->en = DimensionsFixture::language('en'); + $this->fr = DimensionsFixture::language('fr'); + } + + /** + * @test + */ + public function consistentDataNeedsNoRepair(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme'); + + self::assertSame([], $this->metaDataRepair->analyze()); + } + + /** + * @test + */ + public function localizedValuesOfAGlobalPropertyAreConsolidatedIntoTheDefaultChainWinner(): void + { + $this->storage->addRawValue($this->asset, 'copyright', $this->de->hash, '© Acme'); + $this->storage->addRawValue($this->asset, 'copyright', $this->en->hash, '© 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)); + + $this->metaDataRepair->apply($actions); + self::assertSame('© Acme Inc', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright')->value); + self::assertCount(1, $this->storage->all()); + } + + /** + * @test + */ + public function theOnlyLocalizedValueOfAGlobalPropertyIsKeptEvenIfItIsNotOnTheDefaultChain(): void + { + $this->storage->addRawValue($this->asset, 'copyright', $this->fr->hash, '© Foto Meier'); + + $this->metaDataRepair->apply($this->metaDataRepair->analyze()); + + self::assertSame('© Foto Meier', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright')->value); + self::assertCount(1, $this->storage->all()); + } + + /** + * @test + */ + public function anExistingSharedValueWinsOverStaleLocalizedOnes(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Current'); + $this->storage->addRawValue($this->asset, 'copyright', $this->en->hash, '© Stale'); + + $actions = $this->metaDataRepair->analyze(); + self::assertSame([], self::actionsOfType($actions, MetaDataRepairActionType::promoteToGlobalScope), 'live data must not be overwritten'); + + $this->metaDataRepair->apply($actions); + self::assertSame('© Current', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright')->value); + self::assertCount(1, $this->storage->all()); + } + + /** + * @test + */ + public function aSharedValueOfALocalizedPropertyIsPromotedToTheDefaultDimension(): void + { + $this->storage->addRawValue($this->asset, 'caption', 'global', 'A cat'); + + $actions = $this->metaDataRepair->analyze(); + self::assertCount(1, self::actionsOfType($actions, MetaDataRepairActionType::promoteToDefaultDimension)); + + $this->metaDataRepair->apply($actions); + self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->en)->ownValue); + self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de)->inheritedValue); + self::assertCount(1, $this->storage->all()); + } + + /** + * @test + */ + public function aSharedValueIsNotPromotedIfTheDefaultDimensionAlreadyHasAValue(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + $this->storage->addRawValue($this->asset, 'caption', 'global', 'Stale'); + + $actions = $this->metaDataRepair->analyze(); + self::assertSame([], self::actionsOfType($actions, MetaDataRepairActionType::promoteToDefaultDimension)); + + $this->metaDataRepair->apply($actions); + self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->en)->value); + self::assertCount(1, $this->storage->all()); + } + + /** + * @test + */ + public function valuesOfUnconfiguredDimensionsAreOnlyRemovedWhenPruning(): void + { + $this->storage->addRawValue($this->asset, 'caption', DimensionsFixture::language('es')->hash, 'Un gato'); + + $actions = $this->metaDataRepair->analyze(); + self::assertCount(1, self::actionsOfType($actions, MetaDataRepairActionType::deleteObsoleteDimension)); + + self::assertSame(0, $this->metaDataRepair->apply($actions)); + self::assertCount(1, $this->storage->all()); + + self::assertSame(1, $this->metaDataRepair->apply($actions, prune: true)); + self::assertSame([], $this->storage->all()); + } + + /** + * @test + */ + public function valuesOfUndefinedPropertiesAreOnlyRemovedWhenPruning(): void + { + $this->storage->addRawValue($this->asset, 'formerProperty', $this->en->hash, '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([], $this->storage->all()); + } + + /** + * @test + */ + public function valuesOfOtherAssetsAreNotAffected(): void + { + $otherAsset = MetaDataAssetReference::create('neos', 'other-asset'); + $this->storage->addRawValue($this->asset, 'copyright', $this->en->hash, '© Acme'); + $this->metaDataManager->setMetaDataPropertyValue($otherAsset, 'caption', 'A cat', $this->en); + + $this->metaDataRepair->apply($this->metaDataRepair->analyze()); + + self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($otherAsset, 'caption', $this->en)->value); + } + + /** + * @test + */ + public function pruningIsRefusedWithoutConfiguredDimensions(): void + { + $dimensions = DimensionsFixture::none(); + $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()); + } + + // ----------------------- + + /** + * @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..79bf3a3 --- /dev/null +++ b/Tests/Unit/MetaDataManagerTest.php @@ -0,0 +1,262 @@ +storage = new InMemoryMetaDataStorage(); + $this->metaDataManager = new MetaDataManager( + DimensionsFixture::languages(), + PropertyDefinitionsFixture::default(), + $this->storage, + ); + $this->asset = MetaDataAssetReference::create('neos', 'some-asset'); + $this->de = DimensionsFixture::language('de'); + $this->en = DimensionsFixture::language('en'); + $this->fr = DimensionsFixture::language('fr'); + } + + /** + * @test + */ + public function localizedValueWithoutFallbackIsItsOwnValue(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de); + + $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->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + + $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->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de); + + $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 valuesOfUnrelatedDimensionsAreNotInherited(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Un chat', $this->fr); + + $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 + { + $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->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat'); + + self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->en)->ownValue); + self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption')->ownValue); + } + + /** + * @test + */ + public function globalValueIsSharedByAllDimensions(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->de); + + 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->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->en); + + $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 globalValueIsStoredOnlyOnce(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->de); + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme Inc', $this->fr); + + self::assertCount(1, $this->storage->all()); + self::assertSame('© Acme Inc', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright', $this->en)->value); + } + + /** + * @test + */ + public function unsettingAGlobalValueIgnoresTheDimensionSpacePoint(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->en); + $this->metaDataManager->unsetMetaDataPropertyValue($this->asset, 'copyright', $this->de); + + self::assertSame([], $this->storage->all()); + } + + /** + * @test + */ + public function unsettingALocalizedValueOnlyAffectsItsDimension(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de); + $this->metaDataManager->unsetMetaDataPropertyValue($this->asset, 'caption', $this->de); + + $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de); + self::assertNull($value->ownValue); + self::assertSame('A cat', $value->inheritedValue); + } + + /** + * @test + */ + public function allDefinedPropertiesArePresentInTheResult(): void + { + $values = $this->metaDataManager->getMetaDataPropertyValues($this->asset, $this->de); + + self::assertSame(['copyright' => null, 'caption' => null], $values->toArray()); + } + + /** + * @test + */ + public function resultShapeDoesNotDependOnTheDimensionConfiguration(): void + { + $metaDataManager = new MetaDataManager(DimensionsFixture::none(), PropertyDefinitionsFixture::default(), new InMemoryMetaDataStorage()); + + self::assertSame(['copyright' => null, 'caption' => null], $metaDataManager->getMetaDataPropertyValues($this->asset)->toArray()); + } + + /** + * @test + */ + public function readingAnUndefinedPropertyThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionCode(1776278047); + $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'unknown', $this->de); + } + + /** + * @test + */ + public function writingAnUnconfiguredDimensionSpacePointThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionCode(1776279083); + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Hola', DimensionsFixture::language('es')); + } + + /** + * The dimension space point is ignored for global properties, so it is not validated either + * + * @test + */ + public function writingAGlobalValueAcceptsAnyDimensionSpacePoint(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', DimensionsFixture::language('es')); + + self::assertSame('© Acme', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright')->value); + } + + /** + * @test + */ + public function valuesOfOtherAssetsAreNotReturned(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + $otherAsset = MetaDataAssetReference::create('neos', 'other-asset'); + + self::assertNull($this->metaDataManager->getMetaDataPropertyValue($otherAsset, 'caption', $this->en)->value); + } + + /** + * @test + */ + public function valuesOfOtherAssetSourcesAreNotReturned(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + $sameAssetInAnotherSource = MetaDataAssetReference::create('other-source', 'some-asset'); + + self::assertNull($this->metaDataManager->getMetaDataPropertyValue($sameAssetInAnotherSource, 'caption', $this->en)->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" From 85ac24236cea0545ed58f3dc8645133064615cfc Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Fri, 31 Jul 2026 14:17:45 +0200 Subject: [PATCH 2/9] TASK: Cover enumeration of dimension space points by preset value Adds regression tests for 7d4c8f1: a configuration whose preset identifiers differ from the primary values of those presets, asserting that dimension space points are enumerated by value, that every enumerated point is considered valid and includes the default one, and that presets without values are skipped. All three fail when 7d4c8f1 is reverted. --- ...ntProviderContentRepositoryAdapterTest.php | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/Tests/Unit/DimensionSpacePointProvider/DimensionSpacePointProviderContentRepositoryAdapterTest.php b/Tests/Unit/DimensionSpacePointProvider/DimensionSpacePointProviderContentRepositoryAdapterTest.php index ffef174..b4f04df 100644 --- a/Tests/Unit/DimensionSpacePointProvider/DimensionSpacePointProviderContentRepositoryAdapterTest.php +++ b/Tests/Unit/DimensionSpacePointProvider/DimensionSpacePointProviderContentRepositoryAdapterTest.php @@ -110,6 +110,63 @@ public function unknownDimensionValuesAreNotValid(): void 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())); + } + // ----------------------- /** From ec3cdf21076e589984acd6bbbbccf2030e34f671 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Mon, 3 Aug 2026 16:57:24 +0200 Subject: [PATCH 3/9] !!! FEATURE: Allow assets to be found by a metadata filter Adds `MetaDataManager::findAssets()`, returning the references of all assets that have a matching metadata value. The criteria of a `MetaDataAssetFilter` are all optional: asset source id, dimension space point, search term and the properties to search in. 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: 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. As everywhere else in this package, an omitted dimension space point means the *default* one rather than "any dimension" - a search is always carried out as seen from one dimension space point. Resolving the precedence per asset in PHP would mean a query per candidate, so it happens in SQL: 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. The "order is meaningless" rule therefore moves from the interface docblock onto the method it actually describes. !!! `MetaDataStorage` gained a method, so third party implementations of that interface have to be extended. --- Classes/Domain/Dto/MetaDataAssetFilter.php | 58 ++ Classes/Domain/Dto/MetaDataPropertyNames.php | 77 +++ Classes/MetaDataManager.php | 57 ++ Classes/Storage/MetaDataStorage.php | 45 +- .../MetaDataStorageProviderDbalAdapter.php | 103 ++++ Readme.md | 71 ++- Tests/Functional/AbstractMetaDataTestCase.php | 82 +++ .../Fixtures/DimensionsFixture.php | 2 +- .../Fixtures/PropertyDefinitionsFixture.php | 2 +- .../Maintenance/MetaDataRepairTest.php | 48 +- Tests/Functional/MetaDataManagerTest.php | 511 ++++++++++++++++++ ...MetaDataStorageProviderDbalAdapterTest.php | 74 +-- .../Unit/Fixtures/InMemoryMetaDataStorage.php | 125 ----- Tests/Unit/MetaDataManagerTest.php | 262 --------- 14 files changed, 1058 insertions(+), 459 deletions(-) create mode 100644 Classes/Domain/Dto/MetaDataAssetFilter.php create mode 100644 Classes/Domain/Dto/MetaDataPropertyNames.php create mode 100644 Tests/Functional/AbstractMetaDataTestCase.php rename Tests/{Unit => Functional}/Fixtures/DimensionsFixture.php (98%) rename Tests/{Unit => Functional}/Fixtures/PropertyDefinitionsFixture.php (96%) rename Tests/{Unit => Functional}/Maintenance/MetaDataRepairTest.php (80%) create mode 100644 Tests/Functional/MetaDataManagerTest.php delete mode 100644 Tests/Unit/Fixtures/InMemoryMetaDataStorage.php delete mode 100644 Tests/Unit/MetaDataManagerTest.php 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/MetaDataManager.php b/Classes/MetaDataManager.php index aab8b43..68025a0 100644 --- a/Classes/MetaDataManager.php +++ b/Classes/MetaDataManager.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use Neos\MetaData\DimensionSpacePointProvider\DimensionSpacePointProvider; +use Neos\MetaData\Domain\Dto\MetaDataAssetFilter; use Neos\MetaData\Domain\Dto\MetaDataAssetReference; use Neos\MetaData\Domain\Dto\MetaDataDimensionSpacePoint; use Neos\MetaData\Domain\Dto\MetaDataDimensionSpacePoints; @@ -13,6 +14,7 @@ use Neos\MetaData\Domain\Dto\MetaDataPropertyDefinition; use Neos\MetaData\Domain\Dto\MetaDataPropertyDefinitions; use Neos\MetaData\Domain\Dto\MetaDataPropertyName; +use Neos\MetaData\Domain\Dto\MetaDataPropertyNames; use Neos\MetaData\Domain\Dto\MetaDataPropertyValue; use Neos\MetaData\Domain\Dto\MetaDataPropertyValues; use Neos\MetaData\Storage\MetaDataStorage; @@ -115,8 +117,63 @@ public function getMetaDataPropertyValues( 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->storage->findAssets( + $filter->assetSourceId, + $filter->searchTerm, + MetaDataPropertyNames::create(...$localizedPropertyNames), + $this->dimensionSpacePointProvider->getDimensionSpacePointChain( + $this->validateDimensionSpacePoint($filter->dimensionSpacePoint) + ), + MetaDataPropertyNames::create(...$globalScopePropertyNames), + ); + } + // ----------------------- + /** + * 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)); + } + /** * Resolves the own and the inherited value of a single property with one storage lookup */ diff --git a/Classes/Storage/MetaDataStorage.php b/Classes/Storage/MetaDataStorage.php index 3ad428a..25e8655 100644 --- a/Classes/Storage/MetaDataStorage.php +++ b/Classes/Storage/MetaDataStorage.php @@ -9,13 +9,14 @@ 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 store and look up values by scope and must not implement - * any resolution rules. In particular the order of the given dimension space points is meaningless to - * them – the {@see MetaDataManager} decides which of the returned values wins. + * 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 { @@ -27,6 +28,9 @@ public function unsetMetaDataPropertyValue(MetaDataAssetReference $assetReferenc /** * 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. @@ -35,4 +39,39 @@ public function unsetMetaDataPropertyValue(MetaDataAssetReference $assetReferenc */ 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/MetaDataStorageProviderDbalAdapter.php b/Classes/Storage/MetaDataStorageProviderDbalAdapter.php index 9c93183..1595554 100644 --- a/Classes/Storage/MetaDataStorageProviderDbalAdapter.php +++ b/Classes/Storage/MetaDataStorageProviderDbalAdapter.php @@ -11,6 +11,7 @@ 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, MetaDataStorageMaintenance { @@ -92,6 +93,67 @@ public function getMetaDataPropertyValues(MetaDataAssetReference $assetReference 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(); @@ -124,6 +186,47 @@ public function deleteStoredValues(MetaDataStoredValue ...$storedValues): int // ----------------------- + /** + * 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; diff --git a/Readme.md b/Readme.md index c72bc48..24e596c 100644 --- a/Readme.md +++ b/Readme.md @@ -102,6 +102,7 @@ $values = $this->metaDataManager->getMetaDataPropertyValues($assetReference, $ge | `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 and dimension space points that are not allowed by the configured preset constraints lead to an `InvalidArgumentException`. @@ -133,6 +134,51 @@ For properties with a **global scope** the value is shared by all dimensions, so 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. +### Finding assets + +`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 and returns the *effective* @@ -200,9 +246,13 @@ 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` | -Storage implementations are deliberately dumb: they look values up by scope and must not implement any +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`. +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 @@ -229,14 +279,25 @@ Reading a localized property looks up the whole fallback chain in one query. The 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 -Unit tests are part of the regular Flow test suites: +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' ``` -The functional tests exercise the SQL of the storage adapter and require a MySQL or MariaDB test -database; they are skipped on other platforms. +Everything that touches stored values is tested functionally, against the real storage adapter rather +than an in-memory double: resolving a value is spread across the manager and SQL, so a second +implementation would only ever approximate it – `utf8mb4_unicode_ci` folds case and accents in ways +that PHP string functions do not. Those tests therefore require a MySQL or MariaDB test database and +are skipped on other platforms. Only `DimensionSpacePointProviderContentRepositoryAdapterTest`, which +needs no storage at all, is a unit test. diff --git a/Tests/Functional/AbstractMetaDataTestCase.php b/Tests/Functional/AbstractMetaDataTestCase.php new file mode 100644 index 0000000..f2548a1 --- /dev/null +++ b/Tests/Functional/AbstractMetaDataTestCase.php @@ -0,0 +1,82 @@ +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); + } + + public function tearDown(): void + { + $this->connection->executeStatement('DELETE FROM neos_metadata_value'); + parent::tearDown(); + } + + /** + * Writes a row for an arbitrary dimension hash, including ones that are not (or no longer) + * configured and ones that contradict the scope a property is defined for + */ + final protected 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 + */ + final protected function storedValues(): array + { + return iterator_to_array($this->storage->findAllStoredValues(), false); + } +} diff --git a/Tests/Unit/Fixtures/DimensionsFixture.php b/Tests/Functional/Fixtures/DimensionsFixture.php similarity index 98% rename from Tests/Unit/Fixtures/DimensionsFixture.php rename to Tests/Functional/Fixtures/DimensionsFixture.php index 1bffc22..7aae3de 100644 --- a/Tests/Unit/Fixtures/DimensionsFixture.php +++ b/Tests/Functional/Fixtures/DimensionsFixture.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Neos\MetaData\Tests\Unit\Fixtures; +namespace Neos\MetaData\Tests\Functional\Fixtures; use Neos\MetaData\DimensionSpacePointProvider\DimensionSpacePointProvider; use Neos\MetaData\Domain\Dto\MetaDataDimensionSpacePoint; diff --git a/Tests/Unit/Fixtures/PropertyDefinitionsFixture.php b/Tests/Functional/Fixtures/PropertyDefinitionsFixture.php similarity index 96% rename from Tests/Unit/Fixtures/PropertyDefinitionsFixture.php rename to Tests/Functional/Fixtures/PropertyDefinitionsFixture.php index 62d8aff..ff57bd1 100644 --- a/Tests/Unit/Fixtures/PropertyDefinitionsFixture.php +++ b/Tests/Functional/Fixtures/PropertyDefinitionsFixture.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Neos\MetaData\Tests\Unit\Fixtures; +namespace Neos\MetaData\Tests\Functional\Fixtures; use Neos\MetaData\Domain\Dto\MetaDataEditorDefinition; use Neos\MetaData\Domain\Dto\MetaDataPropertyDefinition; diff --git a/Tests/Unit/Maintenance/MetaDataRepairTest.php b/Tests/Functional/Maintenance/MetaDataRepairTest.php similarity index 80% rename from Tests/Unit/Maintenance/MetaDataRepairTest.php rename to Tests/Functional/Maintenance/MetaDataRepairTest.php index 8311372..541b15d 100644 --- a/Tests/Unit/Maintenance/MetaDataRepairTest.php +++ b/Tests/Functional/Maintenance/MetaDataRepairTest.php @@ -2,22 +2,20 @@ declare(strict_types=1); -namespace Neos\MetaData\Tests\Unit\Maintenance; +namespace Neos\MetaData\Tests\Functional\Maintenance; -use Neos\Flow\Tests\UnitTestCase; use Neos\MetaData\Domain\Dto\MetaDataAssetReference; use Neos\MetaData\Domain\Dto\MetaDataDimensionSpacePoint; use Neos\MetaData\Maintenance\MetaDataRepair; use Neos\MetaData\Maintenance\MetaDataRepairAction; use Neos\MetaData\Maintenance\MetaDataRepairActionType; use Neos\MetaData\MetaDataManager; -use Neos\MetaData\Tests\Unit\Fixtures\DimensionsFixture; -use Neos\MetaData\Tests\Unit\Fixtures\InMemoryMetaDataStorage; -use Neos\MetaData\Tests\Unit\Fixtures\PropertyDefinitionsFixture; +use Neos\MetaData\Tests\Functional\AbstractMetaDataTestCase; +use Neos\MetaData\Tests\Functional\Fixtures\DimensionsFixture; +use Neos\MetaData\Tests\Functional\Fixtures\PropertyDefinitionsFixture; -class MetaDataRepairTest extends UnitTestCase +class MetaDataRepairTest extends AbstractMetaDataTestCase { - private InMemoryMetaDataStorage $storage; private MetaDataManager $metaDataManager; private MetaDataRepair $metaDataRepair; private MetaDataAssetReference $asset; @@ -27,8 +25,8 @@ class MetaDataRepairTest extends UnitTestCase public function setUp(): void { + parent::setUp(); $dimensions = DimensionsFixture::languages(); - $this->storage = new InMemoryMetaDataStorage(); $this->metaDataManager = new MetaDataManager($dimensions, PropertyDefinitionsFixture::default(), $this->storage); $this->metaDataRepair = new MetaDataRepair($this->metaDataManager, $dimensions, $this->storage); $this->asset = MetaDataAssetReference::create('neos', 'some-asset'); @@ -53,8 +51,8 @@ public function consistentDataNeedsNoRepair(): void */ public function localizedValuesOfAGlobalPropertyAreConsolidatedIntoTheDefaultChainWinner(): void { - $this->storage->addRawValue($this->asset, 'copyright', $this->de->hash, '© Acme'); - $this->storage->addRawValue($this->asset, 'copyright', $this->en->hash, '© Acme Inc'); + $this->addRawValue($this->asset, 'copyright', $this->de->hash, '© Acme'); + $this->addRawValue($this->asset, 'copyright', $this->en->hash, '© Acme Inc'); $actions = $this->metaDataRepair->analyze(); $promotions = self::actionsOfType($actions, MetaDataRepairActionType::promoteToGlobalScope); @@ -64,7 +62,7 @@ public function localizedValuesOfAGlobalPropertyAreConsolidatedIntoTheDefaultCha $this->metaDataRepair->apply($actions); self::assertSame('© Acme Inc', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright')->value); - self::assertCount(1, $this->storage->all()); + self::assertCount(1, $this->storedValues()); } /** @@ -72,12 +70,12 @@ public function localizedValuesOfAGlobalPropertyAreConsolidatedIntoTheDefaultCha */ public function theOnlyLocalizedValueOfAGlobalPropertyIsKeptEvenIfItIsNotOnTheDefaultChain(): void { - $this->storage->addRawValue($this->asset, 'copyright', $this->fr->hash, '© Foto Meier'); + $this->addRawValue($this->asset, 'copyright', $this->fr->hash, '© Foto Meier'); $this->metaDataRepair->apply($this->metaDataRepair->analyze()); self::assertSame('© Foto Meier', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright')->value); - self::assertCount(1, $this->storage->all()); + self::assertCount(1, $this->storedValues()); } /** @@ -86,14 +84,14 @@ public function theOnlyLocalizedValueOfAGlobalPropertyIsKeptEvenIfItIsNotOnTheDe public function anExistingSharedValueWinsOverStaleLocalizedOnes(): void { $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Current'); - $this->storage->addRawValue($this->asset, 'copyright', $this->en->hash, '© Stale'); + $this->addRawValue($this->asset, 'copyright', $this->en->hash, '© Stale'); $actions = $this->metaDataRepair->analyze(); self::assertSame([], self::actionsOfType($actions, MetaDataRepairActionType::promoteToGlobalScope), 'live data must not be overwritten'); $this->metaDataRepair->apply($actions); self::assertSame('© Current', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright')->value); - self::assertCount(1, $this->storage->all()); + self::assertCount(1, $this->storedValues()); } /** @@ -101,7 +99,7 @@ public function anExistingSharedValueWinsOverStaleLocalizedOnes(): void */ public function aSharedValueOfALocalizedPropertyIsPromotedToTheDefaultDimension(): void { - $this->storage->addRawValue($this->asset, 'caption', 'global', 'A cat'); + $this->addRawValue($this->asset, 'caption', 'global', 'A cat'); $actions = $this->metaDataRepair->analyze(); self::assertCount(1, self::actionsOfType($actions, MetaDataRepairActionType::promoteToDefaultDimension)); @@ -109,7 +107,7 @@ public function aSharedValueOfALocalizedPropertyIsPromotedToTheDefaultDimension( $this->metaDataRepair->apply($actions); self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->en)->ownValue); self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de)->inheritedValue); - self::assertCount(1, $this->storage->all()); + self::assertCount(1, $this->storedValues()); } /** @@ -118,14 +116,14 @@ public function aSharedValueOfALocalizedPropertyIsPromotedToTheDefaultDimension( public function aSharedValueIsNotPromotedIfTheDefaultDimensionAlreadyHasAValue(): void { $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $this->storage->addRawValue($this->asset, 'caption', 'global', 'Stale'); + $this->addRawValue($this->asset, 'caption', 'global', 'Stale'); $actions = $this->metaDataRepair->analyze(); self::assertSame([], self::actionsOfType($actions, MetaDataRepairActionType::promoteToDefaultDimension)); $this->metaDataRepair->apply($actions); self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->en)->value); - self::assertCount(1, $this->storage->all()); + self::assertCount(1, $this->storedValues()); } /** @@ -133,16 +131,16 @@ public function aSharedValueIsNotPromotedIfTheDefaultDimensionAlreadyHasAValue() */ public function valuesOfUnconfiguredDimensionsAreOnlyRemovedWhenPruning(): void { - $this->storage->addRawValue($this->asset, 'caption', DimensionsFixture::language('es')->hash, 'Un gato'); + $this->addRawValue($this->asset, 'caption', DimensionsFixture::language('es')->hash, 'Un gato'); $actions = $this->metaDataRepair->analyze(); self::assertCount(1, self::actionsOfType($actions, MetaDataRepairActionType::deleteObsoleteDimension)); self::assertSame(0, $this->metaDataRepair->apply($actions)); - self::assertCount(1, $this->storage->all()); + self::assertCount(1, $this->storedValues()); self::assertSame(1, $this->metaDataRepair->apply($actions, prune: true)); - self::assertSame([], $this->storage->all()); + self::assertSame([], $this->storedValues()); } /** @@ -150,14 +148,14 @@ public function valuesOfUnconfiguredDimensionsAreOnlyRemovedWhenPruning(): void */ public function valuesOfUndefinedPropertiesAreOnlyRemovedWhenPruning(): void { - $this->storage->addRawValue($this->asset, 'formerProperty', $this->en->hash, 'obsolete'); + $this->addRawValue($this->asset, 'formerProperty', $this->en->hash, '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([], $this->storage->all()); + self::assertSame([], $this->storedValues()); } /** @@ -166,7 +164,7 @@ public function valuesOfUndefinedPropertiesAreOnlyRemovedWhenPruning(): void public function valuesOfOtherAssetsAreNotAffected(): void { $otherAsset = MetaDataAssetReference::create('neos', 'other-asset'); - $this->storage->addRawValue($this->asset, 'copyright', $this->en->hash, '© Acme'); + $this->addRawValue($this->asset, 'copyright', $this->en->hash, '© Acme'); $this->metaDataManager->setMetaDataPropertyValue($otherAsset, 'caption', 'A cat', $this->en); $this->metaDataRepair->apply($this->metaDataRepair->analyze()); diff --git a/Tests/Functional/MetaDataManagerTest.php b/Tests/Functional/MetaDataManagerTest.php new file mode 100644 index 0000000..107fa3b --- /dev/null +++ b/Tests/Functional/MetaDataManagerTest.php @@ -0,0 +1,511 @@ +metaDataManager = new MetaDataManager( + DimensionsFixture::languages(), + PropertyDefinitionsFixture::default(), + $this->storage, + ); + $this->asset = MetaDataAssetReference::create('neos', 'some-asset'); + $this->de = DimensionsFixture::language('de'); + $this->en = DimensionsFixture::language('en'); + $this->fr = DimensionsFixture::language('fr'); + } + + /** + * @test + */ + public function localizedValueWithoutFallbackIsItsOwnValue(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de); + + $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->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + + $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->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de); + + $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 valuesOfUnrelatedDimensionsAreNotInherited(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Un chat', $this->fr); + + $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 + { + $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->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat'); + + self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->en)->ownValue); + self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption')->ownValue); + } + + /** + * @test + */ + public function globalValueIsSharedByAllDimensions(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->de); + + 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->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->en); + + $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 globalValueIsStoredOnlyOnce(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->de); + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme Inc', $this->fr); + + self::assertCount(1, $this->storedValues()); + self::assertSame('© Acme Inc', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright', $this->en)->value); + } + + /** + * @test + */ + public function unsettingAGlobalValueIgnoresTheDimensionSpacePoint(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->en); + $this->metaDataManager->unsetMetaDataPropertyValue($this->asset, 'copyright', $this->de); + + self::assertSame([], $this->storedValues()); + } + + /** + * @test + */ + public function unsettingALocalizedValueOnlyAffectsItsDimension(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de); + $this->metaDataManager->unsetMetaDataPropertyValue($this->asset, 'caption', $this->de); + + $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de); + self::assertNull($value->ownValue); + self::assertSame('A cat', $value->inheritedValue); + } + + /** + * @test + */ + public function allDefinedPropertiesArePresentInTheResult(): void + { + $values = $this->metaDataManager->getMetaDataPropertyValues($this->asset, $this->de); + + self::assertSame(['copyright' => null, 'caption' => null], $values->toArray()); + } + + /** + * @test + */ + public function resultShapeDoesNotDependOnTheDimensionConfiguration(): void + { + $metaDataManager = new MetaDataManager(DimensionsFixture::none(), PropertyDefinitionsFixture::default(), $this->storage); + + self::assertSame(['copyright' => null, 'caption' => null], $metaDataManager->getMetaDataPropertyValues($this->asset)->toArray()); + } + + /** + * @test + */ + public function readingAnUndefinedPropertyThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionCode(1776278047); + $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'unknown', $this->de); + } + + /** + * @test + */ + public function writingAnUnconfiguredDimensionSpacePointThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionCode(1776279083); + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Hola', DimensionsFixture::language('es')); + } + + /** + * The dimension space point is ignored for global properties, so it is not validated either + * + * @test + */ + public function writingAGlobalValueAcceptsAnyDimensionSpacePoint(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', DimensionsFixture::language('es')); + + self::assertSame('© Acme', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright')->value); + } + + /** + * @test + */ + public function valuesOfOtherAssetsAreNotReturned(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + $otherAsset = MetaDataAssetReference::create('neos', 'other-asset'); + + self::assertNull($this->metaDataManager->getMetaDataPropertyValue($otherAsset, 'caption', $this->en)->value); + } + + /** + * @test + */ + public function valuesOfOtherAssetSourcesAreNotReturned(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + $sameAssetInAnotherSource = MetaDataAssetReference::create('other-source', 'some-asset'); + + self::assertNull($this->metaDataManager->getMetaDataPropertyValue($sameAssetInAnotherSource, 'caption', $this->en)->value); + } + + /** + * @test + */ + public function assetsAreFoundByASearchTermInAnyProperty(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + $other = MetaDataAssetReference::create('neos', 'other-asset'); + $this->metaDataManager->setMetaDataPropertyValue($other, 'copyright', '© Cat Photos', $this->en); + + self::assertSame( + ['neos:other-asset', 'neos:some-asset'], + $this->find(MetaDataAssetFilter::create(searchTerm: 'cat')), + 'the global scope property matches as well', + ); + } + + /** + * @test + */ + public function theSearchTermMatchesAnywhereInAValueAndIgnoresCase(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A CATalogue picture', $this->en); + + self::assertSame(['neos:some-asset'], $this->find(MetaDataAssetFilter::create(searchTerm: 'cat'))); + } + + /** + * @test + */ + public function inheritedValuesAreFound(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + + self::assertSame( + ['neos:some-asset'], + $this->find(MetaDataAssetFilter::create(dimensionSpacePoint: $this->de, searchTerm: 'cat')), + 'German inherits the English caption, so it is what an editor working in German sees', + ); + } + + /** + * @test + */ + public function shadowedValuesAreNotFound(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de); + + self::assertSame( + [], + $this->find(MetaDataAssetFilter::create(dimensionSpacePoint: $this->de, searchTerm: 'cat')), + 'the German value overrides the English one, so "cat" is not what German resolves to', + ); + self::assertSame(['neos:some-asset'], $this->find(MetaDataAssetFilter::create(dimensionSpacePoint: $this->en, searchTerm: 'cat'))); + } + + /** + * @test + */ + public function valuesOfUnrelatedDimensionsAreNotFound(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Un chat', $this->fr); + + self::assertSame([], $this->find(MetaDataAssetFilter::create(dimensionSpacePoint: $this->de, searchTerm: 'chat'))); + } + + /** + * @test + */ + public function globalValuesAreFoundRegardlessOfTheDimensionSpacePoint(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme'); + + foreach ([$this->de, $this->en, $this->fr, null] as $dimensionSpacePoint) { + self::assertSame( + ['neos:some-asset'], + $this->find(MetaDataAssetFilter::create(dimensionSpacePoint: $dimensionSpacePoint, searchTerm: 'acme')), + ); + } + } + + /** + * @test + */ + public function anOmittedDimensionSpacePointRefersToTheDefaultOne(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de); + + self::assertSame([], $this->find(MetaDataAssetFilter::create(searchTerm: 'Katze')), 'the default dimension is English'); + self::assertSame(['neos:some-asset'], $this->find(MetaDataAssetFilter::create(dimensionSpacePoint: $this->de, searchTerm: 'Katze'))); + } + + /** + * @test + */ + public function anAssetMatchingSeveralTimesIsReturnedOnce(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Cat Photos'); + + self::assertSame(['neos:some-asset'], $this->find(MetaDataAssetFilter::create(searchTerm: 'cat'))); + } + + /** + * @test + */ + public function theSearchCanBeRestrictedToProperties(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + $other = MetaDataAssetReference::create('neos', 'other-asset'); + $this->metaDataManager->setMetaDataPropertyValue($other, 'copyright', '© Cat Photos'); + + self::assertSame( + ['neos:some-asset'], + $this->find(MetaDataAssetFilter::create(searchTerm: 'cat', propertyNames: MetaDataPropertyNames::create('caption'))), + ); + self::assertSame( + ['neos:other-asset'], + $this->find(MetaDataAssetFilter::create(searchTerm: 'cat', propertyNames: MetaDataPropertyNames::create('copyright'))), + ); + } + + /** + * @test + */ + public function theSearchCanBeRestrictedToAnAssetSource(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + $sameAssetInAnotherSource = MetaDataAssetReference::create('other-source', 'some-asset'); + $this->metaDataManager->setMetaDataPropertyValue($sameAssetInAnotherSource, 'caption', 'A cat', $this->en); + + self::assertSame(['other-source:some-asset'], $this->find(MetaDataAssetFilter::create(assetSourceId: 'other-source', searchTerm: 'cat'))); + } + + /** + * @test + */ + public function anOmittedSearchTermMatchesEveryAssetWithAValue(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + $other = MetaDataAssetReference::create('neos', 'other-asset'); + $this->metaDataManager->setMetaDataPropertyValue($other, 'copyright', '© Acme'); + + self::assertSame(['neos:other-asset', 'neos:some-asset'], $this->find(MetaDataAssetFilter::create())); + self::assertSame( + ['neos:some-asset'], + $this->find(MetaDataAssetFilter::create(propertyNames: MetaDataPropertyNames::create('caption'))), + 'which assets have a caption at all', + ); + } + + /** + * @test + */ + public function anEmptySearchTermIsTreatedLikeAnOmittedOne(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + + self::assertSame(['neos:some-asset'], $this->find(MetaDataAssetFilter::create(searchTerm: ' '))); + } + + /** + * @test + */ + public function theSearchTermIsTrimmed(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + + self::assertSame(['neos:some-asset'], $this->find(MetaDataAssetFilter::create(searchTerm: ' cat '))); + } + + /** + * @test + */ + public function likeWildcardsInTheSearchTermAreEscaped(): void + { + $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + $discounted = MetaDataAssetReference::create('neos', 'discounted'); + $this->metaDataManager->setMetaDataPropertyValue($discounted, 'caption', 'Reduced by 50%', $this->en); + + self::assertSame(['neos:discounted'], $this->find(MetaDataAssetFilter::create(searchTerm: '50%'))); + self::assertSame([], $this->find(MetaDataAssetFilter::create(searchTerm: 'c_t'))); + self::assertSame([], $this->find(MetaDataAssetFilter::create(searchTerm: '\\'))); + } + + /** + * @test + */ + public function valuesOfAScopeThatContradictsTheConfigurationAreNotFound(): void + { + $this->addRawValue($this->asset, 'copyright', $this->en->hash, '© Stale'); + $this->addRawValue($this->asset, 'caption', 'global', 'Stale caption'); + + self::assertSame([], $this->find(MetaDataAssetFilter::create(searchTerm: 'stale'))); + } + + /** + * @test + */ + public function valuesOfUnconfiguredDimensionsAreNotFound(): void + { + $this->addRawValue($this->asset, 'caption', DimensionsFixture::language('es')->hash, 'Un gato'); + + self::assertSame([], $this->find(MetaDataAssetFilter::create(searchTerm: 'gato'))); + } + + /** + * @test + */ + public function valuesOfUndefinedPropertiesAreNotFound(): void + { + $this->addRawValue($this->asset, 'formerProperty', $this->en->hash, 'A cat'); + + self::assertSame([], $this->find(MetaDataAssetFilter::create(searchTerm: 'cat'))); + } + + /** + * @test + */ + public function searchingForAnUndefinedPropertyThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionCode(1776278047); + $this->find(MetaDataAssetFilter::create(propertyNames: MetaDataPropertyNames::create('unknown'))); + } + + /** + * @test + */ + public function searchingInAnUnconfiguredDimensionSpacePointThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionCode(1776279083); + $this->find(MetaDataAssetFilter::create(dimensionSpacePoint: DimensionsFixture::language('es'))); + } + + // ----------------------- + + /** + * @return list the matched asset references as ":" + */ + private function find(MetaDataAssetFilter $filter): array + { + $matches = []; + foreach ($this->metaDataManager->findAssets($filter) as $assetReference) { + $matches[] = $assetReference->assetSourceId . ':' . $assetReference->assetId; + } + return $matches; + } +} diff --git a/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php b/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php index e750de8..1bc8dd3 100644 --- a/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php +++ b/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php @@ -4,32 +4,21 @@ namespace Neos\MetaData\Tests\Functional\Storage; -use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Platforms\AbstractMySQLPlatform; -use Doctrine\ORM\EntityManagerInterface; -use Neos\Flow\Tests\FunctionalTestCase; 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\Storage\MetaDataStorageProviderDbalAdapter; +use Neos\MetaData\Domain\Dto\MetaDataPropertyNames; use Neos\MetaData\Storage\MetaDataStoredValue; +use Neos\MetaData\Tests\Functional\AbstractMetaDataTestCase; /** - * Verifies the parts of the storage that only exist in SQL: the upsert, the lookup by scope and the - * removal of values. - * - * The table is created here rather than by the Doctrine migration, because the values are not mapped as - * an entity and the functional test schema is derived from entity metadata only. The foreign key of the - * migration is omitted on purpose – it implements cascading deletion, which is not what is tested here. + * Verifies the parts of the storage that only exist in SQL: the upsert, the lookup by scope, the + * removal of values and the search. */ -class MetaDataStorageProviderDbalAdapterTest extends FunctionalTestCase +class MetaDataStorageProviderDbalAdapterTest extends AbstractMetaDataTestCase { - protected static $testablePersistenceEnabled = true; - - private Connection $connection; - private MetaDataStorageProviderDbalAdapter $storage; private MetaDataAssetReference $asset; private MetaDataPropertyName $caption; private MetaDataDimensionSpacePoint $de; @@ -38,33 +27,12 @@ class MetaDataStorageProviderDbalAdapterTest extends FunctionalTestCase public function setUp(): void { parent::setUp(); - $this->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 */ @@ -197,4 +165,36 @@ public function deletingStoredValuesRemovesExactlyThoseRows(): void 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))); } + + /** + * @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)); + } } diff --git a/Tests/Unit/Fixtures/InMemoryMetaDataStorage.php b/Tests/Unit/Fixtures/InMemoryMetaDataStorage.php deleted file mode 100644 index 57ebc32..0000000 --- a/Tests/Unit/Fixtures/InMemoryMetaDataStorage.php +++ /dev/null @@ -1,125 +0,0 @@ - - */ - private array $rows = []; - - public function setMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, string|int|bool $propertyValue, MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): void - { - $storedValue = new MetaDataStoredValue( - $assetReference, - $propertyName, - self::dimensionHash($scope), - $scope instanceof MetaDataGlobalScope, - $propertyValue, - ); - $this->rows[self::key($storedValue)] = $storedValue; - } - - public function unsetMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): void - { - unset($this->rows[implode("\0", [$assetReference->assetSourceId, $assetReference->assetId, $propertyName->value, self::dimensionHash($scope)])]); - } - - public function getMetaDataPropertyValues(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoints|MetaDataGlobalScope $scope): array - { - $dimensionHashes = $scope instanceof MetaDataGlobalScope - ? [self::GLOBAL_DIMENSION_HASH] - : $scope->map(static fn (MetaDataDimensionSpacePoint $dimensionSpacePoint) => $dimensionSpacePoint->hash); - - $values = []; - foreach ($this->rows as $row) { - if ($row->assetReference->assetSourceId !== $assetReference->assetSourceId - || $row->assetReference->assetId !== $assetReference->assetId - || !$row->propertyName->equals($propertyName->value) - || !in_array($row->dimensionHash, $dimensionHashes, true)) { - continue; - } - $values[$row->dimensionHash] = $row->value; - } - return $values; - } - - public function findAllStoredValues(): iterable - { - return array_values($this->rows); - } - - public function deleteStoredValues(MetaDataStoredValue ...$storedValues): int - { - $deleted = 0; - foreach ($storedValues as $storedValue) { - $key = self::key($storedValue); - if (array_key_exists($key, $this->rows)) { - unset($this->rows[$key]); - $deleted++; - } - } - return $deleted; - } - - /** - * Test helper: adds a value for an arbitrary dimension hash, including ones that are not (or no - * longer) configured - */ - public function addRawValue(MetaDataAssetReference $assetReference, string $propertyName, string $dimensionHash, string|int|bool $value): void - { - $storedValue = new MetaDataStoredValue( - $assetReference, - MetaDataPropertyName::fromString($propertyName), - $dimensionHash, - $dimensionHash === self::GLOBAL_DIMENSION_HASH, - $value, - ); - $this->rows[self::key($storedValue)] = $storedValue; - } - - /** - * @return list - */ - public function all(): array - { - return array_values($this->rows); - } - - // ----------------------- - - private static function dimensionHash(MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): string - { - return $scope instanceof MetaDataGlobalScope ? self::GLOBAL_DIMENSION_HASH : $scope->hash; - } - - private static function key(MetaDataStoredValue $storedValue): string - { - return implode("\0", [ - $storedValue->assetReference->assetSourceId, - $storedValue->assetReference->assetId, - $storedValue->propertyName->value, - $storedValue->dimensionHash, - ]); - } -} diff --git a/Tests/Unit/MetaDataManagerTest.php b/Tests/Unit/MetaDataManagerTest.php deleted file mode 100644 index 79bf3a3..0000000 --- a/Tests/Unit/MetaDataManagerTest.php +++ /dev/null @@ -1,262 +0,0 @@ -storage = new InMemoryMetaDataStorage(); - $this->metaDataManager = new MetaDataManager( - DimensionsFixture::languages(), - PropertyDefinitionsFixture::default(), - $this->storage, - ); - $this->asset = MetaDataAssetReference::create('neos', 'some-asset'); - $this->de = DimensionsFixture::language('de'); - $this->en = DimensionsFixture::language('en'); - $this->fr = DimensionsFixture::language('fr'); - } - - /** - * @test - */ - public function localizedValueWithoutFallbackIsItsOwnValue(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de); - - $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->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - - $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->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de); - - $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 valuesOfUnrelatedDimensionsAreNotInherited(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Un chat', $this->fr); - - $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 - { - $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->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat'); - - self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->en)->ownValue); - self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption')->ownValue); - } - - /** - * @test - */ - public function globalValueIsSharedByAllDimensions(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->de); - - 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->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->en); - - $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 globalValueIsStoredOnlyOnce(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->de); - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme Inc', $this->fr); - - self::assertCount(1, $this->storage->all()); - self::assertSame('© Acme Inc', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright', $this->en)->value); - } - - /** - * @test - */ - public function unsettingAGlobalValueIgnoresTheDimensionSpacePoint(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->en); - $this->metaDataManager->unsetMetaDataPropertyValue($this->asset, 'copyright', $this->de); - - self::assertSame([], $this->storage->all()); - } - - /** - * @test - */ - public function unsettingALocalizedValueOnlyAffectsItsDimension(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de); - $this->metaDataManager->unsetMetaDataPropertyValue($this->asset, 'caption', $this->de); - - $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de); - self::assertNull($value->ownValue); - self::assertSame('A cat', $value->inheritedValue); - } - - /** - * @test - */ - public function allDefinedPropertiesArePresentInTheResult(): void - { - $values = $this->metaDataManager->getMetaDataPropertyValues($this->asset, $this->de); - - self::assertSame(['copyright' => null, 'caption' => null], $values->toArray()); - } - - /** - * @test - */ - public function resultShapeDoesNotDependOnTheDimensionConfiguration(): void - { - $metaDataManager = new MetaDataManager(DimensionsFixture::none(), PropertyDefinitionsFixture::default(), new InMemoryMetaDataStorage()); - - self::assertSame(['copyright' => null, 'caption' => null], $metaDataManager->getMetaDataPropertyValues($this->asset)->toArray()); - } - - /** - * @test - */ - public function readingAnUndefinedPropertyThrows(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionCode(1776278047); - $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'unknown', $this->de); - } - - /** - * @test - */ - public function writingAnUnconfiguredDimensionSpacePointThrows(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionCode(1776279083); - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Hola', DimensionsFixture::language('es')); - } - - /** - * The dimension space point is ignored for global properties, so it is not validated either - * - * @test - */ - public function writingAGlobalValueAcceptsAnyDimensionSpacePoint(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', DimensionsFixture::language('es')); - - self::assertSame('© Acme', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright')->value); - } - - /** - * @test - */ - public function valuesOfOtherAssetsAreNotReturned(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $otherAsset = MetaDataAssetReference::create('neos', 'other-asset'); - - self::assertNull($this->metaDataManager->getMetaDataPropertyValue($otherAsset, 'caption', $this->en)->value); - } - - /** - * @test - */ - public function valuesOfOtherAssetSourcesAreNotReturned(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $sameAssetInAnotherSource = MetaDataAssetReference::create('other-source', 'some-asset'); - - self::assertNull($this->metaDataManager->getMetaDataPropertyValue($sameAssetInAnotherSource, 'caption', $this->en)->value); - } -} From 543992c8f0890cc0d711a1ae1a10fe4ece844989 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Mon, 3 Aug 2026 18:00:53 +0200 Subject: [PATCH 4/9] FEATURE: Add `getMetaDataProperty()` to the Eel helper Allows a single metadata property to be read from Fusion without fetching all of them first: caption = ${AssetMetaData.getMetaDataProperty(asset, 'caption', {language: 'de'})} Like `getMetaData()` it returns the effective value, i.e. with dimension fallbacks applied, and empty coordinates mean the default dimension. `allowsCallOfMethod()` now allows every method, so that helper methods added in the future are usable from Fusion without having to be listed twice. --- Classes/Helper/AssetMetaDataHelper.php | 20 ++++++++++++++++---- Readme.md | 11 ++++++++--- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/Classes/Helper/AssetMetaDataHelper.php b/Classes/Helper/AssetMetaDataHelper.php index 810d6f9..9f1b061 100644 --- a/Classes/Helper/AssetMetaDataHelper.php +++ b/Classes/Helper/AssetMetaDataHelper.php @@ -19,7 +19,7 @@ public function __construct( } /** - * The effective metadata of the given asset by property name, i.e. with dimension fallbacks applied + * 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 @@ -33,10 +33,22 @@ public function getMetaData(Asset $asset, array $coordinates = []): array } /** - * @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/Readme.md b/Readme.md index 24e596c..9bed859 100644 --- a/Readme.md +++ b/Readme.md @@ -185,11 +185,16 @@ The Eel helper `AssetMetaData` is registered in the default Fusion context and r 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 effective values. Empty coordinates mean the default dimension. +| 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 From af564ceaab264862575af0665f5f890a1fe78e7e Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Mon, 3 Aug 2026 18:21:46 +0200 Subject: [PATCH 5/9] FEATURE: Coerce metadata values to the configured property type Values used to be written and read as provided, so the `type` a property is declared with was exposed to consumers but never applied. It is now enforced in both directions: on the way in, so that nothing but a value of that type is ever stored, and on the way out, so that `MetaDataPropertyValue::$value` 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: "42" is a valid integer, "true", "on" and "yes" are a valid boolean, as are their negative counterparts. Anything else is rejected with an `InvalidArgumentException` rather than silently turned into a wrong value - "abc" is not 0. Reading is deliberately more forgiving, because it meets values that were written before a property was given its current type: a value that cannot be interpreted reads as NULL. Such a value is skipped rather than treated as an empty one, so that it does not shadow a fallback that is still readable. Booleans are stored as `1`/`0` and integers in decimal, which is what was already written before, so existing values stay readable. --- Classes/Domain/Dto/MetaDataPropertyType.php | 83 +++++++++++- Classes/MetaDataManager.php | 27 +++- Readme.md | 31 ++++- .../Fixtures/PropertyDefinitionsFixture.php | 33 ++++- Tests/Functional/MetaDataManagerTest.php | 126 ++++++++++++++++++ .../Domain/Dto/MetaDataPropertyTypeTest.php | 120 +++++++++++++++++ 6 files changed, 402 insertions(+), 18 deletions(-) create mode 100644 Tests/Unit/Domain/Dto/MetaDataPropertyTypeTest.php 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/MetaDataManager.php b/Classes/MetaDataManager.php index 68025a0..0d1317d 100644 --- a/Classes/MetaDataManager.php +++ b/Classes/MetaDataManager.php @@ -15,6 +15,7 @@ use Neos\MetaData\Domain\Dto\MetaDataPropertyDefinitions; use Neos\MetaData\Domain\Dto\MetaDataPropertyName; use Neos\MetaData\Domain\Dto\MetaDataPropertyNames; +use Neos\MetaData\Domain\Dto\MetaDataPropertyType; use Neos\MetaData\Domain\Dto\MetaDataPropertyValue; use Neos\MetaData\Domain\Dto\MetaDataPropertyValues; use Neos\MetaData\Storage\MetaDataStorage; @@ -48,6 +49,14 @@ public function getDimensionSpacePointConfiguration(): MetaDataDimensionSpacePoi return $this->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, @@ -56,11 +65,11 @@ public function setMetaDataPropertyValue( ): void { $propertyDefinition = $this->propertyDefinition($propertyName); - // TODO: ACL, convert value according to property definition + // TODO: ACL $this->storage->setMetaDataPropertyValue( $assetReference, $propertyDefinition->name, - $value, + $propertyDefinition->type->coerceForStorage($value), $this->writeScope($propertyDefinition, $dimensionSpacePoint), ); } @@ -182,7 +191,7 @@ private function resolvePropertyValue( MetaDataPropertyDefinition $propertyDefinition, ?MetaDataDimensionSpacePoint $dimensionSpacePoint, ): MetaDataPropertyValue { - // TODO: ACL, convert values according to property definition + // TODO: ACL if ($propertyDefinition->globalScope) { return $this->resolveGlobalPropertyValue($assetReference, $propertyDefinition); } @@ -200,12 +209,18 @@ private function resolvePropertyValue( 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 = $storedValues[$candidate->hash]; + $ownValue = $value; continue; } - return MetaDataPropertyValue::create($ownValue, $storedValues[$candidate->hash], $candidate); + return MetaDataPropertyValue::create($ownValue, $value, $candidate); } return MetaDataPropertyValue::create($ownValue); } @@ -225,7 +240,7 @@ private function resolveGlobalPropertyValue( if ($storedValues === []) { return MetaDataPropertyValue::createEmpty(); } - return MetaDataPropertyValue::create(reset($storedValues)); + return MetaDataPropertyValue::create($propertyDefinition->type->fromStoredValue(reset($storedValues))); } /** diff --git a/Readme.md b/Readme.md index 9bed859..3aba006 100644 --- a/Readme.md +++ b/Readme.md @@ -59,8 +59,30 @@ Neos: The package ships with three properties out of the box: `copyright` (global scope), `altText` and `caption`. -> **Note:** `type` is parsed into the property definitions and exposed to consumers, but values are not -> converted according to it yet – they are written and read as provided. +### 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`. Dimensions are *not* configured in this package. They are taken from the Content Repository content dimension presets (`Neos.ContentRepository.contentDimensions`) via @@ -104,8 +126,9 @@ $values = $this->metaDataManager->getMetaDataPropertyValues($assetReference, $ge | `getMetaDataPropertyValues()` | The values of all defined properties, as `MetaDataPropertyValues` | | `findAssets()` | References of the assets matching a `MetaDataAssetFilter` | -Unknown property names and dimension space points that are not allowed by the configured preset -constraints lead to an `InvalidArgumentException`. +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 diff --git a/Tests/Functional/Fixtures/PropertyDefinitionsFixture.php b/Tests/Functional/Fixtures/PropertyDefinitionsFixture.php index ff57bd1..246c9d9 100644 --- a/Tests/Functional/Fixtures/PropertyDefinitionsFixture.php +++ b/Tests/Functional/Fixtures/PropertyDefinitionsFixture.php @@ -20,12 +20,7 @@ public static function create(array $globalScopeByPropertyName): MetaDataPropert { $definitions = []; foreach ($globalScopeByPropertyName as $propertyName => $globalScope) { - $definitions[] = new MetaDataPropertyDefinition( - MetaDataPropertyName::fromString($propertyName), - MetaDataPropertyType::string, - $globalScope, - new MetaDataPropertyUiDefinition($propertyName, MetaDataEditorDefinition::default()), - ); + $definitions[] = self::definition($propertyName, MetaDataPropertyType::string, $globalScope); } return MetaDataPropertyDefinitions::create(...$definitions); } @@ -37,4 +32,30 @@ 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/Functional/MetaDataManagerTest.php b/Tests/Functional/MetaDataManagerTest.php index 107fa3b..75395a4 100644 --- a/Tests/Functional/MetaDataManagerTest.php +++ b/Tests/Functional/MetaDataManagerTest.php @@ -495,8 +495,134 @@ public function searchingInAnUnconfiguredDimensionSpacePointThrows(): void $this->find(MetaDataAssetFilter::create(dimensionSpacePoint: DimensionsFixture::language('es'))); } + /** + * @test + */ + public function typedValuesAreReadBackAsTheirType(): void + { + $manager = $this->typedManager(); + $manager->setMetaDataPropertyValue($this->asset, 'width', 42, $this->en); + $manager->setMetaDataPropertyValue($this->asset, 'featured', true, $this->en); + $manager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); + + self::assertSame(42, $manager->getMetaDataPropertyValue($this->asset, 'width', $this->en)->value); + self::assertTrue($manager->getMetaDataPropertyValue($this->asset, 'featured', $this->en)->value); + self::assertSame('A cat', $manager->getMetaDataPropertyValue($this->asset, 'caption', $this->en)->value); + } + + /** + * @test + */ + public function stringInputIsCoercedToTheDefinedType(): void + { + $manager = $this->typedManager(); + $manager->setMetaDataPropertyValue($this->asset, 'width', '42', $this->en); + $manager->setMetaDataPropertyValue($this->asset, 'featured', 'yes', $this->en); + + self::assertSame(42, $manager->getMetaDataPropertyValue($this->asset, 'width', $this->en)->value, 'the command line only ever has strings'); + self::assertTrue($manager->getMetaDataPropertyValue($this->asset, 'featured', $this->en)->value); + } + + /** + * @test + */ + public function aFalseValueIsDistinguishableFromAnAbsentOne(): void + { + $manager = $this->typedManager(); + $manager->setMetaDataPropertyValue($this->asset, 'featured', false, $this->en); + + $value = $manager->getMetaDataPropertyValue($this->asset, 'featured', $this->en); + self::assertFalse($value->value); + self::assertTrue($value->hasOwnValue(), 'FALSE is a value, not the absence of one'); + } + + /** + * @test + */ + public function aZeroValueIsDistinguishableFromAnAbsentOne(): void + { + $manager = $this->typedManager(); + $manager->setMetaDataPropertyValue($this->asset, 'width', 0, $this->en); + + $value = $manager->getMetaDataPropertyValue($this->asset, 'width', $this->en); + self::assertSame(0, $value->value); + self::assertTrue($value->hasOwnValue()); + } + + /** + * @test + */ + public function typedValuesAreInheritedAsTheirType(): void + { + $manager = $this->typedManager(); + $manager->setMetaDataPropertyValue($this->asset, 'width', 42, $this->en); + + $value = $manager->getMetaDataPropertyValue($this->asset, 'width', $this->de); + self::assertSame(42, $value->value); + self::assertSame(42, $value->inheritedValue); + self::assertTrue($value->isInherited()); + } + + /** + * @test + */ + public function writingAValueThatDoesNotMatchTheTypeThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionCode(1785715201); + $this->typedManager()->setMetaDataPropertyValue($this->asset, 'width', 'abc', $this->en); + } + + /** + * @test + */ + public function storedValuesThatDoNotMatchTheTypeAreTreatedLikeAbsentOnes(): void + { + $manager = $this->typedManager(); + $this->addRawValue($this->asset, 'width', $this->en->hash, 'abc'); + + $value = $manager->getMetaDataPropertyValue($this->asset, 'width', $this->en); + self::assertNull($value->value); + self::assertFalse($value->hasOwnValue()); + } + + /** + * @test + */ + public function anUnreadableValueDoesNotShadowAReadableFallback(): void + { + $manager = $this->typedManager(); + $manager->setMetaDataPropertyValue($this->asset, 'width', 42, $this->en); + $this->addRawValue($this->asset, 'width', $this->de->hash, 'abc'); + + $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 = new MetaDataManager( + DimensionsFixture::languages(), + PropertyDefinitionsFixture::create(['copyright' => true, 'caption' => false]), + $this->storage, + ); + $manager->setMetaDataPropertyValue($this->asset, 'copyright', 42); + + self::assertSame('42', $manager->getMetaDataPropertyValue($this->asset, 'copyright')->value); + } + // ----------------------- + private function typedManager(): MetaDataManager + { + return new MetaDataManager(DimensionsFixture::languages(), PropertyDefinitionsFixture::typed(), $this->storage); + } + /** * @return list the matched asset references as ":" */ 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'); + } +} From bbeee7a131ac261f7084313576dfee9e2bc71797 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Mon, 3 Aug 2026 18:35:25 +0200 Subject: [PATCH 6/9] TASK: Rework the test suite along the seams of the package There are two kinds of tests now: one for the `MetaDataManager`, with its dependencies as test doubles, and one per implementation of those dependencies. The manager tests no longer round trip through an in-memory storage. Each states the values the storage holds and asserts what resolves from them, so a test says what a rule *is* rather than demonstrating it through a write followed by a read. Writes are asserted as the calls they make. The repair tests work the same way and record those calls in order, which finally states outright that promotions happen before the deletions of the rows they came from. What a storage does with a lookup is its own business and is covered once per implementation. The tests of the DBAL adapter therefore also took over what used to be tested through the manager: the shadowing of values further down the fallback chain, the escaping of LIKE wildcards, distinctness, ordering and the separation of the localized and the global scope - plus the isolation of assets and asset sources, which was never a rule of the manager to begin with. They also cover the `MetaDataStorageMaintenance` surface that `assetmetadata:repair` is built on, including the values of unconfigured dimensions and undefined properties that reads can never return. `MetaDataConfigurationProviderYamlAdapter` had no test at all and has one now. The adapter stays MySQL specific, so its tests remain the only ones that need a database. --- Readme.md | 26 +- Tests/Functional/AbstractMetaDataTestCase.php | 82 --- .../Functional/Fixtures/DimensionsFixture.php | 79 --- .../Maintenance/MetaDataRepairTest.php | 209 ------ Tests/Functional/MetaDataManagerTest.php | 637 ----------------- ...MetaDataStorageProviderDbalAdapterTest.php | 324 ++++++++- ...taConfigurationProviderYamlAdapterTest.php | 169 +++++ .../DimensionSpacePointProviderMocks.php | 76 ++ .../Fixtures/MaintainableMetaDataStorage.php | 18 + .../Fixtures/PropertyDefinitionsFixture.php | 2 +- Tests/Unit/Maintenance/MetaDataRepairTest.php | 338 +++++++++ Tests/Unit/MetaDataManagerTest.php | 661 ++++++++++++++++++ 12 files changed, 1603 insertions(+), 1018 deletions(-) delete mode 100644 Tests/Functional/AbstractMetaDataTestCase.php delete mode 100644 Tests/Functional/Fixtures/DimensionsFixture.php delete mode 100644 Tests/Functional/Maintenance/MetaDataRepairTest.php delete mode 100644 Tests/Functional/MetaDataManagerTest.php create mode 100644 Tests/Unit/Configuration/MetaDataConfigurationProviderYamlAdapterTest.php create mode 100644 Tests/Unit/Fixtures/DimensionSpacePointProviderMocks.php create mode 100644 Tests/Unit/Fixtures/MaintainableMetaDataStorage.php rename Tests/{Functional => Unit}/Fixtures/PropertyDefinitionsFixture.php (97%) create mode 100644 Tests/Unit/Maintenance/MetaDataRepairTest.php create mode 100644 Tests/Unit/MetaDataManagerTest.php diff --git a/Readme.md b/Readme.md index 3aba006..0fdb051 100644 --- a/Readme.md +++ b/Readme.md @@ -323,9 +323,23 @@ Tests are part of the regular Flow test suites: ./bin/phpunit -c Build/BuildEssentials/PhpUnit/FunctionalTests.xml --filter 'Neos\\MetaData' ``` -Everything that touches stored values is tested functionally, against the real storage adapter rather -than an in-memory double: resolving a value is spread across the manager and SQL, so a second -implementation would only ever approximate it – `utf8mb4_unicode_ci` folds case and accents in ways -that PHP string functions do not. Those tests therefore require a MySQL or MariaDB test database and -are skipped on other platforms. Only `DimensionSpacePointProviderContentRepositoryAdapterTest`, which -needs no storage at all, is a unit test. +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/AbstractMetaDataTestCase.php b/Tests/Functional/AbstractMetaDataTestCase.php deleted file mode 100644 index f2548a1..0000000 --- a/Tests/Functional/AbstractMetaDataTestCase.php +++ /dev/null @@ -1,82 +0,0 @@ -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); - } - - public function tearDown(): void - { - $this->connection->executeStatement('DELETE FROM neos_metadata_value'); - parent::tearDown(); - } - - /** - * Writes a row for an arbitrary dimension hash, including ones that are not (or no longer) - * configured and ones that contradict the scope a property is defined for - */ - final protected 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 - */ - final protected function storedValues(): array - { - return iterator_to_array($this->storage->findAllStoredValues(), false); - } -} diff --git a/Tests/Functional/Fixtures/DimensionsFixture.php b/Tests/Functional/Fixtures/DimensionsFixture.php deleted file mode 100644 index 7aae3de..0000000 --- a/Tests/Functional/Fixtures/DimensionsFixture.php +++ /dev/null @@ -1,79 +0,0 @@ - $chainsByHash - */ - private function __construct( - private readonly MetaDataDimensionSpacePoint $defaultDimensionSpacePoint, - private readonly MetaDataDimensionSpacePoints $dimensionSpacePoints, - private readonly array $chainsByHash, - ) { - } - - /** - * A single "language" dimension with the fallback chains de -> en, fr -> en and en - */ - public static function languages(): self - { - $de = self::language('de'); - $en = self::language('en'); - $fr = self::language('fr'); - return new self( - $en, - MetaDataDimensionSpacePoints::create($en, $de, $fr), - [ - $en->hash => MetaDataDimensionSpacePoints::create($en), - $de->hash => MetaDataDimensionSpacePoints::create($de, $en), - $fr->hash => MetaDataDimensionSpacePoints::create($fr, $en), - ], - ); - } - - /** - * No content dimensions at all: the only valid dimension space point is the empty one - */ - public static function none(): self - { - $empty = MetaDataDimensionSpacePoint::fromCoordinates([]); - return new self($empty, MetaDataDimensionSpacePoints::create($empty), [$empty->hash => MetaDataDimensionSpacePoints::create($empty)]); - } - - public static function language(string $value): MetaDataDimensionSpacePoint - { - return MetaDataDimensionSpacePoint::fromCoordinates(['language' => $value]); - } - - public function getDimensionSpacePoints(): MetaDataDimensionSpacePoints - { - return $this->dimensionSpacePoints; - } - - public function getDefaultDimensionSpacePoint(): MetaDataDimensionSpacePoint - { - return $this->defaultDimensionSpacePoint; - } - - public function getDimensionSpacePointChain(MetaDataDimensionSpacePoint $dimensionSpacePoint): MetaDataDimensionSpacePoints - { - return $this->chainsByHash[$dimensionSpacePoint->hash] ?? MetaDataDimensionSpacePoints::create($dimensionSpacePoint); - } - - public function isDimensionSpacePointValid(MetaDataDimensionSpacePoint $dimensionSpacePoint): bool - { - return $this->dimensionSpacePoints->include($dimensionSpacePoint); - } -} diff --git a/Tests/Functional/Maintenance/MetaDataRepairTest.php b/Tests/Functional/Maintenance/MetaDataRepairTest.php deleted file mode 100644 index 541b15d..0000000 --- a/Tests/Functional/Maintenance/MetaDataRepairTest.php +++ /dev/null @@ -1,209 +0,0 @@ -metaDataManager = new MetaDataManager($dimensions, PropertyDefinitionsFixture::default(), $this->storage); - $this->metaDataRepair = new MetaDataRepair($this->metaDataManager, $dimensions, $this->storage); - $this->asset = MetaDataAssetReference::create('neos', 'some-asset'); - $this->de = DimensionsFixture::language('de'); - $this->en = DimensionsFixture::language('en'); - $this->fr = DimensionsFixture::language('fr'); - } - - /** - * @test - */ - public function consistentDataNeedsNoRepair(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme'); - - self::assertSame([], $this->metaDataRepair->analyze()); - } - - /** - * @test - */ - public function localizedValuesOfAGlobalPropertyAreConsolidatedIntoTheDefaultChainWinner(): void - { - $this->addRawValue($this->asset, 'copyright', $this->de->hash, '© Acme'); - $this->addRawValue($this->asset, 'copyright', $this->en->hash, '© 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)); - - $this->metaDataRepair->apply($actions); - self::assertSame('© Acme Inc', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright')->value); - self::assertCount(1, $this->storedValues()); - } - - /** - * @test - */ - public function theOnlyLocalizedValueOfAGlobalPropertyIsKeptEvenIfItIsNotOnTheDefaultChain(): void - { - $this->addRawValue($this->asset, 'copyright', $this->fr->hash, '© Foto Meier'); - - $this->metaDataRepair->apply($this->metaDataRepair->analyze()); - - self::assertSame('© Foto Meier', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright')->value); - self::assertCount(1, $this->storedValues()); - } - - /** - * @test - */ - public function anExistingSharedValueWinsOverStaleLocalizedOnes(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Current'); - $this->addRawValue($this->asset, 'copyright', $this->en->hash, '© Stale'); - - $actions = $this->metaDataRepair->analyze(); - self::assertSame([], self::actionsOfType($actions, MetaDataRepairActionType::promoteToGlobalScope), 'live data must not be overwritten'); - - $this->metaDataRepair->apply($actions); - self::assertSame('© Current', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright')->value); - self::assertCount(1, $this->storedValues()); - } - - /** - * @test - */ - public function aSharedValueOfALocalizedPropertyIsPromotedToTheDefaultDimension(): void - { - $this->addRawValue($this->asset, 'caption', 'global', 'A cat'); - - $actions = $this->metaDataRepair->analyze(); - self::assertCount(1, self::actionsOfType($actions, MetaDataRepairActionType::promoteToDefaultDimension)); - - $this->metaDataRepair->apply($actions); - self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->en)->ownValue); - self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de)->inheritedValue); - self::assertCount(1, $this->storedValues()); - } - - /** - * @test - */ - public function aSharedValueIsNotPromotedIfTheDefaultDimensionAlreadyHasAValue(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $this->addRawValue($this->asset, 'caption', 'global', 'Stale'); - - $actions = $this->metaDataRepair->analyze(); - self::assertSame([], self::actionsOfType($actions, MetaDataRepairActionType::promoteToDefaultDimension)); - - $this->metaDataRepair->apply($actions); - self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->en)->value); - self::assertCount(1, $this->storedValues()); - } - - /** - * @test - */ - public function valuesOfUnconfiguredDimensionsAreOnlyRemovedWhenPruning(): void - { - $this->addRawValue($this->asset, 'caption', DimensionsFixture::language('es')->hash, 'Un gato'); - - $actions = $this->metaDataRepair->analyze(); - self::assertCount(1, self::actionsOfType($actions, MetaDataRepairActionType::deleteObsoleteDimension)); - - self::assertSame(0, $this->metaDataRepair->apply($actions)); - self::assertCount(1, $this->storedValues()); - - self::assertSame(1, $this->metaDataRepair->apply($actions, prune: true)); - self::assertSame([], $this->storedValues()); - } - - /** - * @test - */ - public function valuesOfUndefinedPropertiesAreOnlyRemovedWhenPruning(): void - { - $this->addRawValue($this->asset, 'formerProperty', $this->en->hash, '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([], $this->storedValues()); - } - - /** - * @test - */ - public function valuesOfOtherAssetsAreNotAffected(): void - { - $otherAsset = MetaDataAssetReference::create('neos', 'other-asset'); - $this->addRawValue($this->asset, 'copyright', $this->en->hash, '© Acme'); - $this->metaDataManager->setMetaDataPropertyValue($otherAsset, 'caption', 'A cat', $this->en); - - $this->metaDataRepair->apply($this->metaDataRepair->analyze()); - - self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($otherAsset, 'caption', $this->en)->value); - } - - /** - * @test - */ - public function pruningIsRefusedWithoutConfiguredDimensions(): void - { - $dimensions = DimensionsFixture::none(); - $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()); - } - - // ----------------------- - - /** - * @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/Functional/MetaDataManagerTest.php b/Tests/Functional/MetaDataManagerTest.php deleted file mode 100644 index 75395a4..0000000 --- a/Tests/Functional/MetaDataManagerTest.php +++ /dev/null @@ -1,637 +0,0 @@ -metaDataManager = new MetaDataManager( - DimensionsFixture::languages(), - PropertyDefinitionsFixture::default(), - $this->storage, - ); - $this->asset = MetaDataAssetReference::create('neos', 'some-asset'); - $this->de = DimensionsFixture::language('de'); - $this->en = DimensionsFixture::language('en'); - $this->fr = DimensionsFixture::language('fr'); - } - - /** - * @test - */ - public function localizedValueWithoutFallbackIsItsOwnValue(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de); - - $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->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - - $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->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de); - - $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 valuesOfUnrelatedDimensionsAreNotInherited(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Un chat', $this->fr); - - $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 - { - $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->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat'); - - self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->en)->ownValue); - self::assertSame('A cat', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption')->ownValue); - } - - /** - * @test - */ - public function globalValueIsSharedByAllDimensions(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->de); - - 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->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->en); - - $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 globalValueIsStoredOnlyOnce(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->de); - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme Inc', $this->fr); - - self::assertCount(1, $this->storedValues()); - self::assertSame('© Acme Inc', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright', $this->en)->value); - } - - /** - * @test - */ - public function unsettingAGlobalValueIgnoresTheDimensionSpacePoint(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', $this->en); - $this->metaDataManager->unsetMetaDataPropertyValue($this->asset, 'copyright', $this->de); - - self::assertSame([], $this->storedValues()); - } - - /** - * @test - */ - public function unsettingALocalizedValueOnlyAffectsItsDimension(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de); - $this->metaDataManager->unsetMetaDataPropertyValue($this->asset, 'caption', $this->de); - - $value = $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'caption', $this->de); - self::assertNull($value->ownValue); - self::assertSame('A cat', $value->inheritedValue); - } - - /** - * @test - */ - public function allDefinedPropertiesArePresentInTheResult(): void - { - $values = $this->metaDataManager->getMetaDataPropertyValues($this->asset, $this->de); - - self::assertSame(['copyright' => null, 'caption' => null], $values->toArray()); - } - - /** - * @test - */ - public function resultShapeDoesNotDependOnTheDimensionConfiguration(): void - { - $metaDataManager = new MetaDataManager(DimensionsFixture::none(), PropertyDefinitionsFixture::default(), $this->storage); - - self::assertSame(['copyright' => null, 'caption' => null], $metaDataManager->getMetaDataPropertyValues($this->asset)->toArray()); - } - - /** - * @test - */ - public function readingAnUndefinedPropertyThrows(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionCode(1776278047); - $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'unknown', $this->de); - } - - /** - * @test - */ - public function writingAnUnconfiguredDimensionSpacePointThrows(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionCode(1776279083); - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Hola', DimensionsFixture::language('es')); - } - - /** - * The dimension space point is ignored for global properties, so it is not validated either - * - * @test - */ - public function writingAGlobalValueAcceptsAnyDimensionSpacePoint(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme', DimensionsFixture::language('es')); - - self::assertSame('© Acme', $this->metaDataManager->getMetaDataPropertyValue($this->asset, 'copyright')->value); - } - - /** - * @test - */ - public function valuesOfOtherAssetsAreNotReturned(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $otherAsset = MetaDataAssetReference::create('neos', 'other-asset'); - - self::assertNull($this->metaDataManager->getMetaDataPropertyValue($otherAsset, 'caption', $this->en)->value); - } - - /** - * @test - */ - public function valuesOfOtherAssetSourcesAreNotReturned(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $sameAssetInAnotherSource = MetaDataAssetReference::create('other-source', 'some-asset'); - - self::assertNull($this->metaDataManager->getMetaDataPropertyValue($sameAssetInAnotherSource, 'caption', $this->en)->value); - } - - /** - * @test - */ - public function assetsAreFoundByASearchTermInAnyProperty(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $other = MetaDataAssetReference::create('neos', 'other-asset'); - $this->metaDataManager->setMetaDataPropertyValue($other, 'copyright', '© Cat Photos', $this->en); - - self::assertSame( - ['neos:other-asset', 'neos:some-asset'], - $this->find(MetaDataAssetFilter::create(searchTerm: 'cat')), - 'the global scope property matches as well', - ); - } - - /** - * @test - */ - public function theSearchTermMatchesAnywhereInAValueAndIgnoresCase(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A CATalogue picture', $this->en); - - self::assertSame(['neos:some-asset'], $this->find(MetaDataAssetFilter::create(searchTerm: 'cat'))); - } - - /** - * @test - */ - public function inheritedValuesAreFound(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - - self::assertSame( - ['neos:some-asset'], - $this->find(MetaDataAssetFilter::create(dimensionSpacePoint: $this->de, searchTerm: 'cat')), - 'German inherits the English caption, so it is what an editor working in German sees', - ); - } - - /** - * @test - */ - public function shadowedValuesAreNotFound(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de); - - self::assertSame( - [], - $this->find(MetaDataAssetFilter::create(dimensionSpacePoint: $this->de, searchTerm: 'cat')), - 'the German value overrides the English one, so "cat" is not what German resolves to', - ); - self::assertSame(['neos:some-asset'], $this->find(MetaDataAssetFilter::create(dimensionSpacePoint: $this->en, searchTerm: 'cat'))); - } - - /** - * @test - */ - public function valuesOfUnrelatedDimensionsAreNotFound(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Un chat', $this->fr); - - self::assertSame([], $this->find(MetaDataAssetFilter::create(dimensionSpacePoint: $this->de, searchTerm: 'chat'))); - } - - /** - * @test - */ - public function globalValuesAreFoundRegardlessOfTheDimensionSpacePoint(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Acme'); - - foreach ([$this->de, $this->en, $this->fr, null] as $dimensionSpacePoint) { - self::assertSame( - ['neos:some-asset'], - $this->find(MetaDataAssetFilter::create(dimensionSpacePoint: $dimensionSpacePoint, searchTerm: 'acme')), - ); - } - } - - /** - * @test - */ - public function anOmittedDimensionSpacePointRefersToTheDefaultOne(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'Eine Katze', $this->de); - - self::assertSame([], $this->find(MetaDataAssetFilter::create(searchTerm: 'Katze')), 'the default dimension is English'); - self::assertSame(['neos:some-asset'], $this->find(MetaDataAssetFilter::create(dimensionSpacePoint: $this->de, searchTerm: 'Katze'))); - } - - /** - * @test - */ - public function anAssetMatchingSeveralTimesIsReturnedOnce(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'copyright', '© Cat Photos'); - - self::assertSame(['neos:some-asset'], $this->find(MetaDataAssetFilter::create(searchTerm: 'cat'))); - } - - /** - * @test - */ - public function theSearchCanBeRestrictedToProperties(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $other = MetaDataAssetReference::create('neos', 'other-asset'); - $this->metaDataManager->setMetaDataPropertyValue($other, 'copyright', '© Cat Photos'); - - self::assertSame( - ['neos:some-asset'], - $this->find(MetaDataAssetFilter::create(searchTerm: 'cat', propertyNames: MetaDataPropertyNames::create('caption'))), - ); - self::assertSame( - ['neos:other-asset'], - $this->find(MetaDataAssetFilter::create(searchTerm: 'cat', propertyNames: MetaDataPropertyNames::create('copyright'))), - ); - } - - /** - * @test - */ - public function theSearchCanBeRestrictedToAnAssetSource(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $sameAssetInAnotherSource = MetaDataAssetReference::create('other-source', 'some-asset'); - $this->metaDataManager->setMetaDataPropertyValue($sameAssetInAnotherSource, 'caption', 'A cat', $this->en); - - self::assertSame(['other-source:some-asset'], $this->find(MetaDataAssetFilter::create(assetSourceId: 'other-source', searchTerm: 'cat'))); - } - - /** - * @test - */ - public function anOmittedSearchTermMatchesEveryAssetWithAValue(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $other = MetaDataAssetReference::create('neos', 'other-asset'); - $this->metaDataManager->setMetaDataPropertyValue($other, 'copyright', '© Acme'); - - self::assertSame(['neos:other-asset', 'neos:some-asset'], $this->find(MetaDataAssetFilter::create())); - self::assertSame( - ['neos:some-asset'], - $this->find(MetaDataAssetFilter::create(propertyNames: MetaDataPropertyNames::create('caption'))), - 'which assets have a caption at all', - ); - } - - /** - * @test - */ - public function anEmptySearchTermIsTreatedLikeAnOmittedOne(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - - self::assertSame(['neos:some-asset'], $this->find(MetaDataAssetFilter::create(searchTerm: ' '))); - } - - /** - * @test - */ - public function theSearchTermIsTrimmed(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - - self::assertSame(['neos:some-asset'], $this->find(MetaDataAssetFilter::create(searchTerm: ' cat '))); - } - - /** - * @test - */ - public function likeWildcardsInTheSearchTermAreEscaped(): void - { - $this->metaDataManager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - $discounted = MetaDataAssetReference::create('neos', 'discounted'); - $this->metaDataManager->setMetaDataPropertyValue($discounted, 'caption', 'Reduced by 50%', $this->en); - - self::assertSame(['neos:discounted'], $this->find(MetaDataAssetFilter::create(searchTerm: '50%'))); - self::assertSame([], $this->find(MetaDataAssetFilter::create(searchTerm: 'c_t'))); - self::assertSame([], $this->find(MetaDataAssetFilter::create(searchTerm: '\\'))); - } - - /** - * @test - */ - public function valuesOfAScopeThatContradictsTheConfigurationAreNotFound(): void - { - $this->addRawValue($this->asset, 'copyright', $this->en->hash, '© Stale'); - $this->addRawValue($this->asset, 'caption', 'global', 'Stale caption'); - - self::assertSame([], $this->find(MetaDataAssetFilter::create(searchTerm: 'stale'))); - } - - /** - * @test - */ - public function valuesOfUnconfiguredDimensionsAreNotFound(): void - { - $this->addRawValue($this->asset, 'caption', DimensionsFixture::language('es')->hash, 'Un gato'); - - self::assertSame([], $this->find(MetaDataAssetFilter::create(searchTerm: 'gato'))); - } - - /** - * @test - */ - public function valuesOfUndefinedPropertiesAreNotFound(): void - { - $this->addRawValue($this->asset, 'formerProperty', $this->en->hash, 'A cat'); - - self::assertSame([], $this->find(MetaDataAssetFilter::create(searchTerm: 'cat'))); - } - - /** - * @test - */ - public function searchingForAnUndefinedPropertyThrows(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionCode(1776278047); - $this->find(MetaDataAssetFilter::create(propertyNames: MetaDataPropertyNames::create('unknown'))); - } - - /** - * @test - */ - public function searchingInAnUnconfiguredDimensionSpacePointThrows(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionCode(1776279083); - $this->find(MetaDataAssetFilter::create(dimensionSpacePoint: DimensionsFixture::language('es'))); - } - - /** - * @test - */ - public function typedValuesAreReadBackAsTheirType(): void - { - $manager = $this->typedManager(); - $manager->setMetaDataPropertyValue($this->asset, 'width', 42, $this->en); - $manager->setMetaDataPropertyValue($this->asset, 'featured', true, $this->en); - $manager->setMetaDataPropertyValue($this->asset, 'caption', 'A cat', $this->en); - - self::assertSame(42, $manager->getMetaDataPropertyValue($this->asset, 'width', $this->en)->value); - self::assertTrue($manager->getMetaDataPropertyValue($this->asset, 'featured', $this->en)->value); - self::assertSame('A cat', $manager->getMetaDataPropertyValue($this->asset, 'caption', $this->en)->value); - } - - /** - * @test - */ - public function stringInputIsCoercedToTheDefinedType(): void - { - $manager = $this->typedManager(); - $manager->setMetaDataPropertyValue($this->asset, 'width', '42', $this->en); - $manager->setMetaDataPropertyValue($this->asset, 'featured', 'yes', $this->en); - - self::assertSame(42, $manager->getMetaDataPropertyValue($this->asset, 'width', $this->en)->value, 'the command line only ever has strings'); - self::assertTrue($manager->getMetaDataPropertyValue($this->asset, 'featured', $this->en)->value); - } - - /** - * @test - */ - public function aFalseValueIsDistinguishableFromAnAbsentOne(): void - { - $manager = $this->typedManager(); - $manager->setMetaDataPropertyValue($this->asset, 'featured', false, $this->en); - - $value = $manager->getMetaDataPropertyValue($this->asset, 'featured', $this->en); - self::assertFalse($value->value); - self::assertTrue($value->hasOwnValue(), 'FALSE is a value, not the absence of one'); - } - - /** - * @test - */ - public function aZeroValueIsDistinguishableFromAnAbsentOne(): void - { - $manager = $this->typedManager(); - $manager->setMetaDataPropertyValue($this->asset, 'width', 0, $this->en); - - $value = $manager->getMetaDataPropertyValue($this->asset, 'width', $this->en); - self::assertSame(0, $value->value); - self::assertTrue($value->hasOwnValue()); - } - - /** - * @test - */ - public function typedValuesAreInheritedAsTheirType(): void - { - $manager = $this->typedManager(); - $manager->setMetaDataPropertyValue($this->asset, 'width', 42, $this->en); - - $value = $manager->getMetaDataPropertyValue($this->asset, 'width', $this->de); - self::assertSame(42, $value->value); - self::assertSame(42, $value->inheritedValue); - self::assertTrue($value->isInherited()); - } - - /** - * @test - */ - public function writingAValueThatDoesNotMatchTheTypeThrows(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionCode(1785715201); - $this->typedManager()->setMetaDataPropertyValue($this->asset, 'width', 'abc', $this->en); - } - - /** - * @test - */ - public function storedValuesThatDoNotMatchTheTypeAreTreatedLikeAbsentOnes(): void - { - $manager = $this->typedManager(); - $this->addRawValue($this->asset, 'width', $this->en->hash, 'abc'); - - $value = $manager->getMetaDataPropertyValue($this->asset, 'width', $this->en); - self::assertNull($value->value); - self::assertFalse($value->hasOwnValue()); - } - - /** - * @test - */ - public function anUnreadableValueDoesNotShadowAReadableFallback(): void - { - $manager = $this->typedManager(); - $manager->setMetaDataPropertyValue($this->asset, 'width', 42, $this->en); - $this->addRawValue($this->asset, 'width', $this->de->hash, 'abc'); - - $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 = new MetaDataManager( - DimensionsFixture::languages(), - PropertyDefinitionsFixture::create(['copyright' => true, 'caption' => false]), - $this->storage, - ); - $manager->setMetaDataPropertyValue($this->asset, 'copyright', 42); - - self::assertSame('42', $manager->getMetaDataPropertyValue($this->asset, 'copyright')->value); - } - - // ----------------------- - - private function typedManager(): MetaDataManager - { - return new MetaDataManager(DimensionsFixture::languages(), PropertyDefinitionsFixture::typed(), $this->storage); - } - - /** - * @return list the matched asset references as ":" - */ - private function find(MetaDataAssetFilter $filter): array - { - $matches = []; - foreach ($this->metaDataManager->findAssets($filter) as $assetReference) { - $matches[] = $assetReference->assetSourceId . ':' . $assetReference->assetId; - } - return $matches; - } -} diff --git a/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php b/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php index 1bc8dd3..255be96 100644 --- a/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php +++ b/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php @@ -4,21 +4,38 @@ namespace Neos\MetaData\Tests\Functional\Storage; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Platforms\AbstractMySQLPlatform; +use Doctrine\ORM\EntityManagerInterface; +use Neos\Flow\Tests\FunctionalTestCase; 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; +use Neos\MetaData\Storage\MetaDataStorageMaintenance; +use Neos\MetaData\Storage\MetaDataStorageProviderDbalAdapter; use Neos\MetaData\Storage\MetaDataStoredValue; -use Neos\MetaData\Tests\Functional\AbstractMetaDataTestCase; /** - * Verifies the parts of the storage that only exist in SQL: the upsert, the lookup by scope, the - * removal of values and the search. + * Verifies the parts of the storage that only exist in SQL: the upsert, the lookup by scope, the search + * and the {@see MetaDataStorageMaintenance} surface that `assetmetadata:repair` is built on. + * + * The adapter is deliberately MySQL specific - the upsert, the fallback ranking and the null safe + * correlation all use MySQL syntax - so these tests need a MySQL or MariaDB test database and are + * skipped elsewhere. + * + * The table is created here rather than by the Doctrine migration, because the values are not mapped as + * an entity and the functional test schema is derived from entity metadata only. The foreign key of the + * migration is omitted on purpose - it implements cascading deletion, which none of these tests cover. */ -class MetaDataStorageProviderDbalAdapterTest extends AbstractMetaDataTestCase +class MetaDataStorageProviderDbalAdapterTest extends FunctionalTestCase { + protected static $testablePersistenceEnabled = true; + + private Connection $connection; + private MetaDataStorageProviderDbalAdapter $storage; private MetaDataAssetReference $asset; private MetaDataPropertyName $caption; private MetaDataDimensionSpacePoint $de; @@ -27,12 +44,33 @@ class MetaDataStorageProviderDbalAdapterTest extends AbstractMetaDataTestCase public function setUp(): void { parent::setUp(); + $this->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 */ @@ -166,6 +204,240 @@ public function deletingStoredValuesRemovesExactlyThoseRows(): void 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 */ @@ -197,4 +469,48 @@ public function searchingWithAnEmptyChainIgnoresLocalizedProperties(): void 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..9c95285 --- /dev/null +++ b/Tests/Unit/Configuration/MetaDataConfigurationProviderYamlAdapterTest.php @@ -0,0 +1,169 @@ +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 anOmittedLabelFallsBackToThePropertyName(): void + { + self::assertSame('caption', $this->definitionFor([])->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->ui->label); + self::assertSame([], $definition->ui->editorDefinition->options); + } + + /** + * @test + */ + public function anEmptyConfigurationLeadsToNoDefinitions(): void + { + self::assertSame([], iterator_to_array($this->definitionsFor([]))); + } + + // ----------------------- + + /** + * @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> $configuration + */ + private function definitionsFor(array $configuration): MetaDataPropertyDefinitions + { + return (new MetaDataConfigurationProviderYamlAdapter($configuration, $this->translator))->getPropertyConfiguration(); + } +} 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 @@ + + */ + 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); + } +} From 79704fef9093538958b28fa4a7f0be8aca828534 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Mon, 3 Aug 2026 18:41:26 +0200 Subject: [PATCH 7/9] TASK: Move two paragraphs back out of the "Property types" section They describe where dimensions come from and what happens when the scope of a property is changed, so they belong to the configuration section as a whole rather than under the subsection about property types that was inserted above them. --- Readme.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Readme.md b/Readme.md index 0fdb051..58e889c 100644 --- a/Readme.md +++ b/Readme.md @@ -59,6 +59,14 @@ Neos: The package ships with three properties out of the box: `copyright` (global scope), `altText` and `caption`. +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 @@ -84,14 +92,6 @@ behaves as if it had no value for that dimension – and does not shadow a fallb readable. Note that the search of `findAssets()` matches the *stored* representation, so a `boolean` is matched as `1`/`0` rather than as `true`/`false`. -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). - ## Usage ### PHP API From fed6ec5a78a844bf29f8a007f9624c35017bdcce Mon Sep 17 00:00:00 2001 From: Michel Loew Date: Tue, 4 Aug 2026 15:52:38 +0200 Subject: [PATCH 8/9] FIX: Add getOwnValue method to MetaDataPropertyValue for fusion access --- Classes/Domain/Dto/MetaDataPropertyValue.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Classes/Domain/Dto/MetaDataPropertyValue.php b/Classes/Domain/Dto/MetaDataPropertyValue.php index de95ddb..ce2b93a 100644 --- a/Classes/Domain/Dto/MetaDataPropertyValue.php +++ b/Classes/Domain/Dto/MetaDataPropertyValue.php @@ -62,6 +62,12 @@ public function hasOwnValue(): bool return $this->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 */ From 5ac2b4b7083c2b84376fc5f042a89014a1f2b5ef Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 5 Aug 2026 10:43:41 +0200 Subject: [PATCH 9/9] FEATURE: Skip metadata properties that are configured to `null` This allows to disable property definitions that are configured elsewhere: Neos: MetaData: metaDataProperties: 'copyright': ~ --- .../MetaDataConfigurationProviderYamlAdapter.php | 4 ++++ Readme.md | 10 ++++++++++ ...MetaDataConfigurationProviderYamlAdapterTest.php | 13 ++++++++++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Classes/Configuration/MetaDataConfigurationProviderYamlAdapter.php b/Classes/Configuration/MetaDataConfigurationProviderYamlAdapter.php index 4c57b6b..b716c72 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) { diff --git a/Readme.md b/Readme.md index 58e889c..8662982 100644 --- a/Readme.md +++ b/Readme.md @@ -59,6 +59,16 @@ Neos: The package ships with three properties out of the box: `copyright` (global scope), `altText` and `caption`. +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 diff --git a/Tests/Unit/Configuration/MetaDataConfigurationProviderYamlAdapterTest.php b/Tests/Unit/Configuration/MetaDataConfigurationProviderYamlAdapterTest.php index 9c95285..26d56fa 100644 --- a/Tests/Unit/Configuration/MetaDataConfigurationProviderYamlAdapterTest.php +++ b/Tests/Unit/Configuration/MetaDataConfigurationProviderYamlAdapterTest.php @@ -149,6 +149,17 @@ 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'))); + } + // ----------------------- /** @@ -160,7 +171,7 @@ private function definitionFor(array $configuration): MetaDataPropertyDefinition } /** - * @param array> $configuration + * @param array|null> $configuration */ private function definitionsFor(array $configuration): MetaDataPropertyDefinitions {