From a04766371d7193e920b0023f8376e3dcf4660678 Mon Sep 17 00:00:00 2001 From: Michel Loew Date: Fri, 7 Aug 2026 15:04:01 +0200 Subject: [PATCH 1/3] TASK: Add top level options to metadata property --- .../MetaDataConfigurationProviderYamlAdapter.php | 4 ++++ Classes/Domain/Dto/MetaDataPropertyDefinition.php | 1 + 2 files changed, 5 insertions(+) diff --git a/Classes/Configuration/MetaDataConfigurationProviderYamlAdapter.php b/Classes/Configuration/MetaDataConfigurationProviderYamlAdapter.php index c326d60..3e10cd5 100644 --- a/Classes/Configuration/MetaDataConfigurationProviderYamlAdapter.php +++ b/Classes/Configuration/MetaDataConfigurationProviderYamlAdapter.php @@ -8,6 +8,7 @@ use Neos\MetaData\Domain\Dto\MetaDataPropertyDefinition; use Neos\MetaData\Domain\Dto\MetaDataPropertyDefinitions; use Neos\MetaData\Domain\Dto\MetaDataPropertyName; +use Neos\MetaData\Domain\Dto\MetaDataPropertyOptions; use Neos\MetaData\Domain\Dto\MetaDataPropertyType; use Neos\MetaData\Domain\Dto\MetaDataPropertyUiDefinition; @@ -43,6 +44,9 @@ public function getPropertyConfiguration(): MetaDataPropertyDefinitions options: $propertyDefinition['ui']['inspector']['editorOptions'] ?? [], ) ) : null, + array_key_exists('options', $propertyDefinition) && is_array($propertyDefinition['options']) + ? $propertyDefinition['options'] + : null, ); } return MetaDataPropertyDefinitions::create(...$propertyDefinitions); diff --git a/Classes/Domain/Dto/MetaDataPropertyDefinition.php b/Classes/Domain/Dto/MetaDataPropertyDefinition.php index f3ee611..a977810 100644 --- a/Classes/Domain/Dto/MetaDataPropertyDefinition.php +++ b/Classes/Domain/Dto/MetaDataPropertyDefinition.php @@ -17,6 +17,7 @@ public function __construct( public MetaDataPropertyType $type, public bool $globalScope, public ?MetaDataPropertyUiDefinition $ui = null, + public ?array $options = null, ) { } } From 63448d0f3e1da74514ac40c78a1bfcd7be375148 Mon Sep 17 00:00:00 2001 From: Michel Loew Date: Fri, 7 Aug 2026 15:10:43 +0200 Subject: [PATCH 2/3] Add float, array and dateTime metadata property types Splits the logical value carried by a metadata property from its stored string representation: MetaDataPropertyType stays the single source of truth for which concrete type each case accepts/returns, so callers type it as mixed instead of a repeated string|int|bool union that would otherwise need to grow at every call site whenever a type is added. --- ...taDataConfigurationProviderYamlAdapter.php | 3 + Classes/Domain/Dto/MetaDataPropertyType.php | 108 ++++++++++++++++-- Classes/Domain/Dto/MetaDataPropertyValue.php | 18 +-- Classes/Domain/Dto/MetaDataPropertyValues.php | 2 +- Classes/Helper/AssetMetaDataHelper.php | 7 +- Classes/MetaDataManager.php | 2 +- Classes/Storage/MetaDataStorage.php | 4 +- .../MetaDataStorageProviderDbalAdapter.php | 2 +- ...taConfigurationProviderYamlAdapterTest.php | 5 +- .../Domain/Dto/MetaDataPropertyTypeTest.php | 68 +++++++++-- .../Fixtures/PropertyDefinitionsFixture.php | 8 +- Tests/Unit/MetaDataManagerTest.php | 71 ++++++++++++ 12 files changed, 263 insertions(+), 35 deletions(-) diff --git a/Classes/Configuration/MetaDataConfigurationProviderYamlAdapter.php b/Classes/Configuration/MetaDataConfigurationProviderYamlAdapter.php index 3e10cd5..f9836f8 100644 --- a/Classes/Configuration/MetaDataConfigurationProviderYamlAdapter.php +++ b/Classes/Configuration/MetaDataConfigurationProviderYamlAdapter.php @@ -34,6 +34,9 @@ public function getPropertyConfiguration(): MetaDataPropertyDefinitions match ($propertyDefinition['type'] ?? null) { 'integer' => MetaDataPropertyType::integer, 'boolean' => MetaDataPropertyType::boolean, + 'float' => MetaDataPropertyType::float, + 'array' => MetaDataPropertyType::array, + 'dateTime' => MetaDataPropertyType::dateTime, default => MetaDataPropertyType::string, }, $propertyDefinition['globalScope'] ?? false, diff --git a/Classes/Domain/Dto/MetaDataPropertyType.php b/Classes/Domain/Dto/MetaDataPropertyType.php index 4548739..6126482 100644 --- a/Classes/Domain/Dto/MetaDataPropertyType.php +++ b/Classes/Domain/Dto/MetaDataPropertyType.php @@ -4,13 +4,20 @@ namespace Neos\MetaData\Domain\Dto; +use DateTimeImmutable; +use DateTimeInterface; use InvalidArgumentException; +use JsonException; +use Throwable; /** * Type of a custom asset metadata property. * * Values are stored as strings, so this is also what turns a value into its stored representation and * back: {@see self::coerceForStorage()} on the way in, {@see self::fromStoredValue()} on the way out. + * The concrete PHP type of a value is case-dependent - see the private `to*()` helpers below for what + * each case accepts and returns. Callers elsewhere in the domain therefore type a logical value as + * `mixed` rather than repeating a union of every case's type. * * The two directions are deliberately not equally strict. Writing rejects what it cannot interpret, * because a caller passing "abc" for an integer property has made a mistake that should not be @@ -22,45 +29,71 @@ enum MetaDataPropertyType case string; case integer; case boolean; + case float; + case array; + case dateTime; /** * The given value in the representation it is stored as. * * Unambiguous conversions are applied, so that callers which only ever have strings – the command * line, form input, Fusion – do not have to cast: "42" is a valid integer, "true", "on" and "yes" - * are a valid boolean, as are their negative counterparts. Anything else is rejected. + * are a valid boolean, as are their negative counterparts, a JSON encoded string is a valid array + * and an ISO 8601 string is a valid date and time. Anything else is rejected. * * @throws InvalidArgumentException if the value cannot be interpreted as this type */ - public function coerceForStorage(string|int|bool $value): string + public function coerceForStorage(mixed $value): string { $coerced = $this->tryCoerce($value); if ($coerced === null) { throw new InvalidArgumentException(sprintf('Value %s cannot be interpreted as %s', json_encode($value), $this->name), 1785715201); } - return is_bool($coerced) ? ($coerced ? '1' : '0') : (string)$coerced; + return match (true) { + is_bool($coerced) => $coerced ? '1' : '0', + is_array($coerced) => json_encode($coerced, JSON_THROW_ON_ERROR), + $coerced instanceof DateTimeInterface => $coerced->format(DATE_ATOM), + default => (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 + public function fromStoredValue(string $value): mixed { return $this->tryCoerce($value); } // ----------------------- - private function tryCoerce(string|int|bool $value): string|int|bool|null + private function tryCoerce(mixed $value): mixed { + if ($value === null) { + return null; + } return match ($this) { - self::string => is_bool($value) ? ($value ? '1' : '0') : (string)$value, + self::string => self::toString($value), self::integer => self::toInteger($value), self::boolean => self::toBoolean($value), + self::float => self::toFloat($value), + self::array => self::toArray($value), + self::dateTime => self::toDateTime($value), }; } - private static function toInteger(string|int|bool $value): ?int + private static function toString(mixed $value): ?string + { + if (is_bool($value)) { + return $value ? '1' : '0'; + } + if (is_string($value) || is_int($value) || is_float($value)) { + return (string)$value; + } + return null; + } + + private static function toInteger(mixed $value): ?int { if (is_int($value)) { return $value; @@ -68,11 +101,14 @@ private static function toInteger(string|int|bool $value): ?int if (is_bool($value)) { return $value ? 1 : 0; } + if (!is_string($value)) { + return null; + } $trimmed = trim($value); return preg_match('/^-?\d+$/', $trimmed) === 1 ? (int)$trimmed : null; } - private static function toBoolean(string|int|bool $value): ?bool + private static function toBoolean(mixed $value): ?bool { if (is_bool($value)) { return $value; @@ -84,10 +120,66 @@ private static function toBoolean(string|int|bool $value): ?bool default => null, }; } + if (!is_string($value)) { + return null; + } return match (strtolower(trim($value))) { '1', 'true', 'on', 'yes' => true, '0', 'false', 'off', 'no' => false, default => null, }; } + + private static function toFloat(mixed $value): ?float + { + if (is_float($value) && !is_nan($value) && !is_infinite($value)) { + return $value; + } + if (is_int($value)) { + return (float)$value; + } + if (!is_string($value)) { + return null; + } + $trimmed = trim($value); + return preg_match('/^-?\d+(\.\d+)?$/', $trimmed) === 1 ? (float)$trimmed : null; + } + + private static function toArray(mixed $value): ?array + { + if (is_array($value)) { + return $value; + } + if (!is_string($value)) { + return null; + } + try { + $decoded = json_decode($value, true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + return is_array($decoded) ? $decoded : null; + } + + private static function toDateTime(mixed $value): ?DateTimeImmutable + { + if ($value instanceof DateTimeImmutable) { + return $value; + } + if ($value instanceof DateTimeInterface) { + return DateTimeImmutable::createFromInterface($value); + } + if (!is_string($value)) { + return null; + } + $parsed = DateTimeImmutable::createFromFormat(DATE_ATOM, $value); + if ($parsed !== false) { + return $parsed; + } + try { + return new DateTimeImmutable($value); + } catch (Throwable) { + return null; + } + } } diff --git a/Classes/Domain/Dto/MetaDataPropertyValue.php b/Classes/Domain/Dto/MetaDataPropertyValue.php index ce2b93a..adc2430 100644 --- a/Classes/Domain/Dto/MetaDataPropertyValue.php +++ b/Classes/Domain/Dto/MetaDataPropertyValue.php @@ -22,22 +22,22 @@ final readonly class MetaDataPropertyValue { /** - * @param string|int|bool|null $value the effective value, i.e. the own value falling back to the inherited one - * @param string|int|bool|null $ownValue the value stored for the dimension space point that was asked for - * @param string|int|bool|null $inheritedValue the value stored for the closest fallback dimension space point + * @param mixed $value the effective value, i.e. the own value falling back to the inherited one - see {@see MetaDataPropertyType} for the concrete type + * @param mixed $ownValue the value stored for the dimension space point that was asked for + * @param mixed $inheritedValue the value stored for the closest fallback dimension space point * @param MetaDataDimensionSpacePoint|null $inheritedFrom the dimension space point the inherited value stems from */ private function __construct( - public string|int|bool|null $value, - public string|int|bool|null $ownValue, - public string|int|bool|null $inheritedValue, + public mixed $value, + public mixed $ownValue, + public mixed $inheritedValue, public ?MetaDataDimensionSpacePoint $inheritedFrom, ) { } public static function create( - string|int|bool|null $ownValue, - string|int|bool|null $inheritedValue = null, + mixed $ownValue, + mixed $inheritedValue = null, ?MetaDataDimensionSpacePoint $inheritedFrom = null, ): self { return new self( @@ -63,7 +63,7 @@ public function hasOwnValue(): bool } /** Fusion getter access */ - public function getOwnValue(): string|int|bool|null + public function getOwnValue(): mixed { return $this->ownValue; } diff --git a/Classes/Domain/Dto/MetaDataPropertyValues.php b/Classes/Domain/Dto/MetaDataPropertyValues.php index 091276f..2eb64ab 100644 --- a/Classes/Domain/Dto/MetaDataPropertyValues.php +++ b/Classes/Domain/Dto/MetaDataPropertyValues.php @@ -43,7 +43,7 @@ public function get(MetaDataPropertyName $propertyName): MetaDataPropertyValue /** * The effective values by property name, e.g. for rendering * - * @return array + * @return array */ public function toArray(): array { diff --git a/Classes/Helper/AssetMetaDataHelper.php b/Classes/Helper/AssetMetaDataHelper.php index 9f1b061..734a548 100644 --- a/Classes/Helper/AssetMetaDataHelper.php +++ b/Classes/Helper/AssetMetaDataHelper.php @@ -7,6 +7,7 @@ use Neos\Media\Domain\Model\Asset; use Neos\MetaData\Domain\Dto\MetaDataAssetReference; use Neos\MetaData\Domain\Dto\MetaDataDimensionSpacePoint; +use Neos\MetaData\Domain\Dto\MetaDataPropertyType; use Neos\MetaData\MetaDataManager; class AssetMetaDataHelper implements ProtectedContextAwareInterface @@ -22,7 +23,7 @@ public function __construct( * The effective metadata of the given asset by property name, with dimension fallbacks applied * * @param array $coordinates dimension coordinates, e.g. ['language' => 'de']. Empty = the default dimension - * @return array + * @return array */ public function getMetaData(Asset $asset, array $coordinates = []): array { @@ -36,9 +37,9 @@ public function getMetaData(Asset $asset, array $coordinates = []): array * 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) + * @return mixed Value of the metadata property, whose concrete type is defined by {@see MetaDataPropertyType}, or NULL if it was not set (or explicitly reset) */ - public function getMetaDataProperty(Asset $asset, string $propertyName, array $coordinates = []): string|int|bool|null + public function getMetaDataProperty(Asset $asset, string $propertyName, array $coordinates = []): mixed { return $this->metaDataManager->getMetaDataPropertyValue( MetaDataAssetReference::create($asset->assetSourceIdentifier, $asset->getIdentifier()), diff --git a/Classes/MetaDataManager.php b/Classes/MetaDataManager.php index 0d1317d..985f72d 100644 --- a/Classes/MetaDataManager.php +++ b/Classes/MetaDataManager.php @@ -60,7 +60,7 @@ public function getDimensionSpacePointConfiguration(): MetaDataDimensionSpacePoi public function setMetaDataPropertyValue( MetaDataAssetReference $assetReference, MetaDataPropertyName|string $propertyName, - string|int|bool $value, + mixed $value, ?MetaDataDimensionSpacePoint $dimensionSpacePoint = null, ): void { $propertyDefinition = $this->propertyDefinition($propertyName); diff --git a/Classes/Storage/MetaDataStorage.php b/Classes/Storage/MetaDataStorage.php index 25e8655..34b2ed2 100644 --- a/Classes/Storage/MetaDataStorage.php +++ b/Classes/Storage/MetaDataStorage.php @@ -21,7 +21,7 @@ interface MetaDataStorage { - public function setMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, string|int|bool $propertyValue, MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): void; + public function setMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, string $propertyValue, MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): void; public function unsetMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): void; @@ -35,7 +35,7 @@ public function unsetMetaDataPropertyValue(MetaDataAssetReference $assetReferenc * be compared with {@see MetaDataDimensionSpacePoint::$hash}. Scopes without a stored value are * absent from the result. * - * @return array + * @return array */ public function getMetaDataPropertyValues(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, MetaDataDimensionSpacePoints|MetaDataGlobalScope $scope): array; diff --git a/Classes/Storage/MetaDataStorageProviderDbalAdapter.php b/Classes/Storage/MetaDataStorageProviderDbalAdapter.php index 1595554..d68655d 100644 --- a/Classes/Storage/MetaDataStorageProviderDbalAdapter.php +++ b/Classes/Storage/MetaDataStorageProviderDbalAdapter.php @@ -28,7 +28,7 @@ public function __construct( ) { } - public function setMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, string|int|bool $propertyValue, MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): void + public function setMetaDataPropertyValue(MetaDataAssetReference $assetReference, MetaDataPropertyName $propertyName, string $propertyValue, MetaDataDimensionSpacePoint|MetaDataGlobalScope $scope): void { $statement = sprintf(<< ['configuredType' => 'string', 'expectedType' => MetaDataPropertyType::string]; yield 'integer' => ['configuredType' => 'integer', 'expectedType' => MetaDataPropertyType::integer]; yield 'boolean' => ['configuredType' => 'boolean', 'expectedType' => MetaDataPropertyType::boolean]; + yield 'float' => ['configuredType' => 'float', 'expectedType' => MetaDataPropertyType::float]; + yield 'array' => ['configuredType' => 'array', 'expectedType' => MetaDataPropertyType::array]; + yield 'dateTime' => ['configuredType' => 'dateTime', 'expectedType' => MetaDataPropertyType::dateTime]; yield 'omitted defaults to string' => ['configuredType' => null, 'expectedType' => MetaDataPropertyType::string]; - yield 'unknown defaults to string' => ['configuredType' => 'float', 'expectedType' => MetaDataPropertyType::string]; + yield 'unknown defaults to string' => ['configuredType' => 'not-a-real-type', 'expectedType' => MetaDataPropertyType::string]; } /** diff --git a/Tests/Unit/Domain/Dto/MetaDataPropertyTypeTest.php b/Tests/Unit/Domain/Dto/MetaDataPropertyTypeTest.php index 2cbd3d7..fdee08f 100644 --- a/Tests/Unit/Domain/Dto/MetaDataPropertyTypeTest.php +++ b/Tests/Unit/Domain/Dto/MetaDataPropertyTypeTest.php @@ -4,14 +4,16 @@ namespace Neos\MetaData\Tests\Unit\Domain\Dto; +use DateTimeImmutable; use InvalidArgumentException; use Neos\Flow\Tests\UnitTestCase; use Neos\MetaData\Domain\Dto\MetaDataPropertyType; +use stdClass; class MetaDataPropertyTypeTest extends UnitTestCase { /** - * @return iterable + * @return iterable */ public static function coercibleValues(): iterable { @@ -36,19 +38,34 @@ public static function coercibleValues(): iterable 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']; + + yield 'float from float' => ['type' => MetaDataPropertyType::float, 'value' => 4.2, 'expected' => '4.2']; + yield 'float from integer' => ['type' => MetaDataPropertyType::float, 'value' => 4, 'expected' => '4']; + yield 'float from numeric string' => ['type' => MetaDataPropertyType::float, 'value' => '4.2', 'expected' => '4.2']; + yield 'float from padded string' => ['type' => MetaDataPropertyType::float, 'value' => ' 4.2 ', 'expected' => '4.2']; + yield 'negative float' => ['type' => MetaDataPropertyType::float, 'value' => '-4.2', 'expected' => '-4.2']; + + yield 'array from array' => ['type' => MetaDataPropertyType::array, 'value' => ['a', 'b'], 'expected' => '["a","b"]']; + yield 'array from empty array' => ['type' => MetaDataPropertyType::array, 'value' => [], 'expected' => '[]']; + yield 'array from JSON string' => ['type' => MetaDataPropertyType::array, 'value' => '["a","b"]', 'expected' => '["a","b"]']; + yield 'array from nested array' => ['type' => MetaDataPropertyType::array, 'value' => ['a' => ['b' => 1]], 'expected' => '{"a":{"b":1}}']; + + yield 'dateTime from DateTimeImmutable' => ['type' => MetaDataPropertyType::dateTime, 'value' => new DateTimeImmutable('2024-01-02T10:00:00+00:00'), 'expected' => '2024-01-02T10:00:00+00:00']; + yield 'dateTime from ISO 8601 string' => ['type' => MetaDataPropertyType::dateTime, 'value' => '2024-01-02T10:00:00+00:00', 'expected' => '2024-01-02T10:00:00+00:00']; + yield 'dateTime from date only string' => ['type' => MetaDataPropertyType::dateTime, 'value' => '2024-01-02', 'expected' => (new DateTimeImmutable('2024-01-02'))->format(DATE_ATOM)]; } /** * @dataProvider coercibleValues * @test */ - public function valuesAreCoercedToTheirStoredRepresentation(MetaDataPropertyType $type, string|int|bool $value, string $expected): void + public function valuesAreCoercedToTheirStoredRepresentation(MetaDataPropertyType $type, mixed $value, string $expected): void { self::assertSame($expected, $type->coerceForStorage($value)); } /** - * @return iterable + * @return iterable */ public static function incoercibleValues(): iterable { @@ -60,13 +77,34 @@ public static function incoercibleValues(): iterable 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]; + + yield 'float from words' => ['type' => MetaDataPropertyType::float, 'value' => 'abc']; + yield 'float from empty string' => ['type' => MetaDataPropertyType::float, 'value' => '']; + yield 'float from scientific notation' => ['type' => MetaDataPropertyType::float, 'value' => '1e10']; + yield 'float from NAN' => ['type' => MetaDataPropertyType::float, 'value' => NAN]; + yield 'float from array' => ['type' => MetaDataPropertyType::float, 'value' => [1.0]]; + + yield 'array from JSON scalar' => ['type' => MetaDataPropertyType::array, 'value' => '42']; + yield 'array from malformed JSON' => ['type' => MetaDataPropertyType::array, 'value' => '{not json']; + yield 'array from boolean' => ['type' => MetaDataPropertyType::array, 'value' => true]; + + yield 'dateTime from unparseable string' => ['type' => MetaDataPropertyType::dateTime, 'value' => 'not a date']; + yield 'dateTime from wrong object type' => ['type' => MetaDataPropertyType::dateTime, 'value' => new stdClass()]; + yield 'dateTime from integer' => ['type' => MetaDataPropertyType::dateTime, 'value' => 42]; + + yield 'string from array' => ['type' => MetaDataPropertyType::string, 'value' => ['a']]; + yield 'string from object' => ['type' => MetaDataPropertyType::string, 'value' => new stdClass()]; + + yield 'null is always rejected (string)' => ['type' => MetaDataPropertyType::string, 'value' => null]; + yield 'null is always rejected (integer)' => ['type' => MetaDataPropertyType::integer, 'value' => null]; + yield 'null is always rejected (array)' => ['type' => MetaDataPropertyType::array, 'value' => null]; } /** * @dataProvider incoercibleValues * @test */ - public function valuesThatCannotBeInterpretedAreRejected(MetaDataPropertyType $type, string|int|bool $value): void + public function valuesThatCannotBeInterpretedAreRejected(MetaDataPropertyType $type, mixed $value): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionCode(1785715201); @@ -74,7 +112,7 @@ public function valuesThatCannotBeInterpretedAreRejected(MetaDataPropertyType $t } /** - * @return iterable + * @return iterable */ public static function storedValues(): iterable { @@ -83,15 +121,20 @@ public static function storedValues(): iterable 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]; + yield 'float' => ['type' => MetaDataPropertyType::float, 'value' => '4.2', 'expected' => 4.2]; + yield 'negative float' => ['type' => MetaDataPropertyType::float, 'value' => '-4.2', 'expected' => -4.2]; + yield 'array' => ['type' => MetaDataPropertyType::array, 'value' => '["a","b"]', 'expected' => ['a', 'b']]; + yield 'empty array' => ['type' => MetaDataPropertyType::array, 'value' => '[]', 'expected' => []]; + yield 'dateTime' => ['type' => MetaDataPropertyType::dateTime, 'value' => '2024-01-02T10:00:00+00:00', 'expected' => new DateTimeImmutable('2024-01-02T10:00:00+00:00')]; } /** * @dataProvider storedValues * @test */ - public function storedValuesAreReadBackAsTheirType(MetaDataPropertyType $type, string $value, string|int|bool $expected): void + public function storedValuesAreReadBackAsTheirType(MetaDataPropertyType $type, string $value, mixed $expected): void { - self::assertSame($expected, $type->fromStoredValue($value)); + self::assertEquals($expected, $type->fromStoredValue($value)); } /** @@ -115,6 +158,17 @@ public function storedValuesThatCannotBeInterpretedAreReadAsNull(): void { self::assertNull(MetaDataPropertyType::integer->fromStoredValue('abc')); self::assertNull(MetaDataPropertyType::boolean->fromStoredValue('maybe')); + self::assertNull(MetaDataPropertyType::float->fromStoredValue('abc')); + self::assertNull(MetaDataPropertyType::array->fromStoredValue('{not json')); + self::assertNull(MetaDataPropertyType::dateTime->fromStoredValue('not a date')); self::assertSame('42', MetaDataPropertyType::string->fromStoredValue('42'), 'anything is readable as a string'); } + + /** + * @test + */ + public function dateTimeIsAlwaysReadAsAnImmutableInstance(): void + { + self::assertInstanceOf(DateTimeImmutable::class, MetaDataPropertyType::dateTime->fromStoredValue('2024-01-02T10:00:00+00:00')); + } } diff --git a/Tests/Unit/Fixtures/PropertyDefinitionsFixture.php b/Tests/Unit/Fixtures/PropertyDefinitionsFixture.php index 1478981..9fb897f 100644 --- a/Tests/Unit/Fixtures/PropertyDefinitionsFixture.php +++ b/Tests/Unit/Fixtures/PropertyDefinitionsFixture.php @@ -34,8 +34,9 @@ public static function default(): MetaDataPropertyDefinitions } /** - * The default definitions plus a localized `width` of type integer and a localized `featured` of - * type boolean + * The default definitions plus a localized `width` of type integer, a localized `featured` of type + * boolean, a localized `rating` of type float, a localized `tags` of type array and a localized + * `publishedAt` of type dateTime */ public static function typed(): MetaDataPropertyDefinitions { @@ -44,6 +45,9 @@ public static function typed(): MetaDataPropertyDefinitions self::definition('caption', MetaDataPropertyType::string, false), self::definition('width', MetaDataPropertyType::integer, false), self::definition('featured', MetaDataPropertyType::boolean, false), + self::definition('rating', MetaDataPropertyType::float, false), + self::definition('tags', MetaDataPropertyType::array, false), + self::definition('publishedAt', MetaDataPropertyType::dateTime, false), ); } diff --git a/Tests/Unit/MetaDataManagerTest.php b/Tests/Unit/MetaDataManagerTest.php index 016bec3..278e7ce 100644 --- a/Tests/Unit/MetaDataManagerTest.php +++ b/Tests/Unit/MetaDataManagerTest.php @@ -4,6 +4,7 @@ namespace Neos\MetaData\Tests\Unit; +use DateTimeImmutable; use InvalidArgumentException; use Neos\Flow\Tests\UnitTestCase; use Neos\MetaData\DimensionSpacePointProvider\DimensionSpacePointProvider; @@ -467,6 +468,76 @@ public function globalValuesAreCoercedAsWell(): void self::assertSame('42', $manager->getMetaDataPropertyValue($this->asset, 'copyright')->value); } + /** + * @test + */ + public function floatValuesAreCoercedAndReadBack(): void + { + $manager = $this->managerFor(PropertyDefinitionsFixture::typed()); + $written = []; + $this->storage->method('setMetaDataPropertyValue') + ->willReturnCallback(static function (MetaDataAssetReference $ref, MetaDataPropertyName $name, string $value) use (&$written): void { + $written[$name->value] = $value; + }); + + $manager->setMetaDataPropertyValue($this->asset, 'rating', 4.2, $this->de); + self::assertSame(['rating' => '4.2'], $written); + + $this->storageContains(['rating' => [$this->de->hash => '4.2']]); + self::assertSame(4.2, $manager->getMetaDataPropertyValue($this->asset, 'rating', $this->de)->value); + } + + /** + * @test + */ + public function arrayValuesAreCoercedAndReadBack(): void + { + $manager = $this->managerFor(PropertyDefinitionsFixture::typed()); + $written = []; + $this->storage->method('setMetaDataPropertyValue') + ->willReturnCallback(static function (MetaDataAssetReference $ref, MetaDataPropertyName $name, string $value) use (&$written): void { + $written[$name->value] = $value; + }); + + $manager->setMetaDataPropertyValue($this->asset, 'tags', ['cat', 'cute'], $this->de); + self::assertSame(['tags' => '["cat","cute"]'], $written); + + $this->storageContains(['tags' => [$this->de->hash => '["cat","cute"]']]); + self::assertSame(['cat', 'cute'], $manager->getMetaDataPropertyValue($this->asset, 'tags', $this->de)->value); + } + + /** + * @test + */ + public function dateTimeValuesAreCoercedAndReadBack(): void + { + $manager = $this->managerFor(PropertyDefinitionsFixture::typed()); + $written = []; + $this->storage->method('setMetaDataPropertyValue') + ->willReturnCallback(static function (MetaDataAssetReference $ref, MetaDataPropertyName $name, string $value) use (&$written): void { + $written[$name->value] = $value; + }); + + $manager->setMetaDataPropertyValue($this->asset, 'publishedAt', new DateTimeImmutable('2024-01-02T10:00:00+00:00'), $this->de); + self::assertSame(['publishedAt' => '2024-01-02T10:00:00+00:00'], $written); + + $this->storageContains(['publishedAt' => [$this->de->hash => '2024-01-02T10:00:00+00:00']]); + self::assertEquals(new DateTimeImmutable('2024-01-02T10:00:00+00:00'), $manager->getMetaDataPropertyValue($this->asset, 'publishedAt', $this->de)->value); + } + + /** + * @test + */ + public function anEmptyArrayIsDistinguishableFromAnAbsentValue(): void + { + $manager = $this->managerFor(PropertyDefinitionsFixture::typed()); + $this->storageContains(['tags' => [$this->de->hash => '[]']]); + + $value = $manager->getMetaDataPropertyValue($this->asset, 'tags', $this->de); + self::assertSame([], $value->value); + self::assertTrue($value->hasOwnValue(), 'an empty array is a value, not the absence of one'); + } + // ----------------------- finding assets /** From f31803fb27451a56d0a6a574ac84e3737df4f377 Mon Sep 17 00:00:00 2001 From: Michel Loew Date: Tue, 11 Aug 2026 17:38:55 +0200 Subject: [PATCH 3/3] Document float, array and dateTime property types in the README --- Readme.md | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/Readme.md b/Readme.md index 8662982..4f38e02 100644 --- a/Readme.md +++ b/Readme.md @@ -50,7 +50,7 @@ Neos: | Option | Description | |-------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | `string` (default), `integer` or `boolean` | +| `type` | `string` (default), `integer`, `boolean`, `float`, `array` or `dateTime` | | `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 | @@ -81,26 +81,37 @@ match its scope. Those are never returned when reading, see [`assetmetadata:repa 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. +The *stored representation* is always a `string` (it is a single database column); the *logical value* a +caller passes in or reads back is whatever the property's `type` produces, so +`MetaDataPropertyValue::$value` is declared as `mixed` rather than a fixed union – `MetaDataPropertyType` +is the single place that defines the concrete type per case. 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` | +| Type | Accepted | Stored as | +|------------|-----------------------------------------------------------------------------------------------------|-----------------------------| +| `string` | a `string`, `int`, `float` or `bool` | as provided (booleans as `1`/`0`) | +| `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` | +| `float` | a `float`, an `int`, or a decimal string like `"-4.2"` (no scientific notation, no `NAN`/`INF`) | decimal | +| `array` | a PHP `array`, or a string containing its JSON encoding | JSON | +| `dateTime` | a `DateTimeInterface`, or a string in ISO 8601 (or another unambiguous format PHP can parse) | ISO 8601 (`DATE_ATOM`) | 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`. +value – `"abc"` is not `0`, and a PHP `array` is not a valid `string` value. Surrounding whitespace is +tolerated for `integer`, `boolean` and `float` but kept verbatim for `string`. A `null` value is always +rejected on write, regardless of `type` – setting "no value" is `unsetMetaDataPropertyValue()`, not a +`null` argument here. An empty `array` (`[]`) is a real, storable value, not treated as absent – the same +way `boolean`'s `false` and `integer`'s `0` are. 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`. +readable. A `dateTime` value is always read back as a `DateTimeImmutable`, never a mutable `DateTime`, so +callers cannot accidentally mutate a value they only read. Note that the search of `findAssets()` matches +the *stored* representation, so a `boolean` is matched as `1`/`0` rather than as `true`/`false`, and an +`array` or `dateTime` value is matched against its JSON or ISO 8601 text rather than its PHP shape. ## Usage @@ -292,6 +303,11 @@ the manager hands the storage the fallback chain *ordered*, from the most to the 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. +The value a storage implementation ever sees is always a plain `string` – by the time +`MetaDataManager` calls it, `MetaDataPropertyType::coerceForStorage()` has already turned the logical +value into its stored representation. A storage never has to know or care which `type` a property was +declared with. + `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.