Skip to content

Map each class with a mapper built once per class - #226

Merged
DavidBadura merged 3 commits into
3.0.xfrom
mapper
Oct 17, 2026
Merged

DavidBadura merged 3 commits into
3.0.xfrom
mapper

Conversation

@DavidBadura

Copy link
Copy Markdown
Member

The transformer naming grew out of the old middleware stack and left us with several names for the same thing. This replaces it with a single concept: the hydrator maps every class with a Mapper, middlewares wrap that mapper and normalizers convert single values.

The hydrator now builds one chain per class, once. It consists of the mapper of the class, the middlewares which don't skip it and the lazy proxy for lazy classes. A middleware gets the rest of that chain as a Mapper instead of Next, so nothing is allocated per call anymore. Skipping per direction still works, a link simply passes the skipped direction on.

MapperFactory replaces ClassTransformerFactory and receives the StackHydrator. Its new mapper() method returns the chain of a class, which is the hook for generated code. The ReflectionMapper uses it to map nested objects of an ObjectNormalizer right away instead of going through the normalizer and the hydrator again, as long as the call comes from its own hydrator. ChainTransformerFactory and ReflectionTransformerFactory are gone.

For consistency PRIORITY_BEFORE_TRANSFORM and the beforeTransform argument of the UpcastExtension are renamed to PRIORITY_BEFORE_MAPPING and beforeMapping. The upgrade guide covers all of it.

Compared to 3.0.x, hydrating and extracting the benchmark event with three nested objects takes roughly 40% less time. With 20 nested objects it is about twice as fast.

The hydrator now builds one chain per class, once: the mapper of the class
wrapped in the middlewares which run for it and, for lazy classes, the lazy
proxy. A middleware gets the rest of that chain as a Mapper instead of Next,
so nothing is allocated per call anymore.

Transformer is renamed to Mapper throughout. MapperFactory replaces
ClassTransformerFactory and gets the StackHydrator, whose mapper() returns
the chain of a class. The ReflectionMapper uses it to map nested objects of
an ObjectNormalizer right away instead of going through the normalizer and
the hydrator again. ChainTransformerFactory and ReflectionTransformerFactory
are gone, the hydrator asks its factories in order and falls back to the
ReflectionMapper.

PRIORITY_BEFORE_TRANSFORM and the beforeTransform argument of the
UpcastExtension are renamed to PRIORITY_BEFORE_MAPPING and beforeMapping.
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Hello 👋

here is the most recent benchmark result:

StackHydratorBench
==================

+-------------------------------+---------------------------------------+------------------------------+
|                               | time (kde mode)                       | memory                       |
+-------------------------------+--------------------+------------------+-----------------+------------+
| subject                       | Tag: <current>     | Tag: base        | Tag: <current>  | Tag: base  |
+-------------------------------+--------------------+------------------+-----------------+------------+
| benchHydrate1Object ()        | 2.400μs (±0.00%)   | 2.800μs (±0.00%) | 2.832mb         | 3.272mb    |
| benchExtract1Object ()        | 2.200μs (±0.00%)   | 3.000μs (±0.00%) | 2.832mb         | 3.272mb    |
| benchHydrate1000Objects ()    | 1.538ms (±0.00%)   | 2.185ms (±0.00%) | 2.912mb         | 3.516mb    |
| benchExtract1000Objects ()    | 1.449ms (±0.00%)   | 2.109ms (±0.00%) | 2.908mb         | 3.512mb    |
| benchHydrate1000000Objects () | 650.300ms (±0.00%) | 1.163s (±0.00%)  | 2.912mb         | 3.516mb    |
| benchExtract1000000Objects () | 666.996ms (±0.00%) | 1.236s (±0.00%)  | 2.908mb         | 3.032mb    |
+-------------------------------+--------------------+------------------+-----------------+------------+

HydratorWithLazyBench
=====================

+------------------------------------------+-----------------------------------------+------------------------------+
|                                          | time (kde mode)                         | memory                       |
+------------------------------------------+--------------------+--------------------+-----------------+------------+
| subject                                  | Tag: <current>     | Tag: base          | Tag: <current>  | Tag: base  |
+------------------------------------------+--------------------+--------------------+-----------------+------------+
| benchHydrate1Object ()                   | 0.400μs (±0.00%)   | 0.800μs (±0.00%)   | 2.832mb         | 2.772mb    |
| benchHydrate1ObjectTriggerInit ()        | 2.600μs (±0.00%)   | 3.400μs (±0.00%)   | 2.832mb         | 2.772mb    |
| benchHydrate1000Objects ()               | 294.000μs (±0.00%) | 447.000μs (±0.00%) | 2.832mb         | 2.772mb    |
| benchHydrate1000ObjectsTriggerInit ()    | 2.035ms (±0.00%)   | 2.859ms (±0.00%)   | 2.896mb         | 3.020mb    |
| benchHydrate1000000Objects ()            | 188.210ms (±0.00%) | 256.071ms (±0.00%) | 2.832mb         | 2.772mb    |
| benchHydrate1000000ObjectsTriggerInit () | 1.273s (±0.00%)    | 1.820s (±0.00%)    | 2.896mb         | 3.020mb    |
+------------------------------------------+--------------------+--------------------+-----------------+------------+

StackHydratorWithCryptographyBench
==================================

+-------------------------------+---------------------------------------+------------------------------+
|                               | time (kde mode)                       | memory                       |
+-------------------------------+-------------------+-------------------+-----------------+------------+
| subject                       | Tag: <current>    | Tag: base         | Tag: <current>  | Tag: base  |
+-------------------------------+-------------------+-------------------+-----------------+------------+
| benchHydrate1Object ()        | 5.600μs (±0.00%)  | 6.200μs (±0.00%)  | 2.909mb         | 2.822mb    |
| benchExtract1Object ()        | 11.600μs (±0.00%) | 13.200μs (±0.00%) | 2.909mb         | 2.822mb    |
| benchHydrate1000Objects ()    | 2.556ms (±0.00%)  | 3.594ms (±0.00%)  | 2.995mb         | 2.991mb    |
| benchExtract1000Objects ()    | 6.361ms (±0.00%)  | 7.704ms (±0.00%)  | 3.201mb         | 3.202mb    |
| benchHydrate1000000Objects () | 1.203s (±0.00%)   | 2.034s (±0.00%)   | 2.995mb         | 2.991mb    |
| benchExtract1000000Objects () | 4.571s (±0.00%)   | 5.502s (±0.00%)   | 3.201mb         | 3.202mb    |
+-------------------------------+-------------------+-------------------+-----------------+------------+

HydratorBench
=============

+-------------------------------+---------------------------------------+------------------------------+
|                               | time (kde mode)                       | memory                       |
+-------------------------------+--------------------+------------------+-----------------+------------+
| subject                       | Tag: <current>     | Tag: base        | Tag: <current>  | Tag: base  |
+-------------------------------+--------------------+------------------+-----------------+------------+
| benchHydrate1Object ()        | 2.000μs (±0.00%)   | 3.000μs (±0.00%) | 2.832mb         | 2.772mb    |
| benchExtract1Object ()        | 2.600μs (±0.00%)   | 3.200μs (±0.00%) | 2.832mb         | 2.772mb    |
| benchHydrate1000Objects ()    | 1.538ms (±0.00%)   | 2.225ms (±0.00%) | 2.912mb         | 3.035mb    |
| benchExtract1000Objects ()    | 1.480ms (±0.00%)   | 2.239ms (±0.00%) | 2.908mb         | 3.032mb    |
| benchHydrate1000000Objects () | 683.504ms (±0.00%) | 1.167s (±0.00%)  | 2.912mb         | 3.035mb    |
| benchExtract1000000Objects () | 675.915ms (±0.00%) | 1.265s (±0.00%)  | 2.908mb         | 3.032mb    |
+-------------------------------+--------------------+------------------+-----------------+------------+

HydratorWithCryptographyBench
=============================

+-------------------------------+---------------------------------------+------------------------------+
|                               | time (kde mode)                       | memory                       |
+-------------------------------+-------------------+-------------------+-----------------+------------+
| subject                       | Tag: <current>    | Tag: base         | Tag: <current>  | Tag: base  |
+-------------------------------+-------------------+-------------------+-----------------+------------+
| benchHydrate1Object ()        | 4.600μs (±0.00%)  | 6.000μs (±0.00%)  | 2.909mb         | 2.822mb    |
| benchExtract1Object ()        | 13.200μs (±0.00%) | 13.200μs (±0.00%) | 2.909mb         | 2.822mb    |
| benchHydrate1000Objects ()    | 2.527ms (±0.00%)  | 3.558ms (±0.00%)  | 2.995mb         | 2.991mb    |
| benchExtract1000Objects ()    | 6.326ms (±0.00%)  | 7.646ms (±0.00%)  | 3.201mb         | 3.202mb    |
| benchHydrate1000000Objects () | 1.206s (±0.00%)   | 1.988s (±0.00%)   | 2.995mb         | 2.991mb    |
| benchExtract1000000Objects () | 4.525s (±0.00%)   | 5.453s (±0.00%)   | 3.201mb         | 3.202mb    |
+-------------------------------+-------------------+-------------------+-----------------+------------+

This comment gets update everytime a new commit comes in!

When the chain of a nested class is a ReflectionMapper of the same hydrator,
its properties are mapped right away. The object to populate and the hydrator
in the context were already checked by the parent mapper.

@DanielBadura DanielBadura left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Besides the 2 comments, LGTM

Comment thread docs/UPGRADE-3.0.md Outdated
*
* @return array<string, array{class-string, bool}>
*
* @infection-ignore-all

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why thought 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nested shortcut is only an optimization: if a mutation breaks it, the object goes through the ObjectNormalizer and StackHydrator::hydrate() and ends up in the same chain with the same result. So those mutants are equivalent and can't be killed by a test, the StackHydrator is final and there's nothing to observe. Without the annotation the diff MSI drops below 90%.

@DavidBadura
DavidBadura merged commit a1a4f74 into 3.0.x Oct 17, 2026
16 of 17 checks passed
@DavidBadura
DavidBadura deleted the mapper branch October 17, 2026 06:52
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.

2 participants