Skip to content

test: adapt json-streamer and object-mapper fixtures to Symfony 8.1.4 - #8461

Merged
soyuka merged 1 commit into
api-platform:4.3from
soyuka:test/symfony-8-1-4-streamer-mapper-drift
Aug 16, 2026
Merged

test: adapt json-streamer and object-mapper fixtures to Symfony 8.1.4#8461
soyuka merged 1 commit into
api-platform:4.3from
soyuka:test/symfony-8-1-4-streamer-mapper-drift

Conversation

@soyuka

@soyuka soyuka commented Aug 16, 2026

Copy link
Copy Markdown
Member

One of three PRs finishing the 4.3 CI unblock started in #8458. Test-only — no shipped code changes. Both hunks are adaptations to Symfony 8.1.4 components that moved while 4.3 CI was dark.

json-streamer 8.1.4 — rating

Upstream commit 57ff6c1 "[JsonStreamer] Use readable JSON encode defaults" (2026-08-01) added JSON_PRESERVE_ZERO_FRACTION. Floats with integral values now encode as 5.0 rather than 5.

JsonStreamResource::$rating is declared float (tests/Fixtures/TestBundle/Entity/JsonStreamResource.php:50), so emitting 5.0 is correct JSON semantics and round-trips the declared type. The previous assertIsInt only passed because the encoder was lossy about it. Assertions changed to assertIsFloat / assertSame(0.0, ...).

The same stale assertion in testJsonStreamerWriteJson is fixed too. That test is currently skipped, so it was not failing — it would have failed the day the skip lifts.

Note views (declared int) is unaffected and still asserted as an int, and totalItems is deliberately not touched here — that one was a genuine bug in shipped code, fixed separately.

object-mapper 8.1.4 — MappedResourceWithRelation::$id

Upstream commit 38f9183 "[ObjectMapper] Honor the target property #[Map] for the same-name copy when the source carries metadata" (2026-08-05) changed the same-name-copy path to consult the target's #[Map].

MappedResourceWithRelation::$id (?string) carried no #[Map], so on write it took that path, picked up the entity's #[Map(transform: 'strval')] (MappedResourceWithRelationEntity.php:25), and fed a string into setId(?int) (:43):

Expected argument of type "?int", "string" given at property path "id"

The fixture only ever declared the forward transform (entity int → DTO string) and left the write direction as an untyped same-name copy that worked only because the mapper was not consulting anything. Declaring the inverse #[Map(transform: 'intval')] is the missing half, not a workaround.

Confirmed by pinning: object-mapper 8.1.1 passes, 8.1.4 fails, and the fix passes on both — so it is version-robust rather than tuned to the current release.

json-streamer 8.1.4 encodes with JSON_PRESERVE_ZERO_FRACTION, so the float
$rating property is now emitted as 5.0 rather than 5; assert a float.

object-mapper 8.1.4 honours the target property's #[Map] for the same-name copy,
so the untransformed string id reached a ?int setter. Declare the inverse
transform the fixture was missing; verified on both 8.1.1 and 8.1.4.
@soyuka
soyuka force-pushed the test/symfony-8-1-4-streamer-mapper-drift branch from ab90147 to 0b0684a Compare August 16, 2026 15:25
@soyuka
soyuka merged commit e3cb2da into api-platform:4.3 Aug 16, 2026
107 of 110 checks passed
nicolas-grekas added a commit to symfony/symfony that referenced this pull request Aug 17, 2026
…ap] for the same-name copy (soyuka)

This PR was merged into the 7.4 branch.

Discussion
----------

[ObjectMapper] Only honor an explicitly inbound target #[Map] for the same-name copy

| Q             | A
| ------------- | ---
| Branch?       | 7.4
| Bug fix?      | yes
| New feature?  | no
| Deprecations? | no
| Issues        | no upstream issue, reported downstream as api-platform/core#8461, details below
| License       | MIT

Follow-up to #65154 ("[ObjectMapper] Fix target property mappings dropped when the source carries metadata"), released in 7.4.16 and 8.1.4.

That change was correct in intent but its filter cannot tell an inbound mapping from an outbound one, so a `#[Map(transform:)]` written for one direction now fires in both.

## The problem

`ObjectMapper` encodes mapping direction by which side the metadata is read from. `doMap()` resolves `$mapping->source` only when reading from the target, and `$mapping->target` only when reading from the source:

```php
$sourcePropertyName = $readMetadataFromTarget ? $mapping->source ?? $propertyName : $propertyName;
$targetPropertyName = $mapping->target ?? $propertyName;
```

The same-name cross-read added in #65154 discards that distinction. Its filter defaults both sides to the property name:

```php
if (null === $mapping->if
    && ($mapping->target ?? $propertyName) === $propertyName
    && ($mapping->source ?? $propertyName) === $propertyName
) {
```

so these three become indistinguishable:

| declaration | intent | matched before |
| --- | --- | --- |
| `#[Map(transform: T)]` | outbound (its own class is the source) | yes |
| `#[Map(target: 'id', transform: T)]` | outbound | yes |
| `#[Map(source: 'id', transform: T)]` | inbound | yes |

Only the third describes an inbound copy.

## Before / after

```php
#[Map(target: ProductInput::class)]
class Product
{
    #[Map(transform: 'strtoupper')]
    public string $reference = 'sf-1';
}

#[Map(target: Product::class)]
class ProductInput
{
    public string $reference = 'sf-1';
}

$product = (new ObjectMapper())->map(new ProductInput(), Product::class);
```

- **before:** `$product->reference === 'SF-1'`. The transform declared for `Product` → `ProductInput` also ran on the way in
- **after:** `$product->reference === 'sf-1'`

Mapping the other way is unchanged and still applies the transform:

```php
$input = (new ObjectMapper())->map(new Product(), ProductInput::class);
// 'SF-1', before and after
```

## The fix

Require the target-side mapping to name its source explicitly before honoring it in the cross-read:

```diff
 if (null === $mapping->if
+    && $propertyName === $mapping->source
     && ($mapping->target ?? $propertyName) === $propertyName
-    && ($mapping->source ?? $propertyName) === $propertyName
 ) {
```

#65154's own fixture declares `#[Map(source: 'type', transform: [ToTypeDto::class, 'transform'])]` on `LeadDto`, so it still matches and `testSameNameTargetPropertyMappingIsHonoredWhenSourceCarriesMetadata` passes unmodified. No existing test or fixture is touched.

This also lines the runtime back up with the documented model. The docs teach a bare `transform` on the class that is the *source*:

```php
#[Map(target: Product::class)]
class ProductInput
{
    #[Map(transform: 'intval')]
    public string $stockLevel = '100';
}
```

and state that target-side `#[Map(source: ...)]` applies when the source class does not map the property.

## How it was found

Reported downstream: api-platform/core#8461. API Platform maps DTOs to Doctrine entities, and an entity carrying `#[Map(transform: 'strval')]` on `$id` for the entity → DTO direction started transforming on writes too, pushing a string into a `?int` setter:

```
Expected argument of type "?int", "string" given at property path "id"
```

Bisected to #65154: pinning `symfony/object-mapper` to 8.1.1 passes, 8.1.4 fails.

## Note for the merge-up: the blast radius differs between 7.4 and 8.x

Worth flagging for whoever merges this up, because the 7.4 test coverage does **not** demonstrate the 8.x exposure.

On 7.4, `$readMetadataFromTarget === false` is only reached when the source class itself carries a `#[Map]` attribute. The affected population is small.

On 8.x, FrameworkBundle registers `ReverseClassObjectMapperMetadataFactory`, which puts source classes into a class map wholesale. That drives many more mappings down the `$readMetadataFromTarget === false` path, so a bare target-side `transform` fires inbound for a much wider set of applications. That is why this surfaced in an API Platform app on 8.x rather than in a plain 7.4 one.

I could not run the FrameworkBundle functional tests locally (they need a full root install). I checked its ObjectMapper fixtures by hand. Every `#[Map(transform:)]` there is declared on a source-side class, so none should be affected. The 8.x FrameworkBundle suite is still the real gate, and deserves attention on the merge-up rather than being assumed green from this branch.

## Backward compatibility

The behaviour being removed shipped in 7.4.16 (2026-08-05) and 8.1.4 (2026-08-06), so it has been out for roughly ten days. Someone could in principle have started relying on a bare target-side `transform` firing inbound, but:

- it is undocumented, and contradicts the documented meaning of a bare `transform`
- anyone who wants an inbound transform has `#[Map(source: '<name>', transform: ...)]`, which is unaffected and is the shape #65154's own fixture uses

7.3 does not have the cross-read at all, so 7.4 is the lowest branch where this applies.

Commits
-------

1040321 [ObjectMapper] Only honor an explicitly inbound target #[Map] for the same-name copy
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant