diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..0bc613d --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,135 @@ +name: Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +env: + PACKAGE_FOLDER: metadata + +jobs: + codestyle: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php-versions: ['8.4'] + + steps: + - uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + + - name: Cache dependencies + uses: actions/cache@v5 + with: + path: ~/.composer/cache + key: dependencies-composer-${{ hashFiles('composer.json') }} + + - name: Install dependencies + uses: php-actions/composer@v6 + with: + php_version: ${{ matrix.php-versions }} + version: 2 + + - name: PHPStan + uses: php-actions/phpstan@v3 + with: + php_version: ${{ matrix.php-versions }} + configuration: phpstan.ci.neon + + php-unit-tests: + env: + FLOW_CONTEXT: Testing + FLOW_FOLDER: ../flow-base-distribution + + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php-versions: ['8.4'] + flow-versions: ['8.4'] + + services: + mariadb: + image: mariadb:11 + env: + MARIADB_DATABASE: neos + MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 'yes' + ports: + - 3306:3306 + options: >- + --health-cmd="mariadb-admin ping --silent" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + steps: + - uses: actions/checkout@v5 + + - name: Set package branch name + run: echo "PACKAGE_TARGET_VERSION=${GITHUB_BASE_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_ENV + working-directory: . + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: mbstring, xml, json, zlib, iconv, intl, pdo_sqlite, mysql + coverage: xdebug #optional + ini-values: opcache.fast_shutdown=0 + + - name: Cache dependencies + uses: actions/cache@v5 + with: + path: ~/.composer/cache + key: dependencies-composer-${{ hashFiles('composer.json') }} + + - name: Prepare Flow distribution + run: | + git clone https://github.com/neos/flow-base-distribution.git -b ${{ matrix.flow-versions }} ${FLOW_FOLDER} + cd ${FLOW_FOLDER} + composer require --no-update --dev --no-interaction phpunit/phpunit:"^11.0" + + git -C ../${{ env.PACKAGE_FOLDER }} checkout -b build + composer config repositories.package '{ "type": "path", "url": "../${{ env.PACKAGE_FOLDER }}", "options": { "symlink": false } }' + composer require --no-update --no-interaction neos/metadata:"dev-build as dev-${PACKAGE_TARGET_VERSION}" + + # The storage tests target MySQL/MariaDB, so the Testing context uses the CI database + # service instead of the default in-memory sqlite backend. + mkdir -p Configuration/Testing + cat > Configuration/Testing/Settings.yaml <<'EOF' + Neos: + Flow: + persistence: + backendOptions: + driver: 'pdo_mysql' + host: '127.0.0.1' + dbname: 'neos' + user: 'root' + password: '' + EOF + + - name: Composer Install + run: | + cd ${FLOW_FOLDER} + composer install --no-interaction --no-progress + + - name: Run Unit tests + run: | + cd ${FLOW_FOLDER} + bin/phpunit --colors -c Build/BuildEssentials/PhpUnit/UnitTests.xml Packages/Application/Neos.MetaData/Tests/Unit/ + + - name: Run Functional tests + # The storage tests target MySQL/MariaDB and run against the mariadb service container of + # this job. They would be skipped on the default sqlite backend. + run: | + cd ${FLOW_FOLDER} + bin/phpunit --colors -c Build/BuildEssentials/PhpUnit/FunctionalTests.xml Packages/Application/Neos.MetaData/Tests/Functional/ \ No newline at end of file diff --git a/Classes/Command/AssetMetaDataCommandController.php b/Classes/Command/AssetMetaDataCommandController.php new file mode 100644 index 0000000..1b23019 --- /dev/null +++ b/Classes/Command/AssetMetaDataCommandController.php @@ -0,0 +1,235 @@ +metaDataManager->setMetaDataPropertyValue( + $assetReference, + MetaDataPropertyName::fromString($property), + $value, + $dimensionSpacePointDecoded, + ); + $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); + } + $this->outputLine("$message"); + } + + /** + * Removes a metadata property for an asset + * + * @param string $assetId ID of the asset to unset the metadata property for + * @param string $property name of the metadata property to unset + * @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 + */ + public function unsetCommand(string $assetId, string $property, string|null $assetSource = null, string|null $dimensionSpacePoint = null): void + { + $dimensionSpacePointDecoded = $dimensionSpacePoint !== null ? self::parseDimensionSpacePoint($dimensionSpacePoint) : null; + $assetReference = MetaDataAssetReference::create($assetSource ?? 'neos', $assetId); + $this->metaDataManager->unsetMetaDataPropertyValue( + $assetReference, + MetaDataPropertyName::fromString($property), + $dimensionSpacePointDecoded, + ); + $message = sprintf('Metadata property "%s" of asset "%s" was unset', $property, $assetId); + if ($dimensionSpacePointDecoded !== null) { + $message .= sprintf(' for dimension space point "%s"', $dimensionSpacePointDecoded); + } + $this->outputLine("$message"); + } + + /** + * Lists all metadata properties for an asset + * + * 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 + */ + public function listCommand(string $assetId, string|null $assetSource = null, string|null $dimensionSpacePoint = null): void + { + $dimensionSpacePointDecoded = $dimensionSpacePoint !== null ? self::parseDimensionSpacePoint($dimensionSpacePoint) : null; + $assetReference = MetaDataAssetReference::create($assetSource ?? 'neos', $assetId); + $metaDataPropertyValues = $this->metaDataManager->getMetaDataPropertyValues( + $assetReference, + $dimensionSpacePointDecoded, + ); + $message = sprintf('Metadata properties of asset "%s"', $assetId); + if ($dimensionSpacePointDecoded !== null) { + $message .= sprintf(' for dimension space point "%s"', $dimensionSpacePointDecoded); + } + $this->outputLine($message . ':'); + foreach ($metaDataPropertyValues as $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.'); + } + } + + private static function parseDimensionSpacePoint(string $dimensionSpacePoint): MetaDataDimensionSpacePoint + { + try { + $coordinates = json_decode($dimensionSpacePoint, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new InvalidArgumentException('Failed to parse dimension space point: ' . $e->getMessage(), 1776274597, $e); + } + if (!is_array($coordinates)) { + throw new InvalidArgumentException('Failed to parse dimension space point: expected a JSON object of coordinates', 1776274598); + } + $coordinateValues = []; + foreach ($coordinates as $dimensionName => $coordinateValue) { + if (!is_string($dimensionName) || !is_string($coordinateValue)) { + throw new InvalidArgumentException('Failed to parse dimension space point: coordinates must map dimension names to string values', 1776274599); + } + $coordinateValues[$dimensionName] = $coordinateValue; + } + return MetaDataDimensionSpacePoint::fromCoordinates($coordinateValues); + } + +} diff --git a/Classes/Command/AssetMetaDataMigrationCommandController.php b/Classes/Command/AssetMetaDataMigrationCommandController.php new file mode 100644 index 0000000..d68e407 --- /dev/null +++ b/Classes/Command/AssetMetaDataMigrationCommandController.php @@ -0,0 +1,47 @@ +assetRepository->findAll() as $asset) { + $caption = $asset->getCaption(); + $copyrightNotice = $asset->getCopyrightNotice(); + $metaDataAssetReference = MetaDataAssetReference::create($asset->assetSourceIdentifier, $asset->getIdentifier()); + + if (!empty($caption)) { + $this->metaDataManager->setMetaDataPropertyValue( + $metaDataAssetReference, + MetaDataPropertyName::fromString('caption'), + $caption, + ); + } + if (!empty($copyrightNotice)) { + $this->metaDataManager->setMetaDataPropertyValue( + $metaDataAssetReference, + MetaDataPropertyName::fromString('copyright'), + $copyrightNotice, + ); + } + } + } +} diff --git a/Classes/Configuration/MetaDataConfigurationProvider.php b/Classes/Configuration/MetaDataConfigurationProvider.php new file mode 100644 index 0000000..2c906b1 --- /dev/null +++ b/Classes/Configuration/MetaDataConfigurationProvider.php @@ -0,0 +1,14 @@ + + * } + * } + * }|null> $propertyConfiguration + */ + public function __construct( + private readonly array $propertyConfiguration, + private readonly Translator $translator, + ) + { + } + + 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) { + 'integer' => MetaDataPropertyType::integer, + 'boolean' => MetaDataPropertyType::boolean, + default => MetaDataPropertyType::string, + }, + $propertyDefinition['globalScope'] ?? false, + 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); + } + + // ----------------------- + + private function translatePropertyName(string $propertyName, ?string $label): string + { + if ($label === 'i18n') { + $translationShortHandString = sprintf('properties.%s', $propertyName); + return $this->translator->translateById( + $translationShortHandString, + [], + null, + null, + 'Main', + 'Neos.MetaData' + ) ?? $propertyName; + } + if ($label !== null && preg_match(self::I18N_LABEL_ID_PATTERN, $label) === 1) { + // A translation shorthand string like 'PackageKey:Source:trans-unit-id' + [$packageKey, $sourceName, $id] = explode(':', $label); + return $this->translator->translateById( + $id, + [], + null, + null, + str_replace('.', '/', $sourceName), + $packageKey + ) ?? $propertyName; + } + return $label ?? $propertyName; + } +} diff --git a/Classes/Configuration/MetaDataConfigurationProviderYamlAdapterFactory.php b/Classes/Configuration/MetaDataConfigurationProviderYamlAdapterFactory.php new file mode 100644 index 0000000..22cbf60 --- /dev/null +++ b/Classes/Configuration/MetaDataConfigurationProviderYamlAdapterFactory.php @@ -0,0 +1,34 @@ + + * } + * } + * }|null> $propertyConfiguration + */ + public function __construct( + private readonly array $propertyConfiguration, + private readonly Translator $translator, + ) + { + } + + public function create(): MetaDataConfigurationProvider + { + return new MetaDataConfigurationProviderYamlAdapter($this->propertyConfiguration, $this->translator); + } +} diff --git a/Classes/DimensionSpacePointProvider/DimensionSpacePointProvider.php b/Classes/DimensionSpacePointProvider/DimensionSpacePointProvider.php new file mode 100644 index 0000000..42ca71f --- /dev/null +++ b/Classes/DimensionSpacePointProvider/DimensionSpacePointProvider.php @@ -0,0 +1,22 @@ + + * }> + * }>|null + */ + private ?array $allPresets = null; + + public function __construct( + private readonly ConfigurationContentDimensionPresetSource $configurationContentDimensionPresetSource, + ) { + } + + public function getDimensionSpacePoints(): MetaDataDimensionSpacePoints + { + $presets = $this->getAllPresets(); + return MetaDataDimensionSpacePoints::create(...array_map( + fn ($coords) => MetaDataDimensionSpacePoint::fromCoordinates($coords), + $this->createAllPresetCombinations($presets) + )); + } + + /** + * @return array + * }> + * }> + */ + private function getAllPresets(): array + { + if ($this->allPresets === null) { + $this->allPresets = $this->configurationContentDimensionPresetSource->getAllPresets(); + } + return $this->allPresets; + } + + public function getDefaultDimensionSpacePoint(): MetaDataDimensionSpacePoint + { + $presets = $this->getAllPresets(); + $ccordinates = []; + foreach ($presets as $dimensionName => $dimensionConfig) { + $ccordinates[$dimensionName] = $dimensionConfig['default'] ?? ''; + } + return MetaDataDimensionSpacePoint::fromCoordinates($ccordinates); + } + + public function getDimensionSpacePointChain(?MetaDataDimensionSpacePoint $dimensionSpacePoint = null): MetaDataDimensionSpacePoints + { + if ($dimensionSpacePoint === null) { + $dimensionSpacePoint = $this->getDefaultDimensionSpacePoint(); + } + + if ($dimensionSpacePoint->coordinates === []) { + return MetaDataDimensionSpacePoints::create($dimensionSpacePoint); + } + + // For each coordinate, resolve the ordered fallback chain from the matching preset + $perDimensionChains = []; + foreach ($dimensionSpacePoint->coordinates as $dimensionName => $primaryValue) { + $chain = [$primaryValue]; // safe default: just the value itself + foreach ($this->getAllPresets()[$dimensionName]['presets'] ?? [] as $preset) { + $values = $preset['values'] ?? null; + if ($values !== null && $values[0] === $primaryValue) { + $chain = $values; + break; + } + } + $perDimensionChains[$dimensionName] = $chain; + } + + // Build Cartesian product of all per-dimension chains, tracking fallback distance per combo + $combos = [['coords' => [], 'distance' => 0]]; + foreach ($perDimensionChains as $dimensionName => $chain) { + $expanded = []; + foreach ($combos as $combo) { + foreach ($chain as $index => $value) { + $expanded[] = [ + 'coords' => array_merge($combo['coords'], [$dimensionName => $value]), + 'distance' => $combo['distance'] + $index, + ]; + } + } + $combos = $expanded; + } + + // Sort by total fallback distance: most specific (0) first + usort($combos, fn ($a, $b) => $a['distance'] <=> $b['distance']); + + $spacePoints = array_map( + fn ($combo) => MetaDataDimensionSpacePoint::fromCoordinates($combo['coords']), + $combos + ); + + return MetaDataDimensionSpacePoints::create(...$spacePoints); + } + + public function isDimensionSpacePointValid(MetaDataDimensionSpacePoint $dimensionSpacePoint): bool + { + if (empty($this->getAllPresets())) { + return $dimensionSpacePoint->coordinates === []; + } + + if (count($dimensionSpacePoint->coordinates) !== count($this->getAllPresets())) { + return false; + } + + $presetIdentifiers = []; + foreach ($dimensionSpacePoint->coordinates as $dimensionName => $value) { + foreach ($this->getAllPresets()[$dimensionName]['presets'] ?? [] as $presetIdentifier => $preset) { + $values = $preset['values'] ?? null; + if ($values !== null && $values[0] === $value) { + $presetIdentifiers[$dimensionName] = $presetIdentifier; + break; + } + } + if (!isset($presetIdentifiers[$dimensionName])) { + return false; + } + } + + return $this->configurationContentDimensionPresetSource->isPresetCombinationAllowedByConstraints($presetIdentifiers); + } + + /** + * The cartesian product of all configured dimension presets. + * + * A coordinate is the primary value of a preset, not its identifier – the two are usually the same + * but need not be (a preset "german" can have the values ["de"]). Everything else in this class + * works with values: the fallback chain matches on `values[0]` and the default dimension space point + * is built from the `default` of each dimension. + * + * Presets without values are skipped, because they could never be matched anyway. + */ + /** + * @param array + * }> + * }> $input + * @return list> + */ + function createAllPresetCombinations(array $input): array + { + $result = [[]]; + foreach ($input as $dimensionName => $dimensionConfig) { + $append = []; + foreach ($dimensionConfig['presets'] ?? [] as $preset) { + if (!isset($preset['values'][0])) { + continue; + } + foreach ($result as $coordinates) { + $append[] = $coordinates + [$dimensionName => (string) $preset['values'][0]]; + } + } + $result = $append; + } + + return $result; + } +} diff --git a/Classes/Domain/Collection/MetaDataCollection.php b/Classes/Domain/Collection/MetaDataCollection.php deleted file mode 100644 index 5ed8a8f..0000000 --- a/Classes/Domain/Collection/MetaDataCollection.php +++ /dev/null @@ -1,21 +0,0 @@ -properties = Arrays::arrayMergeRecursiveOverrule($this->properties, $properties); - } -} diff --git a/Classes/Domain/Dto/Asset.php b/Classes/Domain/Dto/Asset.php deleted file mode 100644 index ec77a7a..0000000 --- a/Classes/Domain/Dto/Asset.php +++ /dev/null @@ -1,98 +0,0 @@ - '', - 'CopyrightNotice' => '', - 'Collections' => [], - 'FileName' => '', - 'Identifier' => '', - 'Tags' => [], - 'Title' => '', - 'AssetObject' => null, - ]; - - /** - * @return string - */ - public function getIdentifier() - { - return $this->properties['Identifier']; - } - - /** - * @return string - */ - public function getCaption() - { - return $this->properties['Caption']; - } - - /** - * @return string - */ - public function getCopyrightNotice() - { - return $this->properties['CopyrightNotice']; - } - - /** - * @return array - */ - public function getCollections() - { - return $this->properties['Collections']; - } - - /** - * @return string - */ - public function getFileName() - { - return $this->properties['FileName']; - } - - /** - * @return array - */ - public function getTags() - { - return $this->properties['Tags']; - } - - /** - * @return string - */ - public function getTitle() - { - return $this->properties['Title']; - } - - /** - * @return AssetInterface - */ - public function getAssetObject() - { - return $this->properties['AssetObject']; - } -} diff --git a/Classes/Domain/Dto/Exif.php b/Classes/Domain/Dto/Exif.php deleted file mode 100644 index 4d3b0bc..0000000 --- a/Classes/Domain/Dto/Exif.php +++ /dev/null @@ -1,1179 +0,0 @@ - 0, - 'ImageLength' => 0, - 'BitsPerSample' => [0, 0, 0], - 'Compression' => '', - 'PhotometricInterpretation' => '', - 'Orientation' => '', - 'SamplesPerPixel' => 0, - 'PlanarConfiguration' => '', - 'YCbCrSubSampling' => '', - 'YCbCrPositioning' => '', - 'XResolution' => 0.0, - 'YResolution' => 0.0, - 'ResolutionUnit' => '', - 'StripOffsets' => [], - 'RowsPerStrip' => 0, - 'StripByteCounts' => [], - 'JPEGInterchangeFormat' => 0, - 'JPEGInterchangeFormatLength' => 0, - 'TransferFunction' => [], - 'WhitePoint' => [], - 'PrimaryChromaticities' => [], - 'YCbCrCoefficients' => [], - 'ReferenceBlackWhite' => [], - 'DateTime' => null, - 'ImageDescription' => '', - 'Make' => '', - 'Model' => '', - 'Software' => '', - 'Artist' => '', - 'Copyright' => '', - 'ExifVersion' => '', - 'FlashpixVersion' => '', - 'ColorSpace' => '', - 'Gamma' => 0.0, - 'ComponentsConfiguration' => '', - 'CompressedBitsPerPixel' => 0.0, - 'PixelXDimension' => 0, - 'PixelYDimension' => 0, - 'MakerNote' => '', - 'UserComment' => '', - 'RelatedSoundFile' => '', - 'DateTimeOriginal' => null, - 'DateTimeDigitized' => null, - 'ExposureTime' => 0.0, - 'FNumber' => 0.0, - 'ExposureProgram' => '', - 'SpectralSensitivity' => '', - 'PhotographicSensitivity' => 0, - 'OECF' => [], - 'SensitivityType' => '', - 'StandardOutputSensitivity' => 0, - 'RecommendedExposureIndex' => 0, - 'ISOSpeed' => 0, - 'ISOSpeedLatitudeyyy' => 0, - 'ISOSpeedLatitudezzz' => 0, - 'ShutterSpeedValue' => 0.0, - 'ApertureValue' => 0.0, - 'BrightnessValue' => 0.0, - 'ExposureBiasValue' => 0.0, - 'MaxApertureValue' => 0.0, - 'SubjectDistance' => 0, - 'MeteringMode' => '', - 'LightSource' => '', - 'Flash' => '', - 'FocalLength' => 0.0, - 'SubjectArea' => [0, 0], - 'FlashEnergy' => 0.0, - 'SpatialFrequencyResponse' => [], - 'FocalPlaneXResolution' => 0.0, - 'FocalPlaneYResolution' => 0.0, - 'FocalPlaneResolutionUnit' => '', - 'SubjectLocation' => [0, 0], - 'ExposureIndex' => 0.0, - 'SensingMethod' => '', - 'FileSource' => '', - 'SceneType' => '', - 'CFAPattern' => [], - 'CustomRendered' => '', - 'ExposureMode' => '', - 'WhiteBalance' => '', - 'DigitalZoomRatio' => 0.0, - 'FocalLengthIn35mmFilm' => 0, - 'SceneCaptureType' => '', - 'GainControl' => '', - 'Contrast' => '', - 'Saturation' => '', - 'Sharpness' => '', - 'DeviceSettingDescription' => [], - 'SubjectDistanceRange' => '', - 'Temperature' => 0.0, - 'Humidity' => 0.0, - 'Pressure' => 0.0, - 'WaterDepth' => 0.0, - 'Acceleration' => 0.0, - 'CameraElevationAngle' => 0.0, - 'ImageUniqueID' => '', - 'CameraOwnerName' => '', - 'BodySerialNumber' => '', - 'LensSpecification' => [ - 0.0, // Minimum focal length (mm) - 0.0, // Maximum focal length (mm) - 0.0, // Minimum F number in the minimum focal length - 0.0 // Minimum F number in the maximum focal length - ], - 'LensMake' => '', - 'LensModel' => '', - 'LensSerialNumber' => '', - 'GPSVersionID' => '', - 'GPSLatitude' => 0.0, - 'GPSLongitude' => 0.0, - 'GPSAltitude' => 0.0, - 'GPSSatellites' => '', - 'GPSStatus' => '', - 'GPSMeasureMode' => '', - 'GPSDOP' => 0.0, - 'GPSSpeedRef' => '', - 'GPSSpeed' => 0.0, - 'GPSTrackRef' => '', - 'GPSTrack' => 0.0, - 'GPSImgDirectionRef' => '', - 'GPSImgDirection' => 0.0, - 'GPSMapDatum' => '', - 'GPSDestLatitude' => 0.0, - 'GPSDestLongitude' => 0.0, - 'GPSDestBearingRef' => '', - 'GPSDestBearing' => 0.0, - 'GPSDestDistanceRef' => '', - 'GPSDestDistance' => 0.0, - 'GPSProcessingMethod' => '', - 'GPSAreaInformation' => '', - 'GPSDateTimeStamp' => null, - 'GPSDifferential' => '', - 'GPSHPositioningError' => 0.0, - ]; - - /** - * @return int - */ - public function getImageWidth() - { - return $this->properties['ImageWidth']; - } - - /** - * @return int - */ - public function getImageLength() - { - return $this->properties['ImageLength']; - } - - /** - * @return array - */ - public function getBitsPerSample() - { - return $this->properties['BitsPerSample']; - } - - /** - * @return string - */ - public function getCompression() - { - return $this->properties['Compression']; - } - - /** - * @return string - */ - public function getPhotometricInterpretation() - { - return $this->properties['PhotometricInterpretation']; - } - - /** - * @return string - */ - public function getOrientation() - { - return $this->properties['Orientation']; - } - - /** - * @return int - */ - public function getSamplesPerPixel() - { - return $this->properties['SamplesPerPixel']; - } - - /** - * @return string - */ - public function getPlanarConfiguration() - { - return $this->properties['PlanarConfiguration']; - } - - /** - * @return string - */ - public function getYCbCrSubSampling() - { - return $this->properties['YCbCrSubSampling']; - } - - /** - * @return string - */ - public function getYCbCrPositioning() - { - return $this->properties['YCbCrPositioning']; - } - - /** - * @return float - */ - public function getXResolution() - { - return $this->properties['XResolution']; - } - - /** - * @return float - */ - public function getYResolution() - { - return $this->properties['YResolution']; - } - - /** - * @return string - */ - public function getResolutionUnit() - { - return $this->properties['ResolutionUnit']; - } - - /** - * @return array - */ - public function getStripOffsets() - { - return $this->properties['StripOffsets']; - } - - /** - * @return int - */ - public function getRowsPerStrip() - { - return $this->properties['RowsPerStrip']; - } - - /** - * @return array - */ - public function getStripByteCounts() - { - return $this->properties['StripByteCounts']; - } - - /** - * @return int - */ - public function getJPEGInterchangeFormat() - { - return $this->properties['JPEGInterchangeFormat']; - } - - /** - * @return int - */ - public function getJPEGInterchangeFormatLength() - { - return $this->properties['JPEGInterchangeFormatLength']; - } - - /** - * @return array - */ - public function getTransferFunction() - { - return $this->properties['TransferFunction']; - } - - /** - * @return array - */ - public function getWhitePoint() - { - return $this->properties['WhitePoint']; - } - - /** - * @return array - */ - public function getPrimaryChromaticities() - { - return $this->properties['PrimaryChromaticities']; - } - - /** - * @return array - */ - public function getYCbCrCoefficients() - { - return $this->properties['YCbCrCoefficients']; - } - - /** - * @return array - */ - public function getReferenceBlackWhite() - { - return $this->properties['ReferenceBlackWhite']; - } - - /** - * @return \DateTime - */ - public function getDateTime() - { - return $this->properties['DateTime']; - } - - /** - * @return string - */ - public function getImageDescription() - { - return $this->properties['ImageDescription']; - } - - /** - * @return string - */ - public function getMake() - { - return $this->properties['Make']; - } - - /** - * @return string - */ - public function getModel() - { - return $this->properties['Model']; - } - - /** - * @return string - */ - public function getSoftware() - { - return $this->properties['Software']; - } - - /** - * @return string - */ - public function getArtist() - { - return $this->properties['Artist']; - } - - /** - * @return string - */ - public function getCopyright() - { - return $this->properties['Copyright']; - } - - /** - * @return string - */ - public function getExifVersion() - { - return $this->properties['ExifVersion']; - } - - /** - * @return string - */ - public function getFlashpixVersion() - { - return $this->properties['FlashpixVersion']; - } - - /** - * @return string - */ - public function getColorSpace() - { - return $this->properties['ColorSpace']; - } - - /** - * @return float - */ - public function getGamma() - { - return $this->properties['Gamma']; - } - - /** - * @return string - */ - public function getComponentsConfiguration() - { - return $this->properties['ComponentsConfiguration']; - } - - /** - * @return float - */ - public function getCompressedBitsPerPixel() - { - return $this->properties['CompressedBitsPerPixel']; - } - - /** - * @return int - */ - public function getPixelXDimension() - { - return $this->properties['PixelXDimension']; - } - - /** - * @return int - */ - public function getPixelYDimension() - { - return $this->properties['PixelYDimension']; - } - - /** - * @return string - */ - public function getMakerNote() - { - return $this->properties['MakerNote']; - } - - /** - * @return string - */ - public function getUserComment() - { - return $this->properties['UserComment']; - } - - /** - * @return string - */ - public function getRelatedSoundFile() - { - return $this->properties['RelatedSoundFile']; - } - - /** - * @return \DateTime - */ - public function getDateTimeOriginal() - { - return $this->properties['DateTimeOriginal']; - } - - /** - * @return \DateTime - */ - public function getDateTimeDigitized() - { - return $this->properties['DateTimeDigitized']; - } - - /** - * @return float - */ - public function getExposureTime() - { - return $this->properties['ExposureTime']; - } - - /** - * @return float - */ - public function getFNumber() - { - return $this->properties['FNumber']; - } - - /** - * @return string - */ - public function getExposureProgram() - { - return $this->properties['ExposureProgram']; - } - - /** - * @return string - */ - public function getSpectralSensitivity() - { - return $this->properties['SpectralSensitivity']; - } - - /** - * @return int - */ - public function getPhotographicSensitivity() - { - return $this->properties['PhotographicSensitivity']; - } - - /** - * @return array - */ - public function getOECF() - { - return $this->properties['OECF']; - } - - /** - * @return string - */ - public function getSensitivityType() - { - return $this->properties['SensitivityType']; - } - - /** - * @return int - */ - public function getStandardOutputSensitivity() - { - return $this->properties['StandardOutputSensitivity']; - } - - /** - * @return int - */ - public function getRecommendedExposureIndex() - { - return $this->properties['RecommendedExposureIndex']; - } - - /** - * @return int - */ - public function getISOSpeed() - { - return $this->properties['ISOSpeed']; - } - - /** - * @return int - */ - public function getISOSpeedLatitudeyyy() - { - return $this->properties['ISOSpeedLatitudeyyy']; - } - - /** - * @return int - */ - public function getISOSpeedLatitudezzz() - { - return $this->properties['ISOSpeedLatitudezzz']; - } - - /** - * @return float - */ - public function getShutterSpeedValue() - { - return $this->properties['ShutterSpeedValue']; - } - - /** - * @return float - */ - public function getApertureValue() - { - return $this->properties['ApertureValue']; - } - - /** - * @return float - */ - public function getBrightnessValue() - { - return $this->properties['BrightnessValue']; - } - - /** - * @return float - */ - public function getExposureBiasValue() - { - return $this->properties['ExposureBiasValue']; - } - - /** - * @return float - */ - public function getMaxApertureValue() - { - return $this->properties['MaxApertureValue']; - } - - /** - * @return int - */ - public function getSubjectDistance() - { - return $this->properties['SubjectDistance']; - } - - /** - * @return string - */ - public function getMeteringMode() - { - return $this->properties['MeteringMode']; - } - - /** - * @return string - */ - public function getLightSource() - { - return $this->properties['LightSource']; - } - - /** - * @return string - */ - public function getFlash() - { - return $this->properties['Flash']; - } - - /** - * @return float - */ - public function getFocalLength() - { - return $this->properties['FocalLength']; - } - - /** - * @return array - */ - public function getSubjectArea() - { - return $this->properties['SubjectArea']; - } - - /** - * @return float - */ - public function getFlashEnergy() - { - return $this->properties['FlashEnergy']; - } - - /** - * @return array - */ - public function getSpatialFrequencyResponse() - { - return $this->properties['SpatialFrequencyResponse']; - } - - /** - * @return float - */ - public function getFocalPlaneXResolution() - { - return $this->properties['FocalPlaneXResolution']; - } - - /** - * @return float - */ - public function getFocalPlaneYResolution() - { - return $this->properties['FocalPlaneYResolution']; - } - - /** - * @return string - */ - public function getFocalPlaneResolutionUnit() - { - return $this->properties['FocalPlaneResolutionUnit']; - } - - /** - * @return array - */ - public function getSubjectLocation() - { - return $this->properties['SubjectLocation']; - } - - /** - * @return float - */ - public function getExposureIndex() - { - return $this->properties['ExposureIndex']; - } - - /** - * @return string - */ - public function getSensingMethod() - { - return $this->properties['SensingMethod']; - } - - /** - * @return string - */ - public function getFileSource() - { - return $this->properties['FileSource']; - } - - /** - * @return string - */ - public function getSceneType() - { - return $this->properties['SceneType']; - } - - /** - * @return array - */ - public function getCFAPattern() - { - return $this->properties['CFAPattern']; - } - - /** - * @return string - */ - public function getCustomRendered() - { - return $this->properties['CustomRendered']; - } - - /** - * @return string - */ - public function getExposureMode() - { - return $this->properties['ExposureMode']; - } - - /** - * @return string - */ - public function getWhiteBalance() - { - return $this->properties['WhiteBalance']; - } - - /** - * @return float - */ - public function getDigitalZoomRatio() - { - return $this->properties['DigitalZoomRatio']; - } - - /** - * @return int - */ - public function getFocalLengthIn35mmFilm() - { - return $this->properties['FocalLengthIn35mmFilm']; - } - - /** - * @return string - */ - public function getSceneCaptureType() - { - return $this->properties['SceneCaptureType']; - } - - /** - * @return string - */ - public function getGainControl() - { - return $this->properties['GainControl']; - } - - /** - * @return string - */ - public function getContrast() - { - return $this->properties['Contrast']; - } - - /** - * @return string - */ - public function getSaturation() - { - return $this->properties['Saturation']; - } - - /** - * @return string - */ - public function getSharpness() - { - return $this->properties['Sharpness']; - } - - /** - * @return array - */ - public function getDeviceSettingDescription() - { - return $this->properties['DeviceSettingDescription']; - } - - /** - * @return string - */ - public function getSubjectDistanceRange() - { - return $this->properties['SubjectDistanceRange']; - } - - /** - * @return float - */ - public function getTemperature() - { - return $this->properties['Temperature']; - } - - /** - * @return float - */ - public function getHumidity() - { - return $this->properties['Humidity']; - } - - /** - * @return float - */ - public function getPressure() - { - return $this->properties['Pressure']; - } - - /** - * @return float - */ - public function getWaterDepth() - { - return $this->properties['WaterDepth']; - } - - /** - * @return float - */ - public function getAcceleration() - { - return $this->properties['Acceleration']; - } - - /** - * @return float - */ - public function getCameraElevationAngle() - { - return $this->properties['CameraElevationAngle']; - } - - /** - * @return string - */ - public function getImageUniqueID() - { - return $this->properties['ImageUniqueID']; - } - - /** - * @return string - */ - public function getCameraOwnerName() - { - return $this->properties['CameraOwnerName']; - } - - /** - * @return string - */ - public function getBodySerialNumber() - { - return $this->properties['BodySerialNumber']; - } - - /** - * @return array - */ - public function getLensSpecification() - { - return $this->properties['LensSpecification']; - } - - /** - * @return string - */ - public function getLensMake() - { - return $this->properties['LensMake']; - } - - /** - * @return string - */ - public function getLensModel() - { - return $this->properties['LensModel']; - } - - /** - * @return string - */ - public function getLensSerialNumber() - { - return $this->properties['LensSerialNumber']; - } - - /** - * @return string - */ - public function getGPSVersionID() - { - return $this->properties['GPSVersionID']; - } - - /** - * @return float - */ - public function getGPSLatitude() - { - return $this->properties['GPSLatitude']; - } - - /** - * @return float - */ - public function getGPSLongitude() - { - return $this->properties['GPSLongitude']; - } - - /** - * @return float - */ - public function getGPSAltitude() - { - return $this->properties['GPSAltitude']; - } - - /** - * @return string - */ - public function getGPSSatellites() - { - return $this->properties['GPSSatellites']; - } - - /** - * @return string - */ - public function getGPSStatus() - { - return $this->properties['GPSStatus']; - } - - /** - * @return string - */ - public function getGPSMeasureMode() - { - return $this->properties['GPSMeasureMode']; - } - - /** - * @return float - */ - public function getGPSDOP() - { - return $this->properties['GPSDOP']; - } - - /** - * @return string - */ - public function getGPSSpeedRef() - { - return $this->properties['GPSSpeedRef']; - } - - /** - * @return float - */ - public function getGPSSpeed() - { - return $this->properties['GPSSpeed']; - } - - /** - * @return string - */ - public function getGPSTrackRef() - { - return $this->properties['GPSTrackRef']; - } - - /** - * @return float - */ - public function getGPSTrack() - { - return $this->properties['GPSTrack']; - } - - /** - * @return string - */ - public function getGPSImgDirectionRef() - { - return $this->properties['GPSImgDirectionRef']; - } - - /** - * @return float - */ - public function getGPSImgDirection() - { - return $this->properties['GPSImgDirection']; - } - - /** - * @return string - */ - public function getGPSMapDatum() - { - return $this->properties['GPSMapDatum']; - } - - /** - * @return float - */ - public function getGPSDestLatitude() - { - return $this->properties['GPSDestLatitude']; - } - - /** - * @return float - */ - public function getGPSDestLongitude() - { - return $this->properties['GPSDestLongitude']; - } - - /** - * @return string - */ - public function getGPSDestBearingRef() - { - return $this->properties['GPSDestBearingRef']; - } - - /** - * @return float - */ - public function getGPSDestBearing() - { - return $this->properties['GPSDestBearing']; - } - - /** - * @return string - */ - public function getGPSDestDistanceRef() - { - return $this->properties['GPSDestDistanceRef']; - } - - /** - * @return float - */ - public function getGPSDestDistance() - { - return $this->properties['GPSDestDistance']; - } - - /** - * @return string - */ - public function getGPSProcessingMethod() - { - return $this->properties['GPSProcessingMethod']; - } - - /** - * @return string - */ - public function getGPSAreaInformation() - { - return $this->properties['GPSAreaInformation']; - } - - /** - * @return \DateTime - */ - public function getGPSDateTimeStamp() - { - return $this->properties['GPSDateTimeStamp']; - } - - /** - * @return string - */ - public function getGPSDifferential() - { - return $this->properties['GPSDifferential']; - } - - /** - * @return float - */ - public function getGPSHPositioningError() - { - return $this->properties['GPSHPositioningError']; - } -} diff --git a/Classes/Domain/Dto/Iptc.php b/Classes/Domain/Dto/Iptc.php deleted file mode 100644 index a6b0176..0000000 --- a/Classes/Domain/Dto/Iptc.php +++ /dev/null @@ -1,229 +0,0 @@ - '', - 'Contact' => [], - 'CopyrightNotice' => '', - 'Country' => '', - 'CountryCode' => '', - 'CreationDate' => null, - 'Creator' => [], - 'CreatorTitle' => [], - 'CreditLine' => '', - 'DeprecatedCategories' => [], - 'Description' => '', - 'DescriptionWriter' => [], - 'DigitalCreationDate' => null, - 'Headline' => '', - 'Instructions' => '', - 'IntellectualGenres' => [], - 'JobId' => '', - 'Keywords' => [], - 'Source' => '', - 'State' => '', - 'SubjectCodes' => [], - 'Sublocation' => '', - 'Title' => '', - ]; - - /** - * @return string - */ - public function getCity() - { - return $this->properties['City']; - } - - /** - * @return array - */ - public function getContact() - { - return $this->properties['Contact']; - } - - /** - * @return string - */ - public function getCopyrightNotice() - { - return $this->properties['CopyrightNotice']; - } - - /** - * @return string - */ - public function getCountry() - { - return $this->properties['Country']; - } - - /** - * @return string - */ - public function getCountryCode() - { - return $this->properties['CountryCode']; - } - - /** - * @return \DateTime - */ - public function getCreationDate() - { - return $this->properties['CreationDate']; - } - - /** - * @return array - */ - public function getCreator() - { - return $this->properties['Creator']; - } - - /** - * @return array - */ - public function getCreatorTitle() - { - return $this->properties['CreatorTitle']; - } - - /** - * @return string - */ - public function getCreditLine() - { - return $this->properties['CreditLine']; - } - - /** - * @return array - */ - public function getDeprecatedCategories() - { - return $this->properties['DeprecatedCategories']; - } - - /** - * @return string - */ - public function getDescription() - { - return $this->properties['Description']; - } - - /** - * @return array - */ - public function getDescriptionWriter() - { - return $this->properties['DescriptionWriter']; - } - - /** - * @return \DateTime - */ - public function getDigitalCreationDate() - { - return $this->properties['DigitalCreationDate']; - } - - /** - * @return string - */ - public function getHeadline() - { - return $this->properties['Headline']; - } - - /** - * @return string - */ - public function getInstructions() - { - return $this->properties['Instructions']; - } - - /** - * @return array - */ - public function getIntellectualGenres() - { - return $this->properties['IntellectualGenres']; - } - - /** - * @return string - */ - public function getJobId() - { - return $this->properties['JobId']; - } - - /** - * @return array - */ - public function getKeywords() - { - return $this->properties['Keywords']; - } - - /** - * @return string - */ - public function getSource() - { - return $this->properties['Source']; - } - - /** - * @return string - */ - public function getState() - { - return $this->properties['State']; - } - - /** - * @return array - */ - public function getSubjectCodes() - { - return $this->properties['SubjectCodes']; - } - - /** - * @return string - */ - public function getSublocation() - { - return $this->properties['Sublocation']; - } - - /** - * @return string - */ - public function getTitle() - { - return $this->properties['Title']; - } -} 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 @@ + DimensionValue. + * E.g.: ["language" => "es", "country" => "ar"] + */ +final readonly class MetaDataDimensionSpacePoint implements Stringable { + + /** + * @param array $coordinates + * @param string $hash + */ + private function __construct( + public array $coordinates, + public string $hash, + ) { + } + + /** + * @param array $coordinates + */ + private static function hashCoordinates(array $coordinates): string + { + $identityComponents = $coordinates; + ksort($identityComponents); + try { + return md5(json_encode($identityComponents, JSON_THROW_ON_ERROR)); + } catch (JsonException $e) { + throw new \InvalidArgumentException('Failed to hash coordinates: ' . $e->getMessage(), 1776251973, $e); + } + } + + /** + * @param array $coordinates + */ + public static function fromCoordinates(array $coordinates): self + { + return new self( + $coordinates, + self::hashCoordinates($coordinates), + ); + } + + public function equals(self $other): bool + { + return $this->hash === $other->hash; + } + + public function __toString(): string + { + return (string) json_encode($this->coordinates); + } +} diff --git a/Classes/Domain/Dto/MetaDataDimensionSpacePoints.php b/Classes/Domain/Dto/MetaDataDimensionSpacePoints.php new file mode 100644 index 0000000..60d0a44 --- /dev/null +++ b/Classes/Domain/Dto/MetaDataDimensionSpacePoints.php @@ -0,0 +1,60 @@ + + */ +final readonly class MetaDataDimensionSpacePoints implements IteratorAggregate, Countable { + + /** + * @param list $spacePoints + */ + private function __construct( + private array $spacePoints, + ) { + } + + public static function create(MetaDataDimensionSpacePoint ...$spacePoints): self + { + return new self(array_values($spacePoints)); + } + + public function include(MetaDataDimensionSpacePoint $dimensionSpacePoint): bool + { + foreach ($this->spacePoints as $spacePoint) { + if ($spacePoint->equals($dimensionSpacePoint)) { + return true; + } + } + return false; + } + + public function getIterator(): Traversable + { + yield from $this->spacePoints; + } + + /** + * @template T + * @param Closure(MetaDataDimensionSpacePoint): T $callback + * @return T[] + */ + public function map(Closure $callback): array + { + return array_map($callback, $this->spacePoints); + } + + public function count(): int + { + return count($this->spacePoints); + } +} diff --git a/Classes/Domain/Dto/MetaDataEditorDefinition.php b/Classes/Domain/Dto/MetaDataEditorDefinition.php new file mode 100644 index 0000000..e3bac14 --- /dev/null +++ b/Classes/Domain/Dto/MetaDataEditorDefinition.php @@ -0,0 +1,31 @@ + $options Editor specific options (e.g. ['rows' => 3]) + */ + private function __construct( + public string|null $editorType, + public array $options, + ) { + } + + public static function default(): self + { + return new self(null, []); + } + + /** + * @param array $options Editor specific options (e.g. ['rows' => 3]) + */ + public static function create(string|null $editorType, array $options): self + { + return new self($editorType, $options); + } +} diff --git a/Classes/Domain/Dto/MetaDataGlobalScope.php b/Classes/Domain/Dto/MetaDataGlobalScope.php new file mode 100644 index 0000000..f926f8a --- /dev/null +++ b/Classes/Domain/Dto/MetaDataGlobalScope.php @@ -0,0 +1,31 @@ + + */ +final readonly class MetaDataPropertyDefinitions implements IteratorAggregate { + + /** + * @param array $propertyDefinitionsByName + */ + private function __construct( + private array $propertyDefinitionsByName, + ) { + } + + public static function create(MetaDataPropertyDefinition ...$propertyDefinitions): self + { + $propertyDefinitionsByName = []; + foreach ($propertyDefinitions as $propertyDefinition) { + $propertyDefinitionsByName[$propertyDefinition->name->value] = $propertyDefinition; + } + return new self($propertyDefinitionsByName); + } + + public function include(MetaDataPropertyName $propertyName): bool + { + return array_key_exists($propertyName->value, $this->propertyDefinitionsByName); + } + + public function get(MetaDataPropertyName $propertyName): MetaDataPropertyDefinition + { + if (!array_key_exists($propertyName->value, $this->propertyDefinitionsByName)) { + throw new InvalidArgumentException(sprintf('Metadata property "%s" is not defined', $propertyName), 1776278182); + } + return $this->propertyDefinitionsByName[$propertyName->value]; + } + + public function getIterator(): Traversable + { + yield from $this->propertyDefinitionsByName; + } +} diff --git a/Classes/Domain/Dto/MetaDataPropertyName.php b/Classes/Domain/Dto/MetaDataPropertyName.php new file mode 100644 index 0000000..48d4539 --- /dev/null +++ b/Classes/Domain/Dto/MetaDataPropertyName.php @@ -0,0 +1,33 @@ +value; + } + + public function equals(string $propertyName): bool + { + return $this->value === $propertyName; + } +} diff --git a/Classes/Domain/Dto/MetaDataPropertyNames.php b/Classes/Domain/Dto/MetaDataPropertyNames.php new file mode 100644 index 0000000..05d2441 --- /dev/null +++ b/Classes/Domain/Dto/MetaDataPropertyNames.php @@ -0,0 +1,77 @@ + + */ +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 new file mode 100644 index 0000000..4548739 --- /dev/null +++ b/Classes/Domain/Dto/MetaDataPropertyType.php @@ -0,0 +1,93 @@ +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/MetaDataPropertyUiDefinition.php b/Classes/Domain/Dto/MetaDataPropertyUiDefinition.php new file mode 100644 index 0000000..af45fda --- /dev/null +++ b/Classes/Domain/Dto/MetaDataPropertyUiDefinition.php @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..091276f --- /dev/null +++ b/Classes/Domain/Dto/MetaDataPropertyValues.php @@ -0,0 +1,59 @@ + + */ +final readonly class MetaDataPropertyValues implements IteratorAggregate { + + /** + * @param array $values + */ + private function __construct( + private array $values, + ) { + } + + public static function createEmpty(): self + { + return new 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) { + yield MetaDataPropertyName::fromString($propertyName) => $value; + } + } +} diff --git a/Classes/Helper/AssetMetaDataHelper.php b/Classes/Helper/AssetMetaDataHelper.php new file mode 100644 index 0000000..6e12176 --- /dev/null +++ b/Classes/Helper/AssetMetaDataHelper.php @@ -0,0 +1,54 @@ + $coordinates dimension coordinates, e.g. ['language' => 'de']. Empty = the default dimension + * @return array + */ + public function getMetaData(Asset $asset, array $coordinates = []): array + { + return $this->metaDataManager->getMetaDataPropertyValues( + MetaDataAssetReference::create($asset->assetSourceIdentifier, $asset->getIdentifier()), + $coordinates === [] ? null : MetaDataDimensionSpacePoint::fromCoordinates($coordinates), + )->toArray(); + } + + /** + * 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 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 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 @@ +defaultContextVariables === null) { - $this->defaultContextVariables = EelUtility::getDefaultContextVariables($this->defaultEelContext); - } - } - - /** - * @param Asset $asset - * @param MetaDataCollection $metaDataCollection - */ - public function mapMetaData(Asset $asset, MetaDataCollection $metaDataCollection) - { - if ($asset->getResource()->isDeleted()) { - return; - } - - $contextVariables = array_merge($this->defaultContextVariables, $metaDataCollection->toArray()); - - if (isset($this->metaDataMappingConfiguration['title'])) { - $asset->setTitle(substr((string)EelUtility::evaluateEelExpression($this->metaDataMappingConfiguration['title'], $this->eelEvaluator, $contextVariables), 0, 255)); - } - - if (isset($this->metaDataMappingConfiguration['caption'])) { - $asset->setCaption((string)EelUtility::evaluateEelExpression($this->metaDataMappingConfiguration['caption'], $this->eelEvaluator, $contextVariables)); - } - - if (isset($this->metaDataMappingConfiguration['copyrightNotice']) && method_exists($asset, 'setCopyrightNotice')) { - $asset->setCopyrightNotice((string)EelUtility::evaluateEelExpression($this->metaDataMappingConfiguration['copyrightNotice'], $this->eelEvaluator, $contextVariables)); - } - - if (isset($this->metaDataMappingConfiguration['tags'])) { - $tagLabels = EelUtility::evaluateEelExpression($this->metaDataMappingConfiguration['tags'], $this->eelEvaluator, $contextVariables); - $tagLabels = array_unique($tagLabels); - - $tags = new ArrayCollection(); - foreach ($tagLabels as $tagLabel) { - if (trim($tagLabel) !== '') { - $tags->add($this->getOrCreateTag(trim($tagLabel))); - } - } - $asset->setTags($tags); - } - - if (isset($this->metaDataMappingConfiguration['collections'])) { - $collectionTitles = EelUtility::evaluateEelExpression($this->metaDataMappingConfiguration['collections'], $this->eelEvaluator, $contextVariables); - $collectionTitles = array_unique($collectionTitles); - - $collections = new ArrayCollection(); - foreach ($collectionTitles as $collectionTitle) { - if (trim($collectionTitle) !== '') { - $collections->add($this->getOrCreateCollection(trim($collectionTitle))); - } - } - $asset->setAssetCollections($collections); - } - - if (!$this->persistenceManager->isNewObject($asset)) { - $this->assetRepository->update($asset); - } - } - - /** - * @param string $label - * - * @return Tag - */ - protected function getOrCreateTag($label) - { - if (isset($this->tagFirstLevelCache[$label])) { - return $this->tagFirstLevelCache[$label]; - } - - $tag = $this->tagRepository->findOneByLabel($label); - - if ($tag === null) { - $tag = new Tag($label); - $this->tagRepository->add($tag); - } - - $this->tagFirstLevelCache[$label] = $tag; - - return $tag; - } - - /** - * @param string $title - * - * @return AssetCollection - */ - protected function getOrCreateCollection($title) - { - if (isset($this->collectionFirstLevelCache[$title])) { - return $this->collectionFirstLevelCache[$title]; - } - - $collection = $this->collectionRepository->findOneByTitle($title); - - if ($collection === null) { - $collection = new AssetCollection($title); - $this->collectionRepository->add($collection); - } - - $this->collectionFirstLevelCache[$title] = $collection; - - return $collection; - } -} diff --git a/Classes/Mapper/MetaDataMapperInterface.php b/Classes/Mapper/MetaDataMapperInterface.php deleted file mode 100644 index 687ec22..0000000 --- a/Classes/Mapper/MetaDataMapperInterface.php +++ /dev/null @@ -1,26 +0,0 @@ -propertyDefinitions; + } + + public function getDimensionSpacePointConfiguration(): MetaDataDimensionSpacePoints + { + return $this->dimensionSpacePointProvider->getDimensionSpacePoints(); + } + + /** + * Sets the value of a single metadata property. + * + * The value is coerced to the type the property is defined for, so that callers which only ever + * have strings – the command line, form input, Fusion – do not have to cast. A value that cannot be + * interpreted as that type is rejected rather than silently turned into a wrong one, + * see {@see MetaDataPropertyType::coerceForStorage()}. + */ + public function setMetaDataPropertyValue( + MetaDataAssetReference $assetReference, + MetaDataPropertyName|string $propertyName, + string|int|bool $value, + ?MetaDataDimensionSpacePoint $dimensionSpacePoint = null, + ): void { + $propertyDefinition = $this->propertyDefinition($propertyName); + + // 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 = null, + ): void { + $propertyDefinition = $this->propertyDefinition($propertyName); + + // TODO: ACL + $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, + ?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 = null, + ): MetaDataPropertyValues { + $propertyValues = MetaDataPropertyValues::createEmpty(); + foreach ($this->propertyDefinitions as $propertyDefinition) { + $propertyValues = $propertyValues->with( + $propertyDefinition->name, + $this->resolvePropertyValue($assetReference, $propertyDefinition, $dimensionSpacePoint), + ); + } + return $propertyValues; + } -/** - * @Flow\Scope("singleton") - */ -class MetaDataManager -{ /** - * @Flow\Inject - * @var AssetModelMetaDataMapper + * 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 */ - protected $assetModelMetaDataMapper; + public function findAssets(MetaDataAssetFilter $filter): iterable + { + $localizedPropertyNames = []; + $globalScopePropertyNames = []; + foreach ($this->filteredPropertyDefinitions($filter->propertyNames) as $propertyDefinition) { + if ($propertyDefinition->globalScope) { + $globalScopePropertyNames[] = $propertyDefinition->name; + } else { + $localizedPropertyNames[] = $propertyDefinition->name; + } + } + + return $this->storage->findAssets( + $filter->assetSourceId, + $filter->searchTerm, + MetaDataPropertyNames::create(...$localizedPropertyNames), + $this->dimensionSpacePointProvider->getDimensionSpacePointChain( + $this->validateDimensionSpacePoint($filter->dimensionSpacePoint) + ), + MetaDataPropertyNames::create(...$globalScopePropertyNames), + ); + } + + // ----------------------- /** - * @param Asset $asset - * @param MetaDataCollection $metaDataCollection + * The definitions of the given property names, or all of them if no names are given. + * + * @return iterable */ - public function updateMetaDataForAsset(Asset $asset, MetaDataCollection $metaDataCollection) + private function filteredPropertyDefinitions(?MetaDataPropertyNames $propertyNames): iterable { - $this->assetModelMetaDataMapper->mapMetaData($asset, $metaDataCollection); - $this->emitMetaDataCollectionUpdated($asset, $metaDataCollection); + if ($propertyNames === null) { + return $this->propertyDefinitions; + } + return array_map($this->propertyDefinition(...), iterator_to_array($propertyNames)); + } + + /** + * Resolves the own and the inherited value of a single property with one storage lookup + */ + private function resolvePropertyValue( + MetaDataAssetReference $assetReference, + MetaDataPropertyDefinition $propertyDefinition, + ?MetaDataDimensionSpacePoint $dimensionSpacePoint, + ): MetaDataPropertyValue { + // TODO: ACL + if ($propertyDefinition->globalScope) { + return $this->resolveGlobalPropertyValue($assetReference, $propertyDefinition); + } + + $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); + } + + /** + * 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 MetaDataPropertyValue::create($propertyDefinition->type->fromStoredValue(reset($storedValues))); } /** - * @Flow\Signal + * The scope a value of the given property is written to. * - * @param Asset $asset - * @param MetaDataCollection $metaDataCollection + * 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. */ - public function emitMetaDataCollectionUpdated(Asset $asset, MetaDataCollection $metaDataCollection) + private function writeScope( + MetaDataPropertyDefinition $propertyDefinition, + ?MetaDataDimensionSpacePoint $dimensionSpacePoint, + ): MetaDataDimensionSpacePoint|MetaDataGlobalScope { + if ($propertyDefinition->globalScope) { + return MetaDataGlobalScope::create(); + } + return $this->validateDimensionSpacePoint($dimensionSpacePoint); + } + + private function propertyDefinition(MetaDataPropertyName|string $propertyName): MetaDataPropertyDefinition { + if (is_string($propertyName)) { + $propertyName = MetaDataPropertyName::fromString($propertyName); + } + if (!$this->propertyDefinitions->include($propertyName)) { + throw new InvalidArgumentException(sprintf('Metadata property "%s" is not defined', $propertyName), 1776278047); + } + return $this->propertyDefinitions->get($propertyName); } + + private function validateDimensionSpacePoint(?MetaDataDimensionSpacePoint $dimensionSpacePoint): MetaDataDimensionSpacePoint + { + if ($dimensionSpacePoint === null) { + return $this->dimensionSpacePointProvider->getDefaultDimensionSpacePoint(); + } + + if (!$this->dimensionSpacePointProvider->isDimensionSpacePointValid($dimensionSpacePoint)) { + throw new InvalidArgumentException(sprintf('Dimension Space Point "%s" is not configured', $dimensionSpacePoint), 1776279083); + } + return $dimensionSpacePoint; + } + } diff --git a/Classes/MetaDataManagerFactory.php b/Classes/MetaDataManagerFactory.php new file mode 100644 index 0000000..e96c0bb --- /dev/null +++ b/Classes/MetaDataManagerFactory.php @@ -0,0 +1,29 @@ +dimensionSpacePointProvider, + $this->assetMetaDataConfigurationProvider->getPropertyConfiguration(), + $this->metaDataStorageProvider, + ); + } +} diff --git a/Classes/Storage/MetaDataStorage.php b/Classes/Storage/MetaDataStorage.php new file mode 100644 index 0000000..25e8655 --- /dev/null +++ b/Classes/Storage/MetaDataStorage.php @@ -0,0 +1,77 @@ + + */ + 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 new file mode 100644 index 0000000..0840349 --- /dev/null +++ b/Classes/Storage/MetaDataStorageProviderDbalAdapter.php @@ -0,0 +1,259 @@ +connection->executeStatement( + $statement, + [ + 'assetSourceId' => $assetReference->assetSourceId, + 'assetId' => $assetReference->assetId, + 'propertyName' => $propertyName->value, + 'propertyValue' => $propertyValue, + 'dimensionHash' => self::dimensionHash($scope), + ] + ); + } + + public function unsetMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): void + { + $this->connection->delete(self::TABLE_NAME, [ + 'asset_source_id' => $assetReference->assetSourceId, + 'asset_id' => $assetReference->assetId, + 'property_name' => $propertyName->value, + 'dimension_hash' => self::dimensionHash($scope), + ]); + } + + public function getMetaDataPropertyValues(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoints|MetaDataGlobalScope $scope): array + { + $dimensionHashes = self::dimensionHashes($scope); + if ($dimensionHashes === []) { + return []; + } + $query = $this->connection->createQueryBuilder(); + $query->select('dimension_hash', 'property_value') + ->from(self::TABLE_NAME) + ->where( + $query->expr()->and( + $query->expr()->eq('asset_source_id', ':assetSourceId'), + $query->expr()->eq('asset_id', ':assetId'), + $query->expr()->eq('property_name', ':propertyName'), + $query->expr()->in('dimension_hash', ':dimensionHashes'), + ) + ) + // 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' => $dimensionHashes, + ], [ + 'dimensionHashes' => ArrayParameterType::STRING, + ]); + + $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) { + /** @var string $assetSourceId */ + $assetSourceId = $row['asset_source_id']; + /** @var string $assetId */ + $assetId = $row['asset_id']; + /** @var string $propertyName */ + $propertyName = $row['property_name']; + /** @var string $dimensionHash */ + $dimensionHash = $row['dimension_hash']; + /** @var string $propertyValue */ + $propertyValue = $row['property_value']; + yield new MetaDataStoredValue( + MetaDataAssetReference::create($assetSourceId, $assetId), + MetaDataPropertyName::fromString($propertyName), + $dimensionHash, + $dimensionHash === self::GLOBAL_DIMENSION_HASH, + $propertyValue, + ); + } + } + + 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) { + /** @var string $assetSourceId */ + $assetSourceId = $row['asset_source_id']; + /** @var string $assetId */ + $assetId = $row['asset_id']; + yield MetaDataAssetReference::create($assetSourceId, $assetId); + } + } + + 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 @@ +abortIf(!$this->connection->getDatabasePlatform() instanceof MySQLPlatform, 'Migration can only be executed safely on MySQL/MariaDB.'); + + $this->addSql('CREATE TABLE 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->addSql('ALTER TABLE neos_metadata_value ADD CONSTRAINT fk_asset FOREIGN KEY (asset_id) REFERENCES neos_media_domain_model_asset (persistence_object_identifier) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->abortIf(!$this->connection->getDatabasePlatform() instanceof MySQLPlatform, 'Migration can only be executed safely on MySQL/MariaDB.'); + + $this->addSql('ALTER TABLE neos_metadata_value DROP FOREIGN KEY fk_asset'); + $this->addSql('DROP TABLE neos_metadata_value'); + } +} diff --git a/Readme.md b/Readme.md index 960dd12..8662982 100644 --- a/Readme.md +++ b/Readme.md @@ -4,41 +4,352 @@ # Neos.MetaData Package -This package provides data types and interfaces to handle meta data for assets in Neos (or Flow). +This package allows extensible, dimension-aware meta data properties to be attached to assets in Neos +(or Flow). + +Meta data properties are *declared in Settings*, *stored outside the asset* (in a dedicated database +table) and *resolved per dimension space point* with fallbacks along the configured content dimension +presets. This means the same asset can have a different caption per language or country, without +changing the `Asset` model itself. Properties that must not be localized – a copyright notice, say – +can be declared to have a *global scope*, giving them a single value shared by all dimensions. + +## Requirements + +* PHP 8.4 or newer +* `neos/media` 8.3, 8.4 or 9.0 +* MySQL or MariaDB (the shipped Doctrine migration and the DBAL storage adapter are MySQL-specific) ## Installation Install using composer: - composer require neos/metadata + composer require neos/metadata -If you install a package that depends on this package, you should not need to require it manually, -though. Some related packages are: +Afterwards apply the Doctrine migrations to create the `neos_metadata_value` table: -- [`neos/metadata-extractor`](https://github.com/neos/metadata-extractor): Provides CLI and realtime - meta data extraction on assets -- [`neos/metadata-contentrepositoryadapter`](https://github.com/neos/metadata-contentrepositoryadapter): - Handles the mapping of meta data DTOs to the Neos Content Repository + ./flow doctrine:migrate ## Configuration -The provided asset meta data mapper is configured with Eel expressions to determine the source of -mapped data. Check the setup below `Neos.MetaData.metaDataMapping`. +Meta data properties are declared below `Neos.MetaData.metaDataProperties`, keyed by property name: + +```yaml +Neos: + MetaData: + metaDataProperties: + 'copyright': + type: string + globalScope: true + ui: + label: i18n + inspector: + editor: 'Neos.Neos/Inspector/Editors/TextAreaEditor' + editorOptions: + rows: 7 +``` + +| Option | Description | +|-------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `type` | `string` (default), `integer` or `boolean` | +| `globalScope` | `true` = a single value shared by all dimensions, `false` (default) = one value per dimension space point | +| `ui.label` | Label for the property. The literal value `i18n` looks up the translation id `properties.` 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` (global scope), `altText` and +`caption`. + +A property that is set to `null` is skipped, which allows to disable properties that are configured +elsewhere: + +```yaml +Neos: + MetaData: + metaDataProperties: + 'copyright': ~ +``` + +Dimensions are *not* configured in this package. They are taken from the Content Repository content +dimension presets (`Neos.ContentRepository.contentDimensions`) via +`DimensionSpacePointProviderContentRepositoryAdapter`. If no content dimensions are configured, the +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 -The package does not in itself change the way metadata is handled. Instead it provides ways for -other packages to interact with meta data of assets. +### 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']`). Wherever a dimension space point can be passed, `null` means +the default one. + +```php +use Neos\MetaData\Domain\Dto\MetaDataAssetReference; +use Neos\MetaData\Domain\Dto\MetaDataDimensionSpacePoint; +use Neos\MetaData\MetaDataManager; + +#[Flow\Inject] +protected MetaDataManager $metaDataManager; + +$assetReference = MetaDataAssetReference::create($asset->assetSourceIdentifier, $asset->getIdentifier()); +$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); + +$value->ownValue; // 'Eine Katze', or NULL if only a fallback exists +$value->inheritedValue; // 'A cat' +$value->inheritedFrom; // MetaDataDimensionSpacePoint for ['language' => 'en'] +$value->value; // 'Eine Katze' +``` + +For properties with a **global scope** the value is shared by all dimensions, so it is never inherited: +`ownValue` is the shared value and `inheritedValue` is always `null`. The dimension space point that is +passed in is ignored for such properties – callers can always pass the dimension they are working in +without having to know which properties are localized. + +### Finding assets + +`findAssets()` returns the assets that have a matching metadata value. All criteria of a +`MetaDataAssetFilter` are optional and are combined with AND: + +```php +use Neos\MetaData\Domain\Dto\MetaDataAssetFilter; +use Neos\MetaData\Domain\Dto\MetaDataPropertyNames; + +$filter = MetaDataAssetFilter::create( + searchTerm: 'cat', + dimensionSpacePoint: MetaDataDimensionSpacePoint::fromCoordinates(['language' => 'de']), + propertyNames: MetaDataPropertyNames::create('caption', 'altText'), +); + +foreach ($this->metaDataManager->findAssets($filter) as $assetReference) { + $assetReference->assetSourceId; + $assetReference->assetId; +} +``` + +| Criterion | Omitted means | +|-----------------------|--------------------------------------------------------------------------------------------| +| `assetSourceId` | assets of every asset source | +| `dimensionSpacePoint` | the *default* dimension space point – as everywhere else in this package, **not** "any dimension" | +| `searchTerm` | every asset that has a value for the filtered properties at all | +| `propertyNames` | all defined properties | + +The search term matches if it is contained anywhere in a value, ignoring case and accents. `%` and `_` +are matched literally rather than as wildcards. A term that is empty or consists of whitespace only is +treated like an omitted one, so clearing a search field behaves like not having searched. + +A value only counts if it is the one `getMetaDataPropertyValue()` would return for the filter's +dimension space point, so the search agrees with what an editor working in that dimension sees. Given +an asset with the English caption `A cat`, searching for `cat` in German finds it as long as German +inherits that caption – and stops finding it as soon as a German caption of its own is set. Properties +with a global scope are matched on their shared value regardless of the dimension space point, just +like they are read regardless of it. + +The result is lazily streamed, contains each asset at most once and is ordered by asset source id and +asset id. It carries `MetaDataAssetReference`s – the identity of an asset within its asset source – not +`Asset` objects; resolving those is up to the caller, this package never touches the asset model. +Unknown property names and dimension space points that are not allowed by the configured preset +constraints lead to an `InvalidArgumentException`, as they do everywhere else. + +### Fusion / Eel + +The Eel helper `AssetMetaData` is registered in the default Fusion context and returns the *effective* +values: + +``` +caption = ${AssetMetaData.getMetaDataProperty(asset, 'caption', {language: 'de'})} +allMetaData = ${AssetMetaData.getMetaData(asset, {language: 'de'})} +``` + +| 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, marking inherited values +./flow assetmetadata:list --asset-id [--asset-source neos] [--dimension-space-point '{"language":"de"}'] + +# Set a single property +./flow assetmetadata:set --asset-id --property caption --value "A picture" [--asset-source neos] [--dimension-space-point '{"language":"de"}'] + +# Remove a single property +./flow assetmetadata:unset --asset-id --property caption [--asset-source neos] [--dimension-space-point '{"language":"de"}'] +``` + +`--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: + +```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 +default implementation in `Configuration/Objects.yaml`. Replace any of them to change the behaviour: + +| Interface | Default implementation | Responsibility | +|----------------------------------|-----------------------------------------------------------|-----------------------------------------------------------------------------| +| `Storage\MetaDataStorage` | `MetaDataStorageProviderDbalAdapter` | Persists property values in the `neos_metadata_value` table via Doctrine DBAL | +| `DimensionSpacePointProvider\DimensionSpacePointProvider` | `DimensionSpacePointProviderContentRepositoryAdapter` | Provides valid dimension space points, the default one and the fallback chain | +| `Configuration\MetaDataConfigurationProvider` | `MetaDataConfigurationProviderYamlAdapter` | Turns the YAML settings into `MetaDataPropertyDefinitions` | + +Storage implementations are deliberately dumb: they look values up by scope and must not 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`. Rows are deleted together with their asset via a +foreign key with `ON DELETE CASCADE`. + +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' +``` -### Defined Meta Data Mappers +They are split along the seams the package is built on: -* **AssetModelMetaDataMapper**: Maps meta data to `Asset` models from `neos/media`. Supported are title, - caption, copyright notice (on `neos/media` 4.2 and up), tags and collections (see configuration above). +| 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 | -### Defined Data Transfer Objects +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. -* **Asset**: The asset DTO provides basic data about the asset, like original file name. -* **IPTC**: Image meta data, title and description of the photograph and the author. For - further specifications see https://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata. -* **EXIF**: Exchangeable image file format for digital still cameras, the technical meta data of an image. - For further specifications see http://www.cipa.jp/std/documents/e/DC-008-Translation-2016-E.pdf. +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/Resources/Private/Translations/de/Main.xlf b/Resources/Private/Translations/de/Main.xlf new file mode 100644 index 0000000..2dbf576 --- /dev/null +++ b/Resources/Private/Translations/de/Main.xlf @@ -0,0 +1,19 @@ + + + + + + Alt Text + Alternativtext + + + Copyright Notice + Urheberrechtshinweis + + + Caption + Bildunterschrift + + + + diff --git a/Resources/Private/Translations/en/Main.xlf b/Resources/Private/Translations/en/Main.xlf new file mode 100644 index 0000000..d21b23e --- /dev/null +++ b/Resources/Private/Translations/en/Main.xlf @@ -0,0 +1,16 @@ + + + + + + Alt Text + + + Copyright Notice + + + Caption + + + + diff --git a/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php b/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php new file mode 100644 index 0000000..16f3a32 --- /dev/null +++ b/Tests/Functional/Storage/MetaDataStorageProviderDbalAdapterTest.php @@ -0,0 +1,525 @@ +getObjectManager()->get(EntityManagerInterface::class); + $connection = $entityManager->getConnection(); + if (!$connection->getDatabasePlatform() instanceof AbstractMySQLPlatform) { + self::markTestSkipped('The metadata storage adapter requires MySQL or MariaDB'); + } + + parent::setUp(); + + $this->connection = $connection; + $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 + { + if (($this->connection ?? null) !== null) { + $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))); + $count = $this->connection->fetchOne('SELECT COUNT(*) FROM neos_metadata_value'); + self::assertIsNumeric($count); + self::assertSame(1, (int) $count); + } + + /** + * @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..4bdcb9c --- /dev/null +++ b/Tests/Unit/Configuration/MetaDataConfigurationProviderYamlAdapterTest.php @@ -0,0 +1,230 @@ +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'); + + $ui = $this->definitionFor(['ui' => ['label' => 'Some label']])->ui; + self::assertNotNull($ui); + self::assertSame('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'); + + $ui = $this->definitionFor(['ui' => ['label' => 'i18n']])->ui; + self::assertNotNull($ui); + self::assertSame('Bildunterschrift', $ui->label); + } + + /** + * @test + */ + public function aShortHandStringLabelIsTranslatedAgainstTheReferencedPackageAndSource(): void + { + $this->translator->expects(self::once()) + ->method('translateById') + ->with('properties.caption', [], null, null, 'Main', 'Neos.MetaData.Extractor') + ->willReturn('Bildunterschrift'); + + $ui = $this->definitionFor(['ui' => ['label' => 'Neos.MetaData.Extractor:Main:properties.caption']])->ui; + self::assertNotNull($ui); + self::assertSame('Bildunterschrift', $ui->label); + } + + /** + * @test + */ + public function aShortHandStringWithDottedSourceIsNormalizedBeforeTranslation(): void + { + $this->translator->expects(self::once()) + ->method('translateById') + ->with('properties.caption', [], null, null, 'Main/Foo', 'Neos.MetaData.Extractor') + ->willReturn('Bildunterschrift'); + + $ui = $this->definitionFor(['ui' => ['label' => 'Neos.MetaData.Extractor:Main.Foo:properties.caption']])->ui; + self::assertNotNull($ui); + self::assertSame('Bildunterschrift', $ui->label); + } + + /** + * @test + */ + public function anUntranslatedShortHandStringFallsBackToThePropertyName(): void + { + $this->translator->method('translateById')->willReturn(null); + + $ui = $this->definitionFor(['ui' => ['label' => 'Neos.MetaData.Extractor:Main:properties.caption']])->ui; + self::assertNotNull($ui); + self::assertSame('caption', $ui->label); + } + + /** + * @test + */ + public function anUntranslatedLabelFallsBackToThePropertyName(): void + { + $this->translator->method('translateById')->willReturn(null); + + $ui = $this->definitionFor(['ui' => ['label' => 'i18n']])->ui; + self::assertNotNull($ui); + self::assertSame('caption', $ui->label); + } + + /** + * @test + */ + public function theEditorAndItsOptionsAreParsed(): void + { + $definition = $this->definitionFor([ + 'ui' => [ + 'inspector' => [ + 'editor' => 'Neos.Neos/Inspector/Editors/TextAreaEditor', + 'editorOptions' => ['rows' => 7], + ], + ], + ]); + + self::assertNotNull($definition->ui); + 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 $configuration + */ + private function definitionsFor(array $configuration): MetaDataPropertyDefinitions + { + /** @phpstan-ignore argument.type */ + 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..ef84b51 --- /dev/null +++ b/Tests/Unit/DimensionSpacePointProvider/DimensionSpacePointProviderContentRepositoryAdapterTest.php @@ -0,0 +1,194 @@ +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); + } + + /** + * @param iterable $dimensionSpacePoints + * @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 6cf0691..1f761e8 100644 --- a/composer.json +++ b/composer.json @@ -1,95 +1,45 @@ { "name": "neos/metadata", - "description": "Data types and interfaces to manage meta data for assets in Neos", + "description": "Data types and interfaces to manage extensible meta data for assets in Neos", "type": "neos-package", "license": "MIT", "require": { - "doctrine/collections": "^1.0", - "neos/media": "^3.0 || ^4.0 || ^5.0 || ^7.0 || ^8.0 || dev-master", - "neos/flow": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || dev-master" + "php": "^8.4", + "neos/media": "^8.3 || ^8.4 || ^9.0", + "webmozart/assert": "^1 || ^2", + "doctrine/dbal": "*" + }, + "suggest": { + "neos/content-repository": "^8.3" + }, + "require-dev": { + "neos/content-repository": "^8.3", + "phpunit/phpunit": "^11.0", + "phpstan/phpstan": "^1.12" }, "autoload": { "psr-4": { "Neos\\MetaData\\": "Classes" } }, + "autoload-dev": { + "psr-4": { + "Neos\\MetaData\\Tests\\": "Tests" + } + }, + "scripts": { + "test": "../../../bin/phpunit --enforce-time-limit --bootstrap ../../Libraries/autoload.php --testdox Tests", + "test:ci": "phpunit --enforce-time-limit --bootstrap vendor/autoload.php --testdox Tests", + "codestyle": "phpstan analyse --autoload-file ../../Libraries/autoload.php -c phpstan.neon" + }, + "config": { + "allow-plugins": { + "neos/composer-plugin": true + } + }, "extra": { "neos": { "package-key": "Neos.MetaData" - }, - "applied-flow-migrations": [ - "TYPO3.FLOW3-201201261636", - "TYPO3.Fluid-201205031303", - "TYPO3.FLOW3-201205292145", - "TYPO3.FLOW3-201206271128", - "TYPO3.FLOW3-201209201112", - "TYPO3.Flow-201209251426", - "TYPO3.Flow-201211151101", - "TYPO3.Flow-201212051340", - "TYPO3.TypoScript-130516234520", - "TYPO3.TypoScript-130516235550", - "TYPO3.TYPO3CR-130523180140", - "TYPO3.Neos.NodeTypes-201309111655", - "TYPO3.Flow-201310031523", - "TYPO3.Flow-201405111147", - "TYPO3.Neos-201407061038", - "TYPO3.Neos-201409071922", - "TYPO3.TYPO3CR-140911160326", - "TYPO3.Neos-201410010000", - "TYPO3.TYPO3CR-141101082142", - "TYPO3.Neos-20141113115300", - "TYPO3.Fluid-20141113120800", - "TYPO3.Flow-20141113121400", - "TYPO3.Fluid-20141121091700", - "TYPO3.Neos-20141218134700", - "TYPO3.Fluid-20150214130800", - "TYPO3.Neos-20150303231600", - "TYPO3.TYPO3CR-20150510103823", - "TYPO3.Flow-20151113161300", - "TYPO3.Form-20160601101500", - "TYPO3.Flow-20161115140400", - "TYPO3.Flow-20161115140430", - "Neos.Flow-20161124204700", - "Neos.Flow-20161124204701", - "Neos.Twitter.Bootstrap-20161124204912", - "Neos.Form-20161124205254", - "Neos.Flow-20161124224015", - "Neos.Party-20161124225257", - "Neos.Eel-20161124230101", - "Neos.Kickstart-20161124230102", - "Neos.Setup-20161124230842", - "Neos.Imagine-20161124231742", - "Neos.Media-20161124233100", - "Neos.NodeTypes-20161125002300", - "Neos.SiteKickstarter-20161125002311", - "Neos.Neos-20161125002322", - "Neos.ContentRepository-20161125012000", - "Neos.Fusion-20161125013710", - "Neos.Setup-20161125014759", - "Neos.SiteKickstarter-20161125095901", - "Neos.Fusion-20161125104701", - "Neos.NodeTypes-20161125104800", - "Neos.Neos-20161125104802", - "Neos.Kickstarter-20161125110814", - "Neos.Neos-20161125122412", - "Neos.Flow-20161125124112", - "TYPO3.FluidAdaptor-20161130112935", - "Neos.Fusion-20161201202543", - "Neos.Neos-20161201222211", - "Neos.Fusion-20161202215034", - "Neos.Fusion-20161219092345", - "Neos.ContentRepository-20161219093512", - "Neos.Media-20161219094126", - "Neos.Neos-20161219094403", - "Neos.Neos-20161219122512", - "Neos.Fusion-20161219130100", - "Neos.Neos-20161220163741", - "Neos.SwiftMailer-20161130105617", - "Neos.Neos-20170115114620", - "Neos.Fusion-20170120013047", - "Neos.Flow-20170125103800", - "Neos.Seo-20170127154600", - "Neos.Flow-20170127183102" - ] + } } } diff --git a/phpstan.ci.neon b/phpstan.ci.neon new file mode 100644 index 0000000..12b773c --- /dev/null +++ b/phpstan.ci.neon @@ -0,0 +1,8 @@ +parameters: + level: max + phpVersion: 80400 + paths: + - Classes + - Tests + scanDirectories: + - Packages/Framework/Neos.Flow/Tests diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..fbc72d1 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,8 @@ +parameters: + level: max + phpVersion: 80400 + paths: + - Classes + - Tests + scanDirectories: + - ../../Framework/Neos.Flow/Tests