From 30f64ae827188731024fae41e5d60265ed25df29 Mon Sep 17 00:00:00 2001 From: Nicolas Joubert Date: Fri, 9 Oct 2026 10:48:29 +0200 Subject: [PATCH] fix(transformer) #244 Fix MappingTransformer: a missing target property of a `\stdClass` destination (`initial_value` or `keep_input`) threw `Property '...' is not writable`, it is now added when the target is a simple property name (nested paths still throw). Update documentation, add tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + .../transformers/mapping_transformer.md | 8 ++- src/Transformer/MappingTransformer.php | 3 + tests/Transformer/MappingTransformerTest.php | 59 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdf7f535..9c73f5a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Latest ## Fixes * [#143](https://github.com/cleverage/process-bundle/issues/143) Fix InputIteratorTask: an `\IteratorAggregate` input whose `getIterator()` does not return an `\Iterator` (e.g. another `\IteratorAggregate`) is iterated instead of failing with a `TypeError`. Update documentation, add tests. +* [#244](https://github.com/cleverage/process-bundle/issues/244) Fix MappingTransformer: a missing target property of a `\stdClass` destination (`initial_value` or `keep_input`) threw `Property '...' is not writable`, it is now added when the target is a simple property name (nested paths still throw). Update documentation, add tests. v5.1 ----- diff --git a/docs/reference/transformers/mapping_transformer.md b/docs/reference/transformers/mapping_transformer.md index 1b19750f..9a42349f 100644 --- a/docs/reference/transformers/mapping_transformer.md +++ b/docs/reference/transformers/mapping_transformer.md @@ -9,7 +9,8 @@ The algorithm is: * for each target property of `mapping`: - get the source value (from `constant`, `set_null`, or the `code` property path(s)) - apply the property `transformers` on this value - - write the result into the destination (with `merge_callback`, the property accessor, or as a simple array key) + - write the result into the destination (with `merge_callback`, the property accessor, as a simple array key, or as + a new property of a `\stdClass`) Transformer reference --------------------- @@ -32,7 +33,7 @@ Options | Code | Type | Required | Default | Description | |------------------|------------------|:--------:|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `mapping` | `array` | **X** | | List of `target property => property options` (see below). The target is a writable property path of the destination, or a plain array key | +| `mapping` | `array` | **X** | | List of `target property => property options` (see below). The target is a writable property path of the destination, a plain array key or a `\stdClass` property | | `ignore_missing` | `bool` | | `false` | Ignore property accessor read errors for the whole mapping (the property is then skipped) | | `keep_input` | `bool` | | `false` | Use the input as the destination. Cannot be combined with a non-empty `initial_value`. Due to PHP behavior, arrays are copied while objects are modified in place | | `initial_value` | `any` | | `[]` | The destination to fill | @@ -136,5 +137,8 @@ Notes instead of throwing for a missing array index (`framework.property_access.throw_exception_on_invalid_index`), so `ignore_missing` mostly matters for objects. * When a sub-transformer fails, the thrown `TransformerException` reports the target property. +* A missing property of a `\stdClass` destination is added only for a simple target property name (e.g. `name`): a + nested path (e.g. `address.city`) or an index notation (e.g. `[name]`) that is not writable throws an + `\UnexpectedValueException` (`Property '...' is not writable`), as for any other object. * `merge_callback` receives the destination by value: to modify an array destination, the callable must take its first argument by reference. diff --git a/src/Transformer/MappingTransformer.php b/src/Transformer/MappingTransformer.php index 2668c403..b7debb35 100644 --- a/src/Transformer/MappingTransformer.php +++ b/src/Transformer/MappingTransformer.php @@ -115,6 +115,9 @@ public function transform(mixed $value, array $options = []): mixed $this->accessor->setValue($result, $targetProperty, $transformedValue); } elseif (\is_array($result)) { $result[$targetProperty] = $transformedValue; + } elseif ($result instanceof \stdClass && 1 === preg_match('/^[^.[\]]+$/', $targetProperty)) { + // Only a simple property name can be added to a \stdClass, nested paths are not created + $result->{$targetProperty} = $transformedValue; } else { throw new \UnexpectedValueException("Property '{$targetProperty}' is not writable"); } diff --git a/tests/Transformer/MappingTransformerTest.php b/tests/Transformer/MappingTransformerTest.php index 6fd7beee..695e302a 100644 --- a/tests/Transformer/MappingTransformerTest.php +++ b/tests/Transformer/MappingTransformerTest.php @@ -18,6 +18,7 @@ use CleverAge\ProcessBundle\Transformer\CallbackTransformer; use CleverAge\ProcessBundle\Transformer\MappingTransformer; use CleverAge\ProcessBundle\Transformer\TransformerTrait; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Psr\Log\AbstractLogger; use Psr\Log\LoggerInterface; @@ -364,6 +365,64 @@ public function testInitialValueCanBeAnObject(): void self::assertEquals((object) ['field2' => 'value'], $result); } + public function testNewPropertyIsAddedToAStdClassInitialValue(): void + { + $transformer = $this->createTransformer(); + $options = $this->resolveOptions($transformer, [ + 'initial_value' => new \stdClass(), + 'mapping' => [ + 'field2' => ['code' => '[field]'], + ], + ]); + + $result = $transformer->transform(['field' => 'value'], $options); + + self::assertEquals((object) ['field2' => 'value'], $result); + } + + public function testKeepInputAddsANewPropertyToAStdClassInput(): void + { + $transformer = $this->createTransformer(); + $options = $this->resolveOptions($transformer, [ + 'keep_input' => true, + 'mapping' => [ + 'field2' => ['code' => 'field'], + ], + ]); + $input = (object) ['field' => 'value']; + + $result = $transformer->transform($input, $options); + + self::assertSame($input, $result); + self::assertEquals((object) ['field' => 'value', 'field2' => 'value'], $input); + } + + /** + * @return iterable + */ + public static function nonSimpleTargetPropertyProvider(): iterable + { + yield 'nested path' => ['field2.child']; + yield 'index notation' => ['[field2]']; + } + + #[DataProvider('nonSimpleTargetPropertyProvider')] + public function testMissingNonSimpleTargetPropertyOfAStdClassThrows(string $targetProperty): void + { + $transformer = $this->createTransformer(); + $options = $this->resolveOptions($transformer, [ + 'initial_value' => new \stdClass(), + 'mapping' => [ + $targetProperty => ['constant' => 'value'], + ], + ]); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage("Property '{$targetProperty}' is not writable"); + + $transformer->transform([], $options); + } + public function testKeepInputCopiesAnArrayInput(): void { $transformer = $this->createTransformer();