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