diff --git a/bench/.gitignore b/bench/.gitignore new file mode 100644 index 00000000..cfdf5558 --- /dev/null +++ b/bench/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/var/ +.phpbench/ diff --git a/bench/README.md b/bench/README.md index 055a1352..d1ac3b4c 100644 --- a/bench/README.md +++ b/bench/README.md @@ -1,3 +1,333 @@ -# Bench package - -This package benchmarks performance of some PHP operations that allow us to take decision on which one to use. +# AutoMapper benchmarks + +This package benchmarks [jolicode/automapper](https://github.com/jolicode/automapper) +against the Symfony components that solve overlapping problems, and against the +raw PHP `json_encode` / `json_decode` baseline: + +- **`json_encode` / `json_decode`** — the theoretical floor (no object hydration). +- **AutoMapper** — in several configurations (see below). +- **AutoMapper JSON streamer** — the `AutoMapper\JsonStreamer\JsonStream{Reader,Writer}` + bridge that plugs AutoMapper into `symfony/json-streamer`. +- **Symfony Serializer** (`symfony/serializer`). +- **Symfony JSON streamer** (`symfony/json-streamer`). +- **Symfony ObjectMapper** (`symfony/object-mapper`), for the object-to-object case. + +It is built on [PHPBench](https://phpbench.readthedocs.io). Every subject uses the +exact same data (see `src/Factory/PayloadFactory.php`) so the numbers are directly +comparable, and each library is wired once, outside the measured loop +(`src/Factory/MapperFactory.php`). + +## Installing + +```bash +cd bench +composer install +``` + +The package depends on the checked-out AutoMapper via a Composer `path` repository, +so it always benchmarks your working copy. + +## Running + +```bash +# Everything, with timing + peak memory +php vendor/bin/phpbench run --report=aggregate + +# A single scenario +php vendor/bin/phpbench run src/DeserializeBench.php --report=aggregate + +# Focus on memory (custom report defined in phpbench.json) +php vendor/bin/phpbench run src/CollectionBench.php --report=memory + +# By group +php vendor/bin/phpbench run --group=denormalize --report=aggregate +php vendor/bin/phpbench run --group=normalize --report=aggregate +php vendor/bin/phpbench run --group=deserialize --report=aggregate +php vendor/bin/phpbench run --group=serialize --report=aggregate +php vendor/bin/phpbench run --group=object-to-object --report=aggregate +php vendor/bin/phpbench run --group=collection --report=memory +php vendor/bin/phpbench run --group=write-collection --report=memory +php vendor/bin/phpbench run --group=write-wide-object --report=memory +``` + +## Verifying correctness + +A benchmark is only fair if every approach in a group produces the **same result** +for the same input. That is enforced two ways: + +- `bin/verify.php` runs each group and prints an `ok` / `DIFF` report, exiting + non-zero on any divergence: + + ```bash + php bin/verify.php + ``` + +- Every benchmark also calls the matching `Verifier::assert*()` from its `setUp()`, + so `phpbench run` fails fast if any approach drifts out of agreement. + +Results are compared *canonically* — normalized to arrays with keys sorted +recursively — so an approach that emits object keys in a different order (AutoMapper +sorts them, for instance) still counts as equal as long as the data matches. See +`src/Verifier.php`. + +## Scenarios + +The four (de)serialization suites are split so each measures exactly one thing, and +every subject in a suite has the **same input and output shape**: + +| Suite | File | Direction | JSON step? | +|-------|------|-----------|------------| +| Denormalize | `src/DenormalizeBench.php` | array → `Person` object | no | +| Normalize | `src/NormalizeBench.php` | `Person` object → array | no | +| Deserialize | `src/DeserializeBench.php` | JSON string → `Person` object | yes (`json_decode`) | +| Serialize | `src/SerializeBench.php` | `Person` object → JSON string | yes (`json_encode`) | +| Object to object | `src/ObjectToObjectBench.php` | `PersonSource` → `PersonTarget` | — | +| Collection (read) & memory | `src/CollectionBench.php` | large JSON array → `Person` list | **read `mem_peak`** | +| Collection (write) & memory | `src/WriteCollectionBench.php` | `Person` list → large JSON array | **read `mem_peak`** | +| Wide-object write & memory | `src/WriteWideObjectBench.php` | one `Person` with a huge nested list → JSON | **read `mem_peak`** | + +Normalize/Denormalize isolate the pure mapping cost; Serialize/Deserialize add the +`json_encode`/`json_decode` step on top, so the difference between the two is the +JSON cost. The JSON streamers only appear in the JSON suites (they have no +array-producing/consuming step). + +### Symfony services are wired like the framework + +The Symfony Serializer and ObjectMapper are built to match FrameworkBundle's default +services (see `src/Factory/MapperFactory.php`), not a stripped-down minimum: + +- **Serializer** — `ObjectNormalizer` with a cached `ClassMetadataFactory` + (`CacheClassMetadataFactory`), a cached `property_info` (`PropertyInfoCacheExtractor`) + and a cached `PropertyAccessor`, exactly as `serializer.normalizer.object` is configured. +- **ObjectMapper** — the internally-caching `ReflectionObjectMapperMetadataFactory` + plus a cached `PropertyAccessor`, as the `object_mapper` service is configured. + +Note this makes the Symfony numbers *higher*, not lower, than a bare hand-built +setup: the `ClassMetadataFactory` and `PropertyAccessor` the framework always wires +add real per-call cost, and the metadata caches don't help a warm, instance-reused +benchmark (the normalizer already memoizes type info internally). Using the real +default services is the fair comparison — an application pays this cost. + +### AutoMapper configurations compared + +The **denormalize** suite exercises several AutoMapper `Configuration` variants so you +can see what each knob costs — the config only affects the mapping step, so that is +where it is measured (see `src/Factory/MapperFactory.php`): + +- **default** — `FileLoader` on-disk cache, `ConstructorStrategy::AUTO` (out of the box). +- **eval** — `EvalLoader`, no on-disk cache (mappers eval'd into the process). +- **no constructor** — `ConstructorStrategy::NEVER` (writes properties directly). +- **no attribute checking** — `attributeChecking: false`, `mapPrivateProperties: false`. + +## Results + +Indicative numbers on PHP 8.5 (your absolute numbers will differ; the *ratios* are +the point). Lower is better. + +### Single object — denormalize (array → object, no JSON) + +| Approach | Time | vs AutoMapper | +|----------|-----:|--------------:| +| AutoMapper, no attribute checking | ~3.0 µs | fastest mapper | +| AutoMapper (default) | ~7.4 µs | 1× | +| AutoMapper, eval loader | ~7.3 µs | — | +| AutoMapper, no constructor | ~7.4 µs | — | +| Symfony Serializer (`denormalize`) | ~167 µs | **~23× slower** | + +### Single object — normalize (object → array, no JSON) + +| Approach | Time | +|----------|-----:| +| AutoMapper, no attribute checking | ~2.9 µs | +| AutoMapper (default) | ~8.2 µs | +| Symfony Serializer (`normalize`) | ~66 µs | + +### Single object — deserialize (JSON → object) + +Every object-producing subject **fully realizes** the `Person` graph (reads all its +fields) so the work is comparable — see the fairness note below. + +| Approach | Time | +|----------|-----:| +| `json_decode` (array only, no hydration — *pure floor*) | ~2.4 µs | +| **Manual** (`json_decode` + hand-written hydration — *fair floor*) | ~3.3 µs | +| AutoMapper, no attribute checking (`json_decode` + map) | ~6 µs | +| AutoMapper (`json_decode` + map) | ~11 µs | +| AutoMapper JSON streamer, no attribute checking | ~91 µs | +| AutoMapper JSON streamer | ~101 µs | +| Symfony JSON streamer | ~155 µs | +| Symfony Serializer | ~167 µs | + +> **Fairness note.** Symfony's `JsonStreamReader::read()` on a `Type::object` +> returns a *lazy ghost* whose hydration is deferred until a property is read, so a +> subject that never touched the result would clock ~13 µs while actually doing +> almost nothing. Reading the whole graph forces that deferred work, and the fully +> hydrated cost is ~155 µs. The AutoMapper JSON streamer, which hands back a fully +> mapped object up front, is actually *faster* than the stock streamer once both +> produce a usable object. + +Note the two JSON streamers are an order of magnitude slower than plain `map()` for a +single object: they exist to stream **large collections** at flat memory (see below), +and pay a heavy per-call overhead that only amortizes over big inputs. For single +objects, `map()` + native `json_decode` is the right tool. + +### Single object — serialize (object → JSON) + +| Approach | Time | +|----------|-----:| +| `json_encode` (array only, no traversal — *pure floor*) | ~0.8 µs | +| **Manual** (hand-written normalization + `json_encode` — *fair floor*) | ~1.2 µs | +| AutoMapper, no attribute checking (map + `json_encode`) | ~3.9 µs | +| AutoMapper JSON streamer, no attribute checking | ~8 µs | +| Symfony JSON streamer | ~9 µs | +| AutoMapper (map + `json_encode`) | ~10 µs | +| AutoMapper JSON streamer | ~14 µs | +| Symfony Serializer | ~66 µs | + +The AutoMapper JSON streamer writer was ~25 µs here until its `__toString()` was +changed to encode the lazy structure in one native `json_encode` call (both +`LazyMap` and `LazyCollection` are `JsonSerializable`) instead of concatenating +hand-built chunks — a ~1.85× speed-up (2.5× with attribute checking off), with +byte-identical output. Chunk streaming is still used when the result is *iterated* +(`getIterator`), for callers writing to an output stream. + +The **manual** row is the fair baseline: it produces/consumes the same `Person` +graph the libraries do, just by hand (see `src/ManualMapper.php`). The pure +`json_*` rows never touch a `Person`, so they only show the JSON cost in isolation. + +### Object to object + +| Approach | Time | +|----------|-----:| +| Manual (hand-written) | ~0.2 µs | +| AutoMapper, no attribute checking | ~1.6 µs | +| AutoMapper | ~4.9 µs | +| AutoMapper ObjectMapper bridge | ~5.4 µs | +| Symfony ObjectMapper | ~48 µs (**~10× slower**) | + +### Collection — peak memory (the headline) + +Reading a JSON array of `Person` objects, **every object fully hydrated** (the loop +reads a nested field so each library does the same work — see the note below). +Peak memory (`mem_peak`) and time for 20 000 items: + +| Approach | 1 000 items | 20 000 items | time (20k) | +|----------|------------:|-------------:|-----------:| +| `json_decode` (array only, no objects) | 11 MB | 90 MB | 0.10 s | +| AutoMapper `mapCollection` (eager) | 14 MB | 107 MB | 0.29 s | +| Symfony Serializer | 14 MB | 101 MB | 3.44 s | +| Symfony JSON streamer, **iterable** (lazy) | 9.8 MB | **9.8 MB** | 4.11 s | +| Symfony JSON streamer, `list` (materialized) | 43 MB | 681 MB | 4.50 s | +| AutoMapper JSON streamer, buffered | 12 MB | 51 MB | 3.16 s | +| AutoMapper JSON streamer, buffered, no attr checking | 12 MB | 51 MB | 2.96 s | +| **AutoMapper JSON streamer, `STREAM` mode** | **9.8 MB** | **9.8 MB** | 3.06 s | +| **AutoMapper JSON streamer, `STREAM`, no attr checking** | **9.8 MB** | **9.8 MB** | 3.11 s | + +Disabling attribute checking barely moves the streamer here (buffered 3.16 → 2.96 s, +`STREAM` ~unchanged): unlike the plain `map()` path — where it's a ~2–3× win — the +streamer's time is dominated by Symfony's userland JSON tokenizer decoding each +element, so the AutoMapper mapping step it speeds up is only a small slice. Memory is +identical, since attribute checking is a code-generation concern, not a runtime one. + +Both truly-streaming readers — Symfony's `iterable` shape and AutoMapper's `STREAM` +mode — keep a **flat ~10 MB** peak regardless of collection size, because they yield +one object at a time and never hold the whole collection in memory. Streaming trades +throughput for memory: it is slower per element than the eager mappers, so use it +when the input is large enough that memory — not wall time — is the constraint. +AutoMapper's streaming reader is a little faster here and, unlike Symfony's raw +reader, runs each element through the full AutoMapper pipeline (renames, transformers, +`#[MapTo]`, private properties, discriminators, …). + +#### `iterable` vs `list`: the type drives whether Symfony actually streams + +The same JSON array read by the same reader costs **~10 MB or ~680 MB** depending +only on the requested type: + +- **`Type::iterable(Type::object(Person::class), Type::int())`** → `read()` returns a + `Generator` that decodes and yields one hydrated `Person` at a time. Flat, ~10 MB. +- **`Type::list(Type::object(Person::class))`** → the generated reader ends with + `iterator_to_array(...)`, materializing the whole collection. Each element is a + `ReflectionClass::newLazyGhost()` whose initializer closure captures its own + *suspended* boundary generator (lexer + stream state). At 20 000 elements that is + ~20 000 ghosts + ~20 000 live generators retained at once — ~30 KB each, hence the + ~679 MB peak. It holds even if the ghosts are never hydrated. **Prefer `iterable`.** + +The `int` key type is what tells Symfony to read a JSON array (`[…]`) rather than an +object (`{…}`) — omit it and the reader expects `{…}` and throws on a list. + +> **Fairness note.** The `list` reader returns lazy ghosts, so a benchmark loop that +> never reads a property would measure it deferring all the hydration work the +> other mappers do up front. Every subject in `CollectionBench` therefore reads +> `$person->address->city` to force full realization, so timings are comparable. + +> The `STREAM` option (`AutoMapper\MapperContext::STREAM => true`) also makes the +> collection single-pass (it cannot be re-iterated). Without it, the reader buffers +> mapped instances so the collection is countable and re-iterable, at the cost of +> holding them all in memory. + +### Collection — write, a list of `Person` (`src/WriteCollectionBench.php`) + +The write-side counterpart of the read collection: serialize a list of `Person` to +JSON, comparing the two JSON stream **writer** implementations. AutoMapper's writer +now handles a top-level list of objects (it maps each element through the AutoMapper +pipeline and streams the JSON array; with `STREAM=true` it never buffers the mapped +elements). Peak memory (`mem_peak`) and time: + +| Writer / consumption | 1 000 | 20 000 | time (20k) | +|----------------------|------:|-------:|-----------:| +| AutoMapper JSON streamer — `__toString()` (buffered) | 15 MB | 180 MB | 0.63 s | +| **AutoMapper JSON streamer — `STREAM=true` (streamed)** | 8.3 MB | 37 MB | 0.49 s | +| Symfony JSON streamer — `__toString()` | 8.6 MB | 45 MB | 0.10 s | +| **Symfony JSON streamer — `getIterator()` (streamed)** | **8.3 MB** | **36 MB** | **0.07 s** | + +Streamed, AutoMapper reaches the **same flat memory as Symfony** (~37 MB at 20k — +essentially just the source list) but is **~6× slower** (0.49 s vs 0.07 s), the same +per-element gap as the wide-object case. Buffered `__toString()` is worse still — +~180 MB, because it resolves the whole list into an array-of-arrays before encoding, +whereas Symfony encodes straight from the objects (~45 MB). So AutoMapper's list +writer is worth it only when you need AutoMapper's mapping on the way out *and* want +bounded memory (use `STREAM`); for a plain list, Symfony's writer is far faster. + +### Collection — write, one object with a big nested collection (`src/WriteWideObjectBench.php`) + +This is the only shape that exercises the **AutoMapper** JSON stream writer's own +streaming (a single `Person` whose `addresses` is huge). The `STREAM` option applies +on the way out. Peak memory (`mem_peak`): + +| Writer / consumption | 5 000 | 50 000 | time (50k) | +|----------------------|------:|-------:|-----------:| +| AutoMapper — `__toString()` (buffered) | 18 MB | 113 MB | 0.17 s | +| AutoMapper — `__toString()`, no attr checking | 18 MB | 113 MB | 0.11 s | +| AutoMapper — `getIterator()` `STREAM=true` | 8.9 MB | 23 MB | 0.21 s | +| AutoMapper — `getIterator()` `STREAM=true`, no attr | 8.9 MB | 23 MB | 0.16 s | +| **Symfony JSON streamer — `__toString()`** | 9 MB | 27 MB | **0.047 s** | +| **Symfony JSON streamer — `getIterator()`** | **8.9 MB** | **23 MB** | **0.036 s** | + +`STREAM=true` yields the JSON chunk by chunk and never buffers the mapped +collection, so peak stays low (the residual ~23 MB at 50k is the source object's own +materialized `addresses` array). It is single-pass. Disabling attribute checking +gives a **real ~30 % write-side speed-up** here (unlike the read side), because +encoding is pure AutoMapper — map to a lazy array, then `json_encode`/chunk — with no +Symfony JSON tokenizer in the path to dominate the time. + +#### Why Symfony's writer is so much faster at collection scale + +For a *single* small object the two writers look close (single-object serialize: +AutoMapper JSON writer ~14 µs vs Symfony ~9 µs), so the collection gap seems +surprising. It comes down to **per-element cost**, which a collection multiplies by +`N`. Measured marginal cost per extra nested element: + +| | fixed (0 elements) | per element | +|---|------:|------:| +| AutoMapper writer (`STREAM`) | ~15 µs | **~4.0 µs** | +| Symfony writer (`getIterator`) | ~7 µs | **~0.8 µs** | + +So AutoMapper pays ~5× more *per element*. At single-object scale that gap is a fixed +~8 µs that's easy to miss; at 50 000 elements it becomes 50 000 × ~3.2 µs ≈ 160 ms — +the whole difference. The reason: for each element AutoMapper builds a `LazyMap`, +runs the per-item mapping closure, `iterator_to_array()`s it, then `json_encode`s +each scalar field and yields it through nested generators. Symfony's compiled, +type-specialized writer inlines the field encoding straight from the object with none +of that per-element machinery. Reach for the AutoMapper JSON writer when you need its +mapping features (renames, transformers, `#[MapTo]`, …) on the way out; for a plain +object graph, Symfony's writer is the faster tool. diff --git a/bench/bin/verify.php b/bench/bin/verify.php new file mode 100644 index 00000000..837be6d6 --- /dev/null +++ b/bench/bin/verify.php @@ -0,0 +1,56 @@ + Verifier::denormalizeResults(...), + 'normalize' => Verifier::normalizeResults(...), + 'deserialize' => Verifier::deserializeResults(...), + 'serialize' => Verifier::serializeResults(...), + 'object-to-object' => Verifier::objectToObjectResults(...), + 'collection' => Verifier::collectionResults(...), +]; + +$exit = 0; + +foreach ($groups as $group => $build) { + $results = $build(); + $reference = null; + $referenceName = null; + + echo "\n== {$group} ==\n"; + + foreach ($results as $name => $value) { + if (null === $reference) { + $reference = $value; + $referenceName = $name; + echo \sprintf(" ref %s\n", $name); + + continue; + } + + if ($value === $reference) { + echo \sprintf(" ok %s\n", $name); + } else { + $exit = 1; + echo \sprintf(" DIFF %s (does not match %s)\n", $name, $referenceName); + echo \sprintf(" expected: %s\n", json_encode($reference)); + echo \sprintf(" actual: %s\n", json_encode($value)); + } + } +} + +echo "\n" . (0 === $exit ? "All groups consistent.\n" : "Inconsistencies found.\n"); + +exit($exit); diff --git a/bench/bootstrap.php b/bench/bootstrap.php new file mode 100644 index 00000000..79c43b5f --- /dev/null +++ b/bench/bootstrap.php @@ -0,0 +1,5 @@ +=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" }, - "time": "2023-02-02T22:02:53+00:00" + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" }, { "name": "doctrine/lexer", @@ -160,88 +209,190 @@ "time": "2024-02-05T11:56:58+00:00" }, { - "name": "phpbench/container", - "version": "2.2.2", + "name": "jolicode/automapper", + "version": "dev-feat/json-streamer", + "dist": { + "type": "path", + "url": "..", + "reference": "de70615488cd16975e80d4b6e0862473af1abee7" + }, + "require": { + "composer-runtime-api": "^2.1 || ^3.0", + "nikic/php-parser": "^5.6", + "php": "^8.4", + "phpdocumentor/type-resolver": "^1.12 || ^2.0", + "phpstan/phpdoc-parser": "^2.3", + "symfony/dependency-injection": "^7.4 || ^8.0", + "symfony/deprecation-contracts": "^3.6", + "symfony/event-dispatcher": "^7.4 || ^8.0", + "symfony/expression-language": "^7.4 || ^8.0", + "symfony/lock": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" + }, + "conflict": { + "api-platform/core": "<3", + "symfony/framework-bundle": "<6.4", + "symfony/serializer": "<6.4" + }, + "require-dev": { + "api-platform/core": "^4.2", + "doctrine/collections": "^2.4", + "doctrine/doctrine-bundle": "^3.0", + "doctrine/inflector": "^2.1", + "doctrine/orm": "^3.5", + "matthiasnoback/symfony-dependency-injection-test": "^6.1", + "moneyphp/money": "^3.3.2", + "phpunit/phpunit": "^13.0", + "symfony/browser-kit": "^7.4 || ^8.0", + "symfony/console": "^7.4 || ^8.0", + "symfony/filesystem": "^7.4 || ^8.0", + "symfony/finder": "^7.4 || ^8.0", + "symfony/framework-bundle": "^7.4 || ^8.0", + "symfony/http-client": "^7.4 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/json-streamer": "^7.4 || ^8.0", + "symfony/object-mapper": "^7.4 || ^8.0", + "symfony/phpunit-bridge": "^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/stopwatch": "^7.4 || ^8.0", + "symfony/twig-bundle": "^7.4 || ^8.0", + "symfony/uid": "^7.4 || ^8.0", + "symfony/var-dumper": "^7.4 || ^8.0", + "symfony/web-profiler-bundle": "^7.4 || ^8.0", + "symfony/yaml": "^7.4 || ^8.0", + "willdurand/negotiation": "^3.1" + }, + "suggest": { + "symfony/serializer": "Allow to use symfony serializer attributes in mapping" + }, + "type": "library", + "autoload": { + "psr-4": { + "AutoMapper\\": "src/" + }, + "files": [ + "src/php-parser.php" + ] + }, + "autoload-dev": { + "psr-4": { + "AutoMapper\\Tests\\": "tests/" + }, + "classmap": [ + "tests/Bundle/Resources/App/AppKernel.php" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joel Wurtz", + "email": "jwurtz@jolicode.com" + }, + { + "name": "Baptiste Leduc", + "email": "baptiste.leduc@gmail.com" + } + ], + "description": "JoliCode AutoMapper", + "homepage": "https://github.com/jolicode/automapper", + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", "source": { "type": "git", - "url": "https://github.com/phpbench/container.git", - "reference": "a59b929e00b87b532ca6d0edd8eca0967655af33" + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpbench/container/zipball/a59b929e00b87b532ca6d0edd8eca0967655af33", - "reference": "a59b929e00b87b532ca6d0edd8eca0967655af33", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "psr/container": "^1.0|^2.0", - "symfony/options-resolver": "^4.2 || ^5.0 || ^6.0 || ^7.0" + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^2.16", - "phpstan/phpstan": "^0.12.52", - "phpunit/phpunit": "^8" + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" }, + "bin": [ + "bin/php-parse" + ], "type": "library", "extra": { "branch-alias": { - "dev-master": "2.x-dev" + "dev-master": "5.x-dev" } }, "autoload": { "psr-4": { - "PhpBench\\DependencyInjection\\": "lib/" + "PhpParser\\": "lib/PhpParser" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Daniel Leech", - "email": "daniel@dantleech.com" + "name": "Nikita Popov" } ], - "description": "Simple, configurable, service container.", + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], "support": { - "issues": "https://github.com/phpbench/container/issues", - "source": "https://github.com/phpbench/container/tree/2.2.2" + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2023-10-30T13:38:26+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { - "name": "phpbench/dom", - "version": "0.3.3", + "name": "phpbench/container", + "version": "2.2.3", "source": { "type": "git", - "url": "https://github.com/phpbench/dom.git", - "reference": "786a96db538d0def931f5b19225233ec42ec7a72" + "url": "https://github.com/phpbench/container.git", + "reference": "0c7b2d36c1ea53fe27302fb8873ded7172047196" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpbench/dom/zipball/786a96db538d0def931f5b19225233ec42ec7a72", - "reference": "786a96db538d0def931f5b19225233ec42ec7a72", + "url": "https://api.github.com/repos/phpbench/container/zipball/0c7b2d36c1ea53fe27302fb8873ded7172047196", + "reference": "0c7b2d36c1ea53fe27302fb8873ded7172047196", "shasum": "" }, "require": { - "ext-dom": "*", - "php": "^7.3||^8.0" + "psr/container": "^1.0|^2.0", + "symfony/options-resolver": "^4.2 || ^5.0 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.14", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8.0||^9.0" + "php-cs-fixer/shim": "^3.89", + "phpstan/phpstan": "^0.12.52", + "phpunit/phpunit": "^8" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "2.x-dev" } }, "autoload": { "psr-4": { - "PhpBench\\Dom\\": "lib/" + "PhpBench\\DependencyInjection\\": "lib/" } }, "notification-url": "https://packagist.org/downloads/", @@ -254,25 +405,25 @@ "email": "daniel@dantleech.com" } ], - "description": "DOM wrapper to simplify working with the PHP DOM implementation", + "description": "Simple, configurable, service container.", "support": { - "issues": "https://github.com/phpbench/dom/issues", - "source": "https://github.com/phpbench/dom/tree/0.3.3" + "issues": "https://github.com/phpbench/container/issues", + "source": "https://github.com/phpbench/container/tree/2.2.3" }, - "time": "2023-03-06T23:46:57+00:00" + "time": "2025-11-06T09:05:13+00:00" }, { "name": "phpbench/phpbench", - "version": "1.2.15", + "version": "1.7.0", "source": { "type": "git", "url": "https://github.com/phpbench/phpbench.git", - "reference": "f7000319695cfad04a57fc64bf7ef7abdf4c437c" + "reference": "3d13c0d5dcf8730a67b70fa7fb03b4556b5cc0fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpbench/phpbench/zipball/f7000319695cfad04a57fc64bf7ef7abdf4c437c", - "reference": "f7000319695cfad04a57fc64bf7ef7abdf4c437c", + "url": "https://api.github.com/repos/phpbench/phpbench/zipball/3d13c0d5dcf8730a67b70fa7fb03b4556b5cc0fe", + "reference": "3d13c0d5dcf8730a67b70fa7fb03b4556b5cc0fe", "shasum": "" }, "require": { @@ -283,30 +434,31 @@ "ext-reflection": "*", "ext-spl": "*", "ext-tokenizer": "*", - "php": "^8.1", - "phpbench/container": "^2.1", - "phpbench/dom": "~0.3.3", + "php": "^8.2", + "phpbench/container": "^2.2", "psr/log": "^1.1 || ^2.0 || ^3.0", "seld/jsonlint": "^1.1", - "symfony/console": "^4.2 || ^5.0 || ^6.0 || ^7.0", - "symfony/filesystem": "^4.2 || ^5.0 || ^6.0 || ^7.0", - "symfony/finder": "^4.2 || ^5.0 || ^6.0 || ^7.0", - "symfony/options-resolver": "^4.2 || ^5.0 || ^6.0 || ^7.0", - "symfony/process": "^4.2 || ^5.0 || ^6.0 || ^7.0", + "symfony/console": "^6.1 || ^7.0 || ^8.0", + "symfony/filesystem": "^6.1 || ^7.0 || ^8.0", + "symfony/finder": "^6.1 || ^7.0 || ^8.0", + "symfony/options-resolver": "^6.1 || ^7.0 || ^8.0", + "symfony/process": "^6.1 || ^7.0 || ^8.0", "webmozart/glob": "^4.6" }, "require-dev": { "dantleech/invoke": "^2.0", - "friendsofphp/php-cs-fixer": "^3.0", - "jangregor/phpstan-prophecy": "^1.0", - "phpspec/prophecy": "dev-master", + "ergebnis/composer-normalize": "^2.39", + "jangregor/phpstan-prophecy": "^2.0", + "php-cs-fixer/shim": "^3.9", + "phpspec/prophecy": "^1.22", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^10.0", - "rector/rector": "^0.18.10", - "symfony/error-handler": "^5.2 || ^6.0 || ^7.0", - "symfony/var-dumper": "^4.0 || ^5.0 || ^6.0 || ^7.0" + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^11.5", + "rector/rector": "^2.2", + "sebastian/exporter": "^6.3.2", + "symfony/error-handler": "^6.1 || ^7.0 || ^8.0", + "symfony/var-dumper": "^6.1 || ^7.0 || ^8.0" }, "suggest": { "ext-xdebug": "For Xdebug profiling extension." @@ -349,7 +501,7 @@ ], "support": { "issues": "https://github.com/phpbench/phpbench/issues", - "source": "https://github.com/phpbench/phpbench/tree/1.2.15" + "source": "https://github.com/phpbench/phpbench/tree/1.7.0" }, "funding": [ { @@ -357,7 +509,165 @@ "type": "github" } ], - "time": "2023-11-29T12:21:11+00:00" + "time": "2026-06-08T19:09:20+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + }, + "time": "2026-07-08T07:01:06+00:00" }, { "name": "psr/cache", @@ -461,18 +771,68 @@ }, "time": "2021-11-05T16:47:00+00:00" }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, { "name": "psr/log", - "version": "3.0.0", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001" + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001", - "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", "shasum": "" }, "require": { @@ -507,29 +867,29 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.0" + "source": "https://github.com/php-fig/log/tree/3.0.2" }, - "time": "2021-07-14T16:46:02+00:00" + "time": "2024-09-11T13:17:53+00:00" }, { "name": "seld/jsonlint", - "version": "1.10.2", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/Seldaek/jsonlint.git", - "reference": "9bb7db07b5d66d90f6ebf542f09fc67d800e5259" + "reference": "9a90eb5d32d5a500296bf43f946d60246444d5f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/9bb7db07b5d66d90f6ebf542f09fc67d800e5259", - "reference": "9bb7db07b5d66d90f6ebf542f09fc67d800e5259", + "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/9a90eb5d32d5a500296bf43f946d60246444d5f7", + "reference": "9a90eb5d32d5a500296bf43f946d60246444d5f7", "shasum": "" }, "require": { "php": "^5.3 || ^7.0 || ^8.0" }, "require-dev": { - "phpstan/phpstan": "^1.5", + "phpstan/phpstan": "^1.11", "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^8.5.13" }, "bin": [ @@ -561,7 +921,7 @@ ], "support": { "issues": "https://github.com/Seldaek/jsonlint/issues", - "source": "https://github.com/Seldaek/jsonlint/tree/1.10.2" + "source": "https://github.com/Seldaek/jsonlint/tree/1.12.1" }, "funding": [ { @@ -573,56 +933,60 @@ "type": "tidelift" } ], - "time": "2024-02-07T12:57:50+00:00" + "time": "2026-06-12T11:32:29+00:00" }, { - "name": "symfony/console", - "version": "v7.0.4", + "name": "symfony/cache", + "version": "v8.1.1", "source": { "type": "git", - "url": "https://github.com/symfony/console.git", - "reference": "6b099f3306f7c9c2d2786ed736d0026b2903205f" + "url": "https://github.com/symfony/cache.git", + "reference": "c14decc1b0755b1e8ab6babeef56e1880348e817" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/6b099f3306f7c9c2d2786ed736d0026b2903205f", - "reference": "6b099f3306f7c9c2d2786ed736d0026b2903205f", + "url": "https://api.github.com/repos/symfony/cache/zipball/c14decc1b0755b1e8ab6babeef56e1880348e817", + "reference": "c14decc1b0755b1e8ab6babeef56e1880348e817", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/polyfill-mbstring": "~1.0", + "php": ">=8.4.1", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^6.4|^7.0" + "symfony/var-exporter": "^8.1" }, "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/event-dispatcher": "<6.4", - "symfony/lock": "<6.4", - "symfony/process": "<6.4" + "ext-redis": "<6.1", + "ext-relay": "<0.12.1" }, "provide": { - "psr/log-implementation": "1.0|2.0|3.0" + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" }, "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0" + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Console\\": "" + "Symfony\\Component\\Cache\\": "" }, + "classmap": [ + "Traits/ValueWrapper.php" + ], "exclude-from-classmap": [ "/Tests/" ] @@ -633,24 +997,22 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Eases the creation of beautiful and testable command line interfaces", + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", "homepage": "https://symfony.com", "keywords": [ - "cli", - "command-line", - "console", - "terminal" + "caching", + "psr6" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.0.4" + "source": "https://github.com/symfony/cache/tree/v8.1.1" }, "funding": [ { @@ -661,44 +1023,49 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-02-22T20:27:20+00:00" + "time": "2026-06-17T15:04:37+00:00" }, { - "name": "symfony/deprecation-contracts", - "version": "v3.4.0", + "name": "symfony/cache-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf" + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/7c3aff79d10325257a001fcf92d991f24fc967cf", - "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.1", + "psr/cache": "^3.0" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.4-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { - "files": [ - "function.php" - ] + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -714,12 +1081,20 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "A generic function and convention to trigger deprecation notices", + "description": "Generic abstractions related to caching", "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.4.0" - }, - "funding": [ + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" + }, + "funding": [ { "url": "https://symfony.com/sponsor", "type": "custom" @@ -728,36 +1103,311 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-05-23T14:45:45+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { - "name": "symfony/filesystem", - "version": "v7.0.3", + "name": "symfony/console", + "version": "v8.1.1", "source": { "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "2890e3a825bc0c0558526c04499c13f83e1b6b12" + "url": "https://github.com/symfony/console.git", + "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/2890e3a825bc0c0558526c04499c13f83e1b6b12", - "reference": "2890e3a825bc0c0558526c04499c13f83e1b6b12", + "url": "https://api.github.com/repos/symfony/console/zipball/b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", + "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4.6|^8.0.6" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Filesystem\\": "" + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-16T12:55:20+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "99ced9d6305c43b25a7d48fe6a7d169df275a597" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/99ced9d6305c43b25a7d48fe6a7d169df275a597", + "reference": "99ced9d6305c43b25a7d48fe6a7d169df275a597", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^8.1" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v8.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-27T06:18:14+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -769,18 +1419,1197 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-09T12:28:30+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/expression-language", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/expression-language.git", + "reference": "13b74638d9c7c6854fcbb7e89a42a90fdca51f57" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/13b74638d9c7c6854fcbb7e89a42a90fdca51f57", + "reference": "13b74638d9c7c6854fcbb7e89a42a90fdca51f57", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/cache": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ExpressionLanguage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an engine that can compile and evaluate expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/expression-language/tree/v8.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-09T10:54:51+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/e2989e762c70f9490fa3a00a0ac0fae5aa97a531", + "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-27T09:05:56+00:00" + }, + { + "name": "symfony/json-streamer", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/json-streamer.git", + "reference": "a9d9128a066ff0c7e3e29f0948eaebeacb8dc99e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/json-streamer/zipball/a9d9128a066ff0c7e3e29f0948eaebeacb8dc99e", + "reference": "a9d9128a066ff0c7e3e29f0948eaebeacb8dc99e", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.4|^8.0", + "symfony/type-info": "^7.4.1|^8.0.1", + "symfony/var-exporter": "^7.4|^8.0" + }, + "require-dev": { + "nst/json-test-suite": "*", + "phpstan/phpdoc-parser": "^1.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\JsonStreamer\\": "" + }, + "exclude-from-classmap": [ + "Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to read/write data structures from/into JSON streams.", + "homepage": "https://symfony.com", + "keywords": [ + "json", + "stream" + ], + "support": { + "source": "https://github.com/symfony/json-streamer/tree/v8.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-06T11:11:44+00:00" + }, + { + "name": "symfony/lock", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/lock.git", + "reference": "60d3c9f8c537be45d48564f1cfd0c223f1252a50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/lock/zipball/60d3c9f8c537be45d48564f1cfd0c223f1252a50", + "reference": "60d3c9f8c537be45d48564f1cfd0c223f1252a50", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/log": "^1|^2|^3" + }, + "conflict": { + "doctrine/dbal": "<4.3" + }, + "require-dev": { + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/serializer": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Lock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jérémy Derussé", + "email": "jeremy@derusse.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Creates and manages locks, a mechanism to provide exclusive access to a shared resource", + "homepage": "https://symfony.com", + "keywords": [ + "cas", + "flock", + "locking", + "mutex", + "redlock", + "semaphore" + ], + "support": { + "source": "https://github.com/symfony/lock/tree/v8.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-12T08:43:41+00:00" + }, + { + "name": "symfony/object-mapper", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/object-mapper.git", + "reference": "f2d118d3ced275117b83acc5b57f6611ab38cd14" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/object-mapper/zipball/f2d118d3ced275117b83acc5b57f6611ab38cd14", + "reference": "f2d118d3ced275117b83acc5b57f6611ab38cd14", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/container": "^2.0" + }, + "conflict": { + "symfony/property-access": "<7.2" + }, + "require-dev": { + "symfony/property-access": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ObjectMapper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to map an object to another object", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/object-mapper/tree/v8.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-17T10:12:54+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-deepclone", + "version": "v1.40.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-deepclone.git", + "reference": "dca4ccba5f360070b574414dce4c1e7a559844fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/dca4ccba5f360070b574414dce4c1e7a559844fa", + "reference": "dca4ccba5f360070b574414dce4c1e7a559844fa", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "provide": { + "ext-deepclone": "*" + }, + "suggest": { + "ext-deepclone": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\DeepClone\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the deepclone extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "deepclone", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.40.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-12T07:27:17+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T05:58:03+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T02:25:22+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides basic utilities for the filesystem", + "description": "Symfony polyfill for uuid functions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.0.3" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -791,37 +2620,38 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-01-23T15:02:46+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "symfony/finder", - "version": "v7.0.0", + "name": "symfony/process", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "6e5688d69f7cfc4ed4a511e96007e06c2d34ce56" + "url": "https://github.com/symfony/process.git", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/6e5688d69f7cfc4ed4a511e96007e06c2d34ce56", - "reference": "6e5688d69f7cfc4ed4a511e96007e06c2d34ce56", + "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", "shasum": "" }, "require": { - "php": ">=8.2" - }, - "require-dev": { - "symfony/filesystem": "^6.4|^7.0" + "php": ">=8.4.1" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Finder\\": "" + "Symfony\\Component\\Process\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -841,10 +2671,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Finds files and directories via an intuitive fluent interface", + "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.0.0" + "source": "https://github.com/symfony/process/tree/v8.1.0" }, "funding": [ { @@ -855,35 +2685,43 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-10-31T17:59:56+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/options-resolver", - "version": "v7.0.0", + "name": "symfony/property-access", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/options-resolver.git", - "reference": "700ff4096e346f54cb628ea650767c8130f1001f" + "url": "https://github.com/symfony/property-access.git", + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/700ff4096e346f54cb628ea650767c8130f1001f", - "reference": "700ff4096e346f54cb628ea650767c8130f1001f", + "url": "https://api.github.com/repos/symfony/property-access/zipball/9261ef060f26cc7b728f67f141ba19b98a6209a9", + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3" + "php": ">=8.4.1", + "symfony/property-info": "^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\OptionsResolver\\": "" + "Symfony\\Component\\PropertyAccess\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -903,15 +2741,21 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides an improved replacement for the array_replace PHP function", + "description": "Provides functions to read and write from/to an object or array using a simple string notation", "homepage": "https://symfony.com", "keywords": [ - "config", - "configuration", - "options" + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.0.0" + "source": "https://github.com/symfony/property-access/tree/v8.1.0" }, "funding": [ { @@ -922,50 +2766,55 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-08-08T10:20:21+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/polyfill-ctype", - "version": "v1.29.0", + "name": "symfony/property-info", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4" + "url": "https://github.com/symfony/property-info.git", + "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ef4d7e442ca910c4764bce785146269b30cb5fc4", - "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4", + "url": "https://api.github.com/repos/symfony/property-info/zipball/4721e8c56d0cd2378e0ef9a9899f810008b859f7", + "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.4.1", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" }, - "provide": { - "ext-ctype": "*" + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" }, - "suggest": { - "ext-ctype": "For best performance" + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" }, "type": "library", - "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - } + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -973,24 +2822,26 @@ ], "authors": [ { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for ctype functions", + "description": "Extracts information about PHP class' properties using metadata of popular sources", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "ctype", - "polyfill", - "portable" + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.29.0" + "source": "https://github.com/symfony/property-info/tree/v8.1.0" }, "funding": [ { @@ -1001,47 +2852,76 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/polyfill-intl-grapheme", - "version": "v1.29.0", + "name": "symfony/serializer", + "version": "v8.1.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "32a9da87d7b3245e09ac426c83d334ae9f06f80f" + "url": "https://github.com/symfony/serializer.git", + "reference": "f911b744bc24658f435ea30439cfe536f0173a3a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/32a9da87d7b3245e09ac426c83d334ae9f06f80f", - "reference": "32a9da87d7b3245e09ac426c83d334ae9f06f80f", + "url": "https://api.github.com/repos/symfony/serializer/zipball/f911b744bc24658f435ea30439cfe536f0173a3a", + "reference": "f911b744bc24658f435ea30439cfe536f0173a3a", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" }, - "suggest": { - "ext-intl": "For best performance" + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/property-access": "<8.1", + "symfony/property-info": "<7.4", + "symfony/type-info": "<7.4" }, - "type": "library", - "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/property-access": "^8.1", + "symfony/property-info": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, + "type": "library", "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" - } + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1049,26 +2929,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's grapheme_* functions", + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "grapheme", - "intl", - "polyfill", - "portable", - "shim" - ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.29.0" + "source": "https://github.com/symfony/serializer/tree/v8.1.1" }, "funding": [ { @@ -1079,49 +2951,55 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2026-06-27T09:05:56+00:00" }, { - "name": "symfony/polyfill-intl-normalizer", - "version": "v1.29.0", + "name": "symfony/service-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "bc45c394692b948b4d383a08d7753968bed9a83d" + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/bc45c394692b948b4d383a08d7753968bed9a83d", - "reference": "bc45c394692b948b4d383a08d7753968bed9a83d", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" }, - "suggest": { - "ext-intl": "For best performance" + "conflict": { + "ext-psr": "<1.1|>=2" }, "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + "Symfony\\Contracts\\Service\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Test/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1138,18 +3016,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's Normalizer class and related functions", + "description": "Generic abstractions related to writing services", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "intl", - "normalizer", - "polyfill", - "portable", - "shim" + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.29.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -1160,50 +3038,59 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { - "name": "symfony/polyfill-mbstring", - "version": "v1.29.0", + "name": "symfony/string", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec" + "url": "https://github.com/symfony/string.git", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9773676c8a1bb1f8d4340a62efe641cf76eda7ec", - "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" }, - "provide": { - "ext-mbstring": "*" + "conflict": { + "symfony/translation-contracts": "<2.5" }, - "suggest": { - "ext-mbstring": "For best performance" + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", - "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, "autoload": { "files": [ - "bootstrap.php" + "Resources/functions.php" ], "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1219,17 +3106,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for the Mbstring extension", + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.29.0" + "source": "https://github.com/symfony/string/tree/v8.1.0" }, "funding": [ { @@ -1240,34 +3128,45 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/process", - "version": "v7.0.4", + "name": "symfony/type-info", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "0e7727191c3b71ebec6d529fa0e50a01ca5679e9" + "url": "https://github.com/symfony/type-info.git", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/0e7727191c3b71ebec6d529fa0e50a01ca5679e9", - "reference": "0e7727191c3b71ebec6d529fa0e50a01ca5679e9", + "url": "https://api.github.com/repos/symfony/type-info/zipball/9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Process\\": "" + "Symfony\\Component\\TypeInfo\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -1279,18 +3178,28 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Executes commands in sub-processes", + "description": "Extracts PHP types information.", "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], "support": { - "source": "https://github.com/symfony/process/tree/v7.0.4" + "source": "https://github.com/symfony/type-info/tree/v8.1.0" }, "funding": [ { @@ -1301,50 +3210,45 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-02-22T20:27:20+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/service-contracts", - "version": "v3.4.1", + "name": "symfony/uid", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "fe07cbc8d837f60caf7018068e350cc5163681a0" + "url": "https://github.com/symfony/uid.git", + "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/fe07cbc8d837f60caf7018068e350cc5163681a0", - "reference": "fe07cbc8d837f60caf7018068e350cc5163681a0", + "url": "https://api.github.com/repos/symfony/uid/zipball/7393f157a55f7e70a4de0334435c55a5a8fe749a", + "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/container": "^1.1|^2.0" + "php": ">=8.4.1", + "symfony/polyfill-uuid": "^1.15" }, - "conflict": { - "ext-psr": "<1.1|>=2" + "require-dev": { + "symfony/console": "^7.4|^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.4-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } - }, "autoload": { "psr-4": { - "Symfony\\Contracts\\Service\\": "" + "Symfony\\Component\\Uid\\": "" }, "exclude-from-classmap": [ - "/Test/" + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1352,6 +3256,10 @@ "MIT" ], "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, { "name": "Nicolas Grekas", "email": "p@tchwork.com" @@ -1361,18 +3269,15 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to writing services", + "description": "Provides an object-oriented API to generate and represent UIDs", "homepage": "https://symfony.com", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "UID", + "ulid", + "uuid" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.4.1" + "source": "https://github.com/symfony/uid/tree/v8.1.0" }, "funding": [ { @@ -1383,51 +3288,45 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-12-26T14:02:43+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/string", - "version": "v7.0.4", + "name": "symfony/var-exporter", + "version": "v8.1.1", "source": { "type": "git", - "url": "https://github.com/symfony/string.git", - "reference": "f5832521b998b0bec40bee688ad5de98d4cf111b" + "url": "https://github.com/symfony/var-exporter.git", + "reference": "75b74315b4e4be40e5534cf9c5cc30dd0907ed71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f5832521b998b0bec40bee688ad5de98d4cf111b", - "reference": "f5832521b998b0bec40bee688ad5de98d4cf111b", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/75b74315b4e4be40e5534cf9c5cc30dd0907ed71", + "reference": "75b74315b4e4be40e5534cf9c5cc30dd0907ed71", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", - "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0" - }, - "conflict": { - "symfony/translation-contracts": "<2.5" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-deepclone": "^1.40" }, "require-dev": { - "symfony/error-handler": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", - "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { - "files": [ - "Resources/functions.php" - ], "psr-4": { - "Symfony\\Component\\String\\": "" + "Symfony\\Component\\VarExporter\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -1447,18 +3346,21 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "description": "Provides tools to export, instantiate, hydrate, clone and lazy-load PHP objects", "homepage": "https://symfony.com", "keywords": [ - "grapheme", - "i18n", - "string", - "unicode", - "utf-8", - "utf8" + "clone", + "construct", + "deep-clone", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.0.4" + "source": "https://github.com/symfony/var-exporter/tree/v8.1.1" }, "funding": [ { @@ -1469,12 +3371,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-02-01T13:17:36+00:00" + "time": "2026-06-27T09:05:56+00:00" }, { "name": "webmozart/glob", @@ -1528,11 +3434,15 @@ ], "packages-dev": [], "aliases": [], - "minimum-stability": "stable", - "stability-flags": [], - "prefer-stable": false, + "minimum-stability": "dev", + "stability-flags": { + "jolicode/automapper": 20 + }, + "prefer-stable": true, "prefer-lowest": false, - "platform": [], - "platform-dev": [], - "plugin-api-version": "2.6.0" + "platform": { + "php": "^8.4" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" } diff --git a/bench/phpbench.json b/bench/phpbench.json new file mode 100644 index 00000000..506474dc --- /dev/null +++ b/bench/phpbench.json @@ -0,0 +1,18 @@ +{ + "$schema": "./vendor/phpbench/phpbench/phpbench.schema.json", + "runner.bootstrap": "bootstrap.php", + "runner.path": "src", + "runner.file_pattern": "*Bench.php", + "runner.iterations": 5, + "runner.revs": 100, + "runner.php_config": { + "memory_limit": "2048M" + }, + "report.generators": { + "memory": { + "generator": "expression", + "cols": ["benchmark", "subject", "mem_peak", "mode", "rstdev"], + "break": ["benchmark"] + } + } +} diff --git a/bench/src/CollectionBench.php b/bench/src/CollectionBench.php new file mode 100644 index 00000000..0e0bed78 --- /dev/null +++ b/bench/src/CollectionBench.php @@ -0,0 +1,185 @@ +address->city`) so the work is comparable across libraries. + * This matters for Symfony JsonStreamer in particular: `read()` on a `Type::list` + * of objects returns lazy-ghost instances whose hydration is deferred until a + * property is touched, so a loop that never reads a property would measure it + * doing far less work than the mappers that build real objects up front. + * (`benchJsonDecode` is the exception: it is the no-hydration array floor.) + * + * Run with: + * php vendor/bin/phpbench run src/CollectionBench.php --report=aggregate + * and add `mem_peak` to the report to compare memory. + */ +#[Revs(1)] +#[Iterations(3)] +#[BeforeMethods('setUp')] +#[Groups(['collection'])] +class CollectionBench +{ + private string $file; + + private Type $listType; + + private Type $iterableType; + + /** + * @return array + */ + public function provideSizes(): array + { + return [ + 'small (1k)' => ['count' => 1_000], + 'large (20k)' => ['count' => 20_000], + ]; + } + + /** + * @param array{count: int} $params + */ + public function setUp(array $params): void + { + $this->file = PayloadFactory::writePersonListJsonFile($params['count']); + $this->listType = Type::list(Type::object(Person::class)); + // An *iterable* type with an int key is the shape Symfony JSON streamer + // decodes lazily from a JSON array: read() returns a Generator that yields + // one hydrated object at a time instead of materializing the whole list. + $this->iterableType = Type::iterable(Type::object(Person::class), Type::int()); + + // Warm generation with a tiny payload so it is out of the measured run. + foreach (MapperFactory::autoMapperJsonStreamReader()->read(PayloadFactory::stream(PayloadFactory::personListJson(1)), $this->listType) as $ignored) { + } + foreach (MapperFactory::autoMapperNoAttributeJsonStreamReader()->read(PayloadFactory::stream(PayloadFactory::personListJson(1)), $this->listType) as $ignored) { + } + MapperFactory::autoMapper()->mapCollection([PayloadFactory::personArray(0)], Person::class); + + // Fail fast if any approach in this group produces a different result. + Verifier::assertCollection(); + } + + /** + * @return resource + */ + private function open() + { + return fopen($this->file, 'r'); + } + + /** + * Fully realize every mapped object by reading a nested field, so each + * strategy is measured doing the same work (Symfony's lazy ghosts included). + * + * @param iterable $people + */ + private function consume(iterable $people): int + { + $sink = 0; + foreach ($people as $person) { + $sink += \strlen($person->address->city); + } + + return $sink; + } + + #[ParamProviders('provideSizes')] + #[Groups(['baseline'])] + public function benchJsonDecode(): void + { + $data = json_decode((string) file_get_contents($this->file), true, 512, JSON_THROW_ON_ERROR); + $sink = \count($data); + } + + #[ParamProviders('provideSizes')] + public function benchAutoMapperMapCollection(): void + { + $data = json_decode((string) file_get_contents($this->file), true, 512, JSON_THROW_ON_ERROR); + $sink = $this->consume(MapperFactory::autoMapper()->mapCollection($data, Person::class)); + } + + #[ParamProviders('provideSizes')] + public function benchSymfonySerializer(): void + { + $objects = MapperFactory::serializer()->deserialize( + (string) file_get_contents($this->file), + Person::class . '[]', + 'json', + ); + $sink = $this->consume($objects); + } + + #[ParamProviders('provideSizes')] + public function benchSymfonyJsonStreamerIterable(): void + { + $sink = $this->consume(MapperFactory::jsonStreamReader()->read($this->open(), $this->iterableType)); + } + + /** + * Cautionary counter-example: reading the same JSON array as a `Type::list` + * materializes the whole collection (see the README), so its peak memory + * explodes on large inputs. Prefer the iterable subject above. + */ + #[ParamProviders('provideSizes')] + public function benchSymfonyJsonStreamerList(): void + { + $sink = $this->consume(MapperFactory::jsonStreamReader()->read($this->open(), $this->listType)); + } + + #[ParamProviders('provideSizes')] + public function benchAutoMapperJsonStreamerBuffered(): void + { + $sink = $this->consume(MapperFactory::autoMapperJsonStreamReader()->read($this->open(), $this->listType)); + } + + #[ParamProviders('provideSizes')] + public function benchAutoMapperJsonStreamerBufferedNoAttributeChecking(): void + { + $sink = $this->consume(MapperFactory::autoMapperNoAttributeJsonStreamReader()->read($this->open(), $this->listType)); + } + + #[ParamProviders('provideSizes')] + public function benchAutoMapperJsonStreamerStream(): void + { + $collection = MapperFactory::autoMapperJsonStreamReader()->read( + $this->open(), + $this->listType, + [MapperContext::STREAM => true], + ); + $sink = $this->consume($collection); + } + + #[ParamProviders('provideSizes')] + public function benchAutoMapperJsonStreamerStreamNoAttributeChecking(): void + { + $collection = MapperFactory::autoMapperNoAttributeJsonStreamReader()->read( + $this->open(), + $this->listType, + [MapperContext::STREAM => true], + ); + $sink = $this->consume($collection); + } +} diff --git a/bench/src/DenormalizeBench.php b/bench/src/DenormalizeBench.php new file mode 100644 index 00000000..2cff6306 --- /dev/null +++ b/bench/src/DenormalizeBench.php @@ -0,0 +1,75 @@ + */ + private array $array; + + public function setUp(): void + { + $this->array = PayloadFactory::personArray(1); + + // Warm up code generation so it never lands in a measured rev. + MapperFactory::autoMapper()->map($this->array, Person::class); + MapperFactory::autoMapperEval()->map($this->array, Person::class); + MapperFactory::autoMapperNoConstructor()->map($this->array, Person::class); + MapperFactory::autoMapperNoAttributeChecking()->map($this->array, Person::class); + MapperFactory::serializer()->denormalize($this->array, Person::class); + + // Fail fast if any approach in this group produces a different result. + Verifier::assertDenormalize(); + } + + public function benchAutoMapper(): void + { + MapperFactory::autoMapper()->map($this->array, Person::class); + } + + public function benchAutoMapperEval(): void + { + MapperFactory::autoMapperEval()->map($this->array, Person::class); + } + + public function benchAutoMapperNoConstructor(): void + { + MapperFactory::autoMapperNoConstructor()->map($this->array, Person::class); + } + + public function benchAutoMapperNoAttributeChecking(): void + { + MapperFactory::autoMapperNoAttributeChecking()->map($this->array, Person::class); + } + + public function benchSymfonySerializer(): void + { + MapperFactory::serializer()->denormalize($this->array, Person::class); + } +} diff --git a/bench/src/DeserializeBench.php b/bench/src/DeserializeBench.php new file mode 100644 index 00000000..1a0c174f --- /dev/null +++ b/bench/src/DeserializeBench.php @@ -0,0 +1,115 @@ +json = PayloadFactory::personJson(1); + $this->type = Type::object(Person::class); + + // Warm up code generation / lazy service wiring so it never lands in a measured rev. + MapperFactory::autoMapper()->map(json_decode($this->json, true), Person::class); + MapperFactory::autoMapperNoAttributeChecking()->map(json_decode($this->json, true), Person::class); + MapperFactory::serializer()->deserialize($this->json, Person::class, 'json'); + MapperFactory::jsonStreamReader()->read(PayloadFactory::stream($this->json), $this->type); + MapperFactory::autoMapperJsonStreamReader()->read(PayloadFactory::stream($this->json), $this->type); + MapperFactory::autoMapperNoAttributeJsonStreamReader()->read(PayloadFactory::stream($this->json), $this->type); + + // Fail fast if any approach in this group produces a different result. + Verifier::assertDeserialize(); + } + + /** + * Read the whole graph so any deferred hydration (Symfony's lazy ghosts) is + * forced, making every object-producing subject do the same work. + */ + private function realize(Person $person): int + { + $sink = $person->id + \strlen($person->firstName) + \strlen($person->address->city) + \count($person->tags); + foreach ($person->addresses as $address) { + $sink += \strlen($address->city); + } + + return $sink; + } + + /** + * Fair floor: decode then hand-written hydration into a `Person` graph — the + * same output the mappers produce, done by hand. + */ + #[Groups(['baseline'])] + public function benchManual(): void + { + $data = json_decode($this->json, true, 512, JSON_THROW_ON_ERROR); + $this->realize(ManualMapper::hydratePerson($data)); + } + + public function benchAutoMapper(): void + { + $data = json_decode($this->json, true, 512, JSON_THROW_ON_ERROR); + $this->realize(MapperFactory::autoMapper()->map($data, Person::class)); + } + + public function benchAutoMapperNoAttributeChecking(): void + { + $data = json_decode($this->json, true, 512, JSON_THROW_ON_ERROR); + $this->realize(MapperFactory::autoMapperNoAttributeChecking()->map($data, Person::class)); + } + + public function benchSymfonySerializer(): void + { + $this->realize(MapperFactory::serializer()->deserialize($this->json, Person::class, 'json')); + } + + public function benchSymfonyJsonStreamer(): void + { + $this->realize(MapperFactory::jsonStreamReader()->read(PayloadFactory::stream($this->json), $this->type)); + } + + public function benchAutoMapperJsonStreamer(): void + { + $this->realize(MapperFactory::autoMapperJsonStreamReader()->read(PayloadFactory::stream($this->json), $this->type)); + } + + public function benchAutoMapperJsonStreamerNoAttributeChecking(): void + { + $this->realize(MapperFactory::autoMapperNoAttributeJsonStreamReader()->read(PayloadFactory::stream($this->json), $this->type)); + } +} diff --git a/bench/src/Factory/MapperFactory.php b/bench/src/Factory/MapperFactory.php new file mode 100644 index 00000000..6b9d9527 --- /dev/null +++ b/bench/src/Factory/MapperFactory.php @@ -0,0 +1,235 @@ + */ + private static array $autoMappers = []; + + private static ?Serializer $serializer = null; + private static ?JsonStreamReader $jsonStreamReader = null; + private static ?JsonStreamWriter $jsonStreamWriter = null; + private static ?AutoMapperJsonStreamReader $autoMapperJsonStreamReader = null; + private static ?AutoMapperJsonStreamWriter $autoMapperJsonStreamWriter = null; + private static ?AutoMapperJsonStreamReader $autoMapperNoAttributeJsonStreamReader = null; + private static ?AutoMapperJsonStreamWriter $autoMapperNoAttributeJsonStreamWriter = null; + private static ?SymfonyObjectMapper $symfonyObjectMapper = null; + private static ?\AutoMapper\ObjectMapper\ObjectMapper $autoMapperObjectMapper = null; + + /** + * The default AutoMapper: file cache loader, constructor strategy AUTO. + * This is what an application gets out of the box. + */ + public static function autoMapper(): AutoMapperInterface + { + return self::$autoMappers['default'] ??= AutoMapper::create( + new Configuration(classPrefix: 'BenchDefault_'), + cacheDirectory: self::cacheDir('default'), + ); + } + + /** + * AutoMapper with the EvalLoader (no on-disk cache): mappers are generated + * and eval()'d into the current process instead of written to files. + */ + public static function autoMapperEval(): AutoMapperInterface + { + return self::$autoMappers['eval'] ??= AutoMapper::create( + new Configuration(classPrefix: 'BenchEval_'), + ); + } + + /** + * AutoMapper that never uses the target constructor (writes properties directly). + */ + public static function autoMapperNoConstructor(): AutoMapperInterface + { + return self::$autoMappers['no_constructor'] ??= AutoMapper::create( + new Configuration( + classPrefix: 'BenchNoCtor_', + constructorStrategy: ConstructorStrategy::NEVER, + ), + cacheDirectory: self::cacheDir('no_constructor'), + ); + } + + /** + * AutoMapper with attribute checking disabled (skips reading Serializer/AutoMapper + * attributes when building metadata — cheaper generation, same runtime shape here). + */ + public static function autoMapperNoAttributeChecking(): AutoMapperInterface + { + return self::$autoMappers['no_attribute'] ??= AutoMapper::create( + new Configuration( + classPrefix: 'BenchNoAttr_', + attributeChecking: false, + mapPrivateProperties: false, + ), + cacheDirectory: self::cacheDir('no_attribute'), + ); + } + + /** + * A Serializer wired like Symfony FrameworkBundle's default service: the + * property-info extractor and the class-metadata factory are both wrapped in a + * cache, and the ObjectNormalizer uses a cached PropertyAccessor. Without these + * caches, every (de)normalization re-parses the PHPDoc types (via + * phpstan/phpdoc-parser), which is not what a real application pays after warmup. + */ + public static function serializer(): Serializer + { + if (null !== self::$serializer) { + return self::$serializer; + } + + $reflectionExtractor = new ReflectionExtractor(); + $phpStanExtractor = new PhpStanExtractor(); + + $propertyInfo = new PropertyInfoCacheExtractor( + new PropertyInfoExtractor( + listExtractors: [$reflectionExtractor], + typeExtractors: [$phpStanExtractor, $reflectionExtractor], + descriptionExtractors: [], + accessExtractors: [$reflectionExtractor], + initializableExtractors: [$reflectionExtractor], + ), + new ArrayAdapter(), + ); + + $classMetadataFactory = new CacheClassMetadataFactory( + new ClassMetadataFactory(new AttributeLoader()), + new ArrayAdapter(), + ); + + $propertyAccessor = PropertyAccess::createPropertyAccessorBuilder() + ->setCacheItemPool(new ArrayAdapter()) + ->getPropertyAccessor(); + + $normalizer = new ObjectNormalizer( + classMetadataFactory: $classMetadataFactory, + propertyAccessor: $propertyAccessor, + propertyTypeExtractor: $propertyInfo, + ); + + return self::$serializer = new Serializer( + [$normalizer, new ArrayDenormalizer()], + [new JsonEncoder()], + ); + } + + public static function jsonStreamReader(): JsonStreamReader + { + return self::$jsonStreamReader ??= JsonStreamReader::create( + streamReadersDir: self::tmpDir('sf-json-streamer/read'), + ); + } + + public static function jsonStreamWriter(): JsonStreamWriter + { + return self::$jsonStreamWriter ??= JsonStreamWriter::create( + streamWritersDir: self::tmpDir('sf-json-streamer/write'), + ); + } + + public static function autoMapperJsonStreamReader(): AutoMapperJsonStreamReader + { + return self::$autoMapperJsonStreamReader ??= new AutoMapperJsonStreamReader( + self::autoMapper(), + self::jsonStreamReader(), + ); + } + + public static function autoMapperJsonStreamWriter(): AutoMapperJsonStreamWriter + { + return self::$autoMapperJsonStreamWriter ??= new AutoMapperJsonStreamWriter( + self::autoMapper(), + self::jsonStreamWriter(), + ); + } + + public static function autoMapperNoAttributeJsonStreamReader(): AutoMapperJsonStreamReader + { + return self::$autoMapperNoAttributeJsonStreamReader ??= new AutoMapperJsonStreamReader( + self::autoMapperNoAttributeChecking(), + self::jsonStreamReader(), + ); + } + + public static function autoMapperNoAttributeJsonStreamWriter(): AutoMapperJsonStreamWriter + { + return self::$autoMapperNoAttributeJsonStreamWriter ??= new AutoMapperJsonStreamWriter( + self::autoMapperNoAttributeChecking(), + self::jsonStreamWriter(), + ); + } + + /** + * ObjectMapper wired like Symfony FrameworkBundle's default service: the + * reflection metadata factory (which already caches internally) plus a cached + * PropertyAccessor, as the `object_mapper` service is configured. + */ + public static function symfonyObjectMapper(): SymfonyObjectMapper + { + return self::$symfonyObjectMapper ??= new SymfonyObjectMapper( + new ReflectionObjectMapperMetadataFactory(), + PropertyAccess::createPropertyAccessorBuilder() + ->setCacheItemPool(new ArrayAdapter()) + ->getPropertyAccessor(), + ); + } + + public static function autoMapperObjectMapper(): \AutoMapper\ObjectMapper\ObjectMapper + { + return self::$autoMapperObjectMapper ??= new \AutoMapper\ObjectMapper\ObjectMapper(self::autoMapper()); + } + + private static function cacheDir(string $variant): string + { + return self::tmpDir('automapper-cache/' . $variant); + } + + private static function tmpDir(string $suffix): string + { + $dir = sys_get_temp_dir() . '/automapper-bench/' . $suffix; + + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + + return $dir; + } +} diff --git a/bench/src/Factory/PayloadFactory.php b/bench/src/Factory/PayloadFactory.php new file mode 100644 index 00000000..bff08dda --- /dev/null +++ b/bench/src/Factory/PayloadFactory.php @@ -0,0 +1,200 @@ + + */ + public static function personArray(int $seed = 0): array + { + return [ + 'id' => $seed, + 'firstName' => 'First' . $seed, + 'lastName' => 'Last' . $seed, + 'email' => 'user' . $seed . '@example.com', + 'age' => 20 + ($seed % 50), + 'active' => 0 === $seed % 2, + 'balance' => 1000.5 + $seed, + 'address' => self::addressArray($seed), + 'tags' => ['tag-a', 'tag-b', 'tag-c'], + 'addresses' => [ + self::addressArray($seed), + self::addressArray($seed + 1), + ], + ]; + } + + /** + * @return array + */ + public static function addressArray(int $seed = 0): array + { + return [ + 'street' => $seed . ' Main Street', + 'city' => 'City' . $seed, + 'zipCode' => str_pad((string) ($seed % 100000), 5, '0', STR_PAD_LEFT), + 'country' => 'Country' . ($seed % 20), + ]; + } + + /** + * A list of person arrays. + * + * @return list> + */ + public static function personArrayList(int $count): array + { + $list = []; + for ($i = 0; $i < $count; ++$i) { + $list[] = self::personArray($i); + } + + return $list; + } + + public static function person(int $seed = 0): Person + { + $person = new Person(); + $person->id = $seed; + $person->firstName = 'First' . $seed; + $person->lastName = 'Last' . $seed; + $person->email = 'user' . $seed . '@example.com'; + $person->age = 20 + ($seed % 50); + $person->active = 0 === $seed % 2; + $person->balance = 1000.5 + $seed; + $person->address = self::address($seed); + $person->tags = ['tag-a', 'tag-b', 'tag-c']; + $person->addresses = [self::address($seed), self::address($seed + 1)]; + + return $person; + } + + public static function address(int $seed = 0): Address + { + $address = new Address(); + $address->street = $seed . ' Main Street'; + $address->city = 'City' . $seed; + $address->zipCode = str_pad((string) ($seed % 100000), 5, '0', STR_PAD_LEFT); + $address->country = 'Country' . ($seed % 20); + + return $address; + } + + /** + * @return list + */ + public static function personList(int $count): array + { + $list = []; + for ($i = 0; $i < $count; ++$i) { + $list[] = self::person($i); + } + + return $list; + } + + public static function personSource(int $seed = 0): PersonSource + { + $person = new PersonSource(); + $person->id = $seed; + $person->firstName = 'First' . $seed; + $person->lastName = 'Last' . $seed; + $person->email = 'user' . $seed . '@example.com'; + $person->age = 20 + ($seed % 50); + $person->active = 0 === $seed % 2; + $person->balance = 1000.5 + $seed; + + $address = new AddressSource(); + $address->street = $seed . ' Main Street'; + $address->city = 'City' . $seed; + $address->zipCode = str_pad((string) ($seed % 100000), 5, '0', STR_PAD_LEFT); + $address->country = 'Country' . ($seed % 20); + $person->address = $address; + $person->tags = ['tag-a', 'tag-b', 'tag-c']; + + return $person; + } + + /** + * A single person carrying a large `addresses` collection — used to exercise the + * JSON stream *writer*, whose streaming (`STREAM`) mode keeps peak memory flat by + * yielding the nested collection element by element instead of buffering it. + */ + public static function widePerson(int $addressCount): Person + { + $person = self::person(1); + $person->addresses = []; + for ($i = 0; $i < $addressCount; ++$i) { + $person->addresses[] = self::address($i); + } + + return $person; + } + + public static function personJson(int $seed = 0): string + { + return json_encode(self::personArray($seed), JSON_THROW_ON_ERROR); + } + + public static function personListJson(int $count): string + { + return json_encode(self::personArrayList($count), JSON_THROW_ON_ERROR); + } + + /** + * Write a large JSON list to a temp file and return its path. + * + * Used by the memory-focused suites: a stream reader can consume the file + * without ever holding the whole decoded structure in memory. + */ + public static function writePersonListJsonFile(int $count): string + { + $path = sys_get_temp_dir() . '/automapper-bench-persons-' . $count . '.json'; + + if (is_file($path)) { + return $path; + } + + $handle = fopen($path, 'w'); + fwrite($handle, '['); + for ($i = 0; $i < $count; ++$i) { + if ($i > 0) { + fwrite($handle, ','); + } + fwrite($handle, json_encode(self::personArray($i), JSON_THROW_ON_ERROR)); + } + fwrite($handle, ']'); + fclose($handle); + + return $path; + } + + /** + * @return resource + */ + public static function stream(string $json) + { + $stream = fopen('php://memory', 'r+'); + fwrite($stream, $json); + rewind($stream); + + return $stream; + } +} diff --git a/bench/src/ManualMapper.php b/bench/src/ManualMapper.php new file mode 100644 index 00000000..a6a6e97b --- /dev/null +++ b/bench/src/ManualMapper.php @@ -0,0 +1,93 @@ + $data + */ + public static function hydratePerson(array $data): Person + { + $person = new Person(); + $person->id = $data['id']; + $person->firstName = $data['firstName']; + $person->lastName = $data['lastName']; + $person->email = $data['email']; + $person->age = $data['age']; + $person->active = $data['active']; + $person->balance = $data['balance']; + $person->address = self::hydrateAddress($data['address']); + $person->tags = $data['tags']; + + $addresses = []; + foreach ($data['addresses'] as $address) { + $addresses[] = self::hydrateAddress($address); + } + $person->addresses = $addresses; + + return $person; + } + + /** + * @param array $data + */ + public static function hydrateAddress(array $data): Address + { + $address = new Address(); + $address->street = $data['street']; + $address->city = $data['city']; + $address->zipCode = $data['zipCode']; + $address->country = $data['country']; + + return $address; + } + + /** + * @return array + */ + public static function normalizePerson(Person $person): array + { + $addresses = []; + foreach ($person->addresses as $address) { + $addresses[] = self::normalizeAddress($address); + } + + return [ + 'id' => $person->id, + 'firstName' => $person->firstName, + 'lastName' => $person->lastName, + 'email' => $person->email, + 'age' => $person->age, + 'active' => $person->active, + 'balance' => $person->balance, + 'address' => self::normalizeAddress($person->address), + 'tags' => $person->tags, + 'addresses' => $addresses, + ]; + } + + /** + * @return array + */ + public static function normalizeAddress(Address $address): array + { + return [ + 'street' => $address->street, + 'city' => $address->city, + 'zipCode' => $address->zipCode, + 'country' => $address->country, + ]; + } +} diff --git a/bench/src/Model/Address.php b/bench/src/Model/Address.php new file mode 100644 index 00000000..56f0070f --- /dev/null +++ b/bench/src/Model/Address.php @@ -0,0 +1,18 @@ + */ + public array $tags = []; + + /** @var list
*/ + public array $addresses = []; +} diff --git a/bench/src/NormalizeBench.php b/bench/src/NormalizeBench.php new file mode 100644 index 00000000..dcd3da36 --- /dev/null +++ b/bench/src/NormalizeBench.php @@ -0,0 +1,57 @@ +person = PayloadFactory::person(1); + + MapperFactory::autoMapper()->map($this->person, 'array'); + MapperFactory::autoMapperNoAttributeChecking()->map($this->person, 'array'); + MapperFactory::serializer()->normalize($this->person); + + // Fail fast if any approach in this group produces a different result. + Verifier::assertNormalize(); + } + + public function benchAutoMapper(): void + { + MapperFactory::autoMapper()->map($this->person, 'array'); + } + + public function benchAutoMapperNoAttributeChecking(): void + { + MapperFactory::autoMapperNoAttributeChecking()->map($this->person, 'array'); + } + + public function benchSymfonySerializer(): void + { + MapperFactory::serializer()->normalize($this->person); + } +} diff --git a/bench/src/ObjectMapping/AddressSource.php b/bench/src/ObjectMapping/AddressSource.php new file mode 100644 index 00000000..6e0a5605 --- /dev/null +++ b/bench/src/ObjectMapping/AddressSource.php @@ -0,0 +1,16 @@ + */ + public array $tags = []; +} diff --git a/bench/src/ObjectMapping/PersonTarget.php b/bench/src/ObjectMapping/PersonTarget.php new file mode 100644 index 00000000..adafbc96 --- /dev/null +++ b/bench/src/ObjectMapping/PersonTarget.php @@ -0,0 +1,20 @@ + */ + public array $tags = []; +} diff --git a/bench/src/ObjectToObjectBench.php b/bench/src/ObjectToObjectBench.php new file mode 100644 index 00000000..ef64fca3 --- /dev/null +++ b/bench/src/ObjectToObjectBench.php @@ -0,0 +1,83 @@ +source = PayloadFactory::personSource(1); + + MapperFactory::autoMapper()->map($this->source, PersonTarget::class); + MapperFactory::autoMapperNoAttributeChecking()->map($this->source, PersonTarget::class); + MapperFactory::autoMapperObjectMapper()->map($this->source, PersonTarget::class); + MapperFactory::symfonyObjectMapper()->map($this->source); + + // Fail fast if any approach in this group produces a different result. + Verifier::assertObjectToObject(); + } + + #[Groups(['baseline'])] + public function benchManual(): void + { + $s = $this->source; + $target = new PersonTarget(); + $target->id = $s->id; + $target->firstName = $s->firstName; + $target->lastName = $s->lastName; + $target->email = $s->email; + $target->age = $s->age; + $target->active = $s->active; + $target->balance = $s->balance; + $target->address = new \Automapper\Bench\ObjectMapping\AddressTarget(); + $target->address->street = $s->address->street; + $target->address->city = $s->address->city; + $target->address->zipCode = $s->address->zipCode; + $target->address->country = $s->address->country; + $target->tags = $s->tags; + } + + public function benchAutoMapper(): void + { + MapperFactory::autoMapper()->map($this->source, PersonTarget::class); + } + + public function benchAutoMapperNoAttributeChecking(): void + { + MapperFactory::autoMapperNoAttributeChecking()->map($this->source, PersonTarget::class); + } + + public function benchAutoMapperObjectMapper(): void + { + MapperFactory::autoMapperObjectMapper()->map($this->source, PersonTarget::class); + } + + public function benchSymfonyObjectMapper(): void + { + MapperFactory::symfonyObjectMapper()->map($this->source); + } +} diff --git a/bench/src/SerializeBench.php b/bench/src/SerializeBench.php new file mode 100644 index 00000000..5642aa0f --- /dev/null +++ b/bench/src/SerializeBench.php @@ -0,0 +1,96 @@ + */ + private array $array; + + private Type $type; + + public function setUp(): void + { + $this->person = PayloadFactory::person(1); + $this->array = PayloadFactory::personArray(1); + $this->type = Type::object(Person::class); + + MapperFactory::autoMapper()->map($this->person, 'array'); + MapperFactory::autoMapperNoAttributeChecking()->map($this->person, 'array'); + MapperFactory::serializer()->serialize($this->person, 'json'); + (string) MapperFactory::jsonStreamWriter()->write($this->person, $this->type); + (string) MapperFactory::autoMapperJsonStreamWriter()->write($this->person, $this->type); + (string) MapperFactory::autoMapperNoAttributeJsonStreamWriter()->write($this->person, $this->type); + + // Fail fast if any approach in this group produces a different result. + Verifier::assertSerialize(); + } + + /** + * Fair floor: hand-written normalization of the `Person` graph, then encode — + * the same input the mappers consume, done by hand. + */ + #[Groups(['baseline'])] + public function benchManual(): void + { + $data = ManualMapper::normalizePerson($this->person); + $json = json_encode($data, JSON_THROW_ON_ERROR); + } + + public function benchAutoMapper(): void + { + $data = MapperFactory::autoMapper()->map($this->person, 'array'); + $json = json_encode($data, JSON_THROW_ON_ERROR); + } + + public function benchAutoMapperNoAttributeChecking(): void + { + $data = MapperFactory::autoMapperNoAttributeChecking()->map($this->person, 'array'); + $json = json_encode($data, JSON_THROW_ON_ERROR); + } + + public function benchSymfonySerializer(): void + { + MapperFactory::serializer()->serialize($this->person, 'json'); + } + + public function benchSymfonyJsonStreamer(): void + { + $json = (string) MapperFactory::jsonStreamWriter()->write($this->person, $this->type); + } + + public function benchAutoMapperJsonStreamer(): void + { + $json = (string) MapperFactory::autoMapperJsonStreamWriter()->write($this->person, $this->type); + } + + public function benchAutoMapperJsonStreamerNoAttributeChecking(): void + { + $json = (string) MapperFactory::autoMapperNoAttributeJsonStreamWriter()->write($this->person, $this->type); + } +} diff --git a/bench/src/Verifier.php b/bench/src/Verifier.php new file mode 100644 index 00000000..d7237be6 --- /dev/null +++ b/bench/src/Verifier.php @@ -0,0 +1,264 @@ + name => canonical result + */ + public static function denormalizeResults(): array + { + $array = PayloadFactory::personArray(1); + + return [ + 'automapper' => self::canonicalize(MapperFactory::autoMapper()->map($array, Person::class)), + 'automapper_eval' => self::canonicalize(MapperFactory::autoMapperEval()->map($array, Person::class)), + 'automapper_no_constructor' => self::canonicalize(MapperFactory::autoMapperNoConstructor()->map($array, Person::class)), + 'automapper_no_attribute' => self::canonicalize(MapperFactory::autoMapperNoAttributeChecking()->map($array, Person::class)), + 'symfony_serializer' => self::canonicalize(MapperFactory::serializer()->denormalize($array, Person::class)), + ]; + } + + /** + * Every normalization approach (object → array, no JSON) must yield the same + * array. + * + * @return array + */ + public static function normalizeResults(): array + { + $person = PayloadFactory::person(1); + + return [ + 'automapper' => self::canonicalize(MapperFactory::autoMapper()->map($person, 'array')), + 'automapper_no_attribute' => self::canonicalize(MapperFactory::autoMapperNoAttributeChecking()->map($person, 'array')), + 'symfony_serializer' => self::canonicalize(MapperFactory::serializer()->normalize($person)), + ]; + } + + /** + * Every deserialization approach (JSON → object) must yield the same + * {@see Person} graph. + * + * @return array + */ + public static function deserializeResults(): array + { + $json = PayloadFactory::personJson(1); + $type = Type::object(Person::class); + + return [ + 'json_decode' => self::canonicalize(json_decode($json, true)), // the floor: same data, as an array + 'manual' => self::canonicalize(ManualMapper::hydratePerson(json_decode($json, true))), + 'automapper' => self::canonicalize(MapperFactory::autoMapper()->map(json_decode($json, true), Person::class)), + 'automapper_no_attribute' => self::canonicalize(MapperFactory::autoMapperNoAttributeChecking()->map(json_decode($json, true), Person::class)), + 'symfony_serializer' => self::canonicalize(MapperFactory::serializer()->deserialize($json, Person::class, 'json')), + 'symfony_json_streamer' => self::canonicalize(MapperFactory::jsonStreamReader()->read(PayloadFactory::stream($json), $type)), + 'automapper_json_streamer' => self::canonicalize(MapperFactory::autoMapperJsonStreamReader()->read(PayloadFactory::stream($json), $type)), + 'automapper_json_streamer_no_attribute' => self::canonicalize(MapperFactory::autoMapperNoAttributeJsonStreamReader()->read(PayloadFactory::stream($json), $type)), + ]; + } + + /** + * Every serialization approach (object → JSON) must yield the same JSON + * payload (key order aside). + * + * @return array + */ + public static function serializeResults(): array + { + $person = PayloadFactory::person(1); + $type = Type::object(Person::class); + + return [ + 'json_encode' => self::canonicalize(PayloadFactory::personArray(1)), + 'manual' => self::canonicalize(ManualMapper::normalizePerson($person)), + 'automapper' => self::canonicalize(json_decode(json_encode(MapperFactory::autoMapper()->map($person, 'array')), true)), + 'automapper_no_attribute' => self::canonicalize(json_decode(json_encode(MapperFactory::autoMapperNoAttributeChecking()->map($person, 'array')), true)), + 'symfony_serializer' => self::canonicalize(json_decode(MapperFactory::serializer()->serialize($person, 'json'), true)), + 'symfony_json_streamer' => self::canonicalize(json_decode((string) MapperFactory::jsonStreamWriter()->write($person, $type), true)), + 'automapper_json_streamer' => self::canonicalize(json_decode((string) MapperFactory::autoMapperJsonStreamWriter()->write($person, $type), true)), + 'automapper_json_streamer_no_attribute' => self::canonicalize(json_decode((string) MapperFactory::autoMapperNoAttributeJsonStreamWriter()->write($person, $type), true)), + ]; + } + + /** + * Every object-to-object approach must yield the same {@see PersonTarget}. + * + * @return array + */ + public static function objectToObjectResults(): array + { + $source = PayloadFactory::personSource(1); + + return [ + 'automapper' => self::canonicalize(MapperFactory::autoMapper()->map($source, PersonTarget::class)), + 'automapper_no_attribute' => self::canonicalize(MapperFactory::autoMapperNoAttributeChecking()->map($source, PersonTarget::class)), + 'automapper_object_mapper' => self::canonicalize(MapperFactory::autoMapperObjectMapper()->map($source, PersonTarget::class)), + 'symfony_object_mapper' => self::canonicalize(MapperFactory::symfonyObjectMapper()->map($source)), + ]; + } + + /** + * Every collection approach must yield the same list of {@see Person} graphs. + * + * @return array + */ + public static function collectionResults(int $count = 5): array + { + $json = PayloadFactory::personListJson($count); + $listType = Type::list(Type::object(Person::class)); + $iterableType = Type::iterable(Type::object(Person::class), Type::int()); + + return [ + 'automapper_map_collection' => self::canonicalize( + MapperFactory::autoMapper()->mapCollection(json_decode($json, true), Person::class) + ), + 'symfony_serializer' => self::canonicalize( + MapperFactory::serializer()->deserialize($json, Person::class . '[]', 'json') + ), + 'symfony_json_streamer_iterable' => self::canonicalize( + iterator_to_array(MapperFactory::jsonStreamReader()->read(PayloadFactory::stream($json), $iterableType)) + ), + 'symfony_json_streamer_list' => self::canonicalize( + MapperFactory::jsonStreamReader()->read(PayloadFactory::stream($json), $listType) + ), + 'automapper_json_streamer_buffered' => self::canonicalize( + iterator_to_array(MapperFactory::autoMapperJsonStreamReader()->read(PayloadFactory::stream($json), $listType)) + ), + 'automapper_json_streamer_buffered_no_attribute' => self::canonicalize( + iterator_to_array(MapperFactory::autoMapperNoAttributeJsonStreamReader()->read(PayloadFactory::stream($json), $listType)) + ), + 'automapper_json_streamer_stream' => self::canonicalize( + iterator_to_array(MapperFactory::autoMapperJsonStreamReader()->read( + PayloadFactory::stream($json), + $listType, + [\AutoMapper\MapperContext::STREAM => true], + )) + ), + 'automapper_json_streamer_stream_no_attribute' => self::canonicalize( + iterator_to_array(MapperFactory::autoMapperNoAttributeJsonStreamReader()->read( + PayloadFactory::stream($json), + $listType, + [\AutoMapper\MapperContext::STREAM => true], + )) + ), + ]; + } + + public static function assertDenormalize(): void + { + self::assertConsistent('denormalize', self::denormalizeResults()); + } + + public static function assertNormalize(): void + { + self::assertConsistent('normalize', self::normalizeResults()); + } + + public static function assertDeserialize(): void + { + self::assertConsistent('deserialize', self::deserializeResults()); + } + + public static function assertSerialize(): void + { + self::assertConsistent('serialize', self::serializeResults()); + } + + public static function assertObjectToObject(): void + { + self::assertConsistent('object-to-object', self::objectToObjectResults()); + } + + public static function assertCollection(): void + { + self::assertConsistent('collection', self::collectionResults()); + } + + /** + * Assert every entry equals the first one, throwing a readable diff otherwise. + * + * @param array $results + */ + public static function assertConsistent(string $group, array $results): void + { + $referenceName = null; + $reference = null; + + foreach ($results as $name => $value) { + if (null === $referenceName) { + $referenceName = $name; + $reference = $value; + + continue; + } + + if ($value !== $reference) { + throw new \RuntimeException(\sprintf( + "Benchmark group \"%s\" is inconsistent: \"%s\" does not match \"%s\".\n %s = %s\n %s = %s", + $group, + $name, + $referenceName, + $referenceName, + json_encode($reference, JSON_THROW_ON_ERROR), + $name, + json_encode($value, JSON_THROW_ON_ERROR), + )); + } + } + } + + /** + * Normalize an object graph / array to a plain array with keys sorted + * recursively, so two results are compared by data, not by key order. + */ + public static function canonicalize(mixed $value): mixed + { + $normalized = json_decode(json_encode($value, JSON_THROW_ON_ERROR), true); + + return self::ksortRecursive($normalized); + } + + private static function ksortRecursive(mixed $value): mixed + { + if (!\is_array($value)) { + return $value; + } + + // Only sort keys for associative arrays; keep list order intact. + if (!array_is_list($value)) { + ksort($value); + } + + foreach ($value as $key => $item) { + $value[$key] = self::ksortRecursive($item); + } + + return $value; + } +} diff --git a/bench/src/WriteCollectionBench.php b/bench/src/WriteCollectionBench.php new file mode 100644 index 00000000..226d2080 --- /dev/null +++ b/bench/src/WriteCollectionBench.php @@ -0,0 +1,113 @@ + */ + private array $persons; + + private Type $listType; + + /** + * @return array + */ + public function provideSizes(): array + { + return [ + 'small (1k)' => ['count' => 1_000], + 'large (20k)' => ['count' => 20_000], + ]; + } + + /** + * @param array{count: int} $params + */ + public function setUp(array $params): void + { + $this->persons = PayloadFactory::personList($params['count']); + $this->listType = Type::iterable(Type::object(Person::class)); + + // Warm generation and assert both writers agree (canonically). + $automapper = MapperFactory::autoMapperJsonStreamWriter(); + $symfony = MapperFactory::jsonStreamWriter(); + $small = PayloadFactory::personList(3); + + Verifier::assertConsistent('write-collection', [ + 'automapper' => Verifier::canonicalize(json_decode((string) $automapper->write($small, $this->listType), true)), + 'symfony' => Verifier::canonicalize(json_decode((string) $symfony->write($small, $this->listType), true)), + ]); + } + + #[ParamProviders('provideSizes')] + public function benchAutoMapperJsonStreamerToString(): void + { + $sink = \strlen((string) MapperFactory::autoMapperNoAttributeJsonStreamWriter()->write( + $this->persons, + $this->listType, + [MapperContext::STREAM => true], + )); + } + + #[ParamProviders('provideSizes')] + public function benchAutoMapperJsonStreamerStream(): void + { + $sink = 0; + $result = MapperFactory::autoMapperNoAttributeJsonStreamWriter()->write( + $this->persons, + $this->listType, + [MapperContext::STREAM => true], + ); + foreach ($result as $chunk) { + $sink += \strlen($chunk); + } + } + + #[ParamProviders('provideSizes')] + public function benchSymfonyJsonStreamerToString(): void + { + $sink = \strlen((string) MapperFactory::jsonStreamWriter()->write($this->persons, $this->listType)); + } + + #[ParamProviders('provideSizes')] + public function benchSymfonyJsonStreamerIterate(): void + { + $sink = 0; + foreach (MapperFactory::jsonStreamWriter()->write($this->persons, $this->listType) as $chunk) { + $sink += \strlen($chunk); + } + } + + #[ParamProviders('provideSizes')] + public function benchAutoMapperJsonEncode(): void + { + $array = MapperFactory::autoMapperNoAttributeChecking()->mapCollection($this->persons, 'array'); + $sink = \strlen((string) json_encode($array)); + } +} diff --git a/bench/src/WriteWideObjectBench.php b/bench/src/WriteWideObjectBench.php new file mode 100644 index 00000000..5f74917e --- /dev/null +++ b/bench/src/WriteWideObjectBench.php @@ -0,0 +1,141 @@ + + */ + public function provideSizes(): array + { + return [ + 'small (5k)' => ['count' => 5_000], + 'large (50k)' => ['count' => 50_000], + ]; + } + + /** + * @param array{count: int} $params + */ + public function setUp(array $params): void + { + $this->person = PayloadFactory::widePerson($params['count']); + $this->type = Type::object(Person::class); + + // Warm generation and assert every writer agrees on the output (compared + // canonically: Symfony keeps declaration order, AutoMapper sorts keys). + $writer = MapperFactory::autoMapperJsonStreamWriter(); + $noAttr = MapperFactory::autoMapperNoAttributeJsonStreamWriter(); + $symfony = MapperFactory::jsonStreamWriter(); + $small = PayloadFactory::widePerson(3); + + $stream = function ($w, array $options = []) use ($small): string { + $json = ''; + foreach ($w->write($small, $this->type, $options) as $chunk) { + $json .= $chunk; + } + + return $json; + }; + + Verifier::assertConsistent('write-wide-object', [ + 'automapper_buffered' => Verifier::canonicalize(json_decode((string) $writer->write($small, $this->type), true)), + 'automapper_stream' => Verifier::canonicalize(json_decode($stream($writer, [MapperContext::STREAM => true]), true)), + 'automapper_buffered_no_attr' => Verifier::canonicalize(json_decode((string) $noAttr->write($small, $this->type), true)), + 'automapper_stream_no_attr' => Verifier::canonicalize(json_decode($stream($noAttr, [MapperContext::STREAM => true]), true)), + 'symfony_buffered' => Verifier::canonicalize(json_decode((string) $symfony->write($small, $this->type), true)), + 'symfony_stream' => Verifier::canonicalize(json_decode($stream($symfony), true)), + ]); + } + + #[ParamProviders('provideSizes')] + public function benchBufferedToString(): void + { + $sink = \strlen((string) MapperFactory::autoMapperJsonStreamWriter()->write($this->person, $this->type)); + } + + #[ParamProviders('provideSizes')] + public function benchBufferedToStringNoAttributeChecking(): void + { + $sink = \strlen((string) MapperFactory::autoMapperNoAttributeJsonStreamWriter()->write($this->person, $this->type)); + } + + #[ParamProviders('provideSizes')] + public function benchStreamIterate(): void + { + $sink = 0; + $result = MapperFactory::autoMapperJsonStreamWriter()->write( + $this->person, + $this->type, + [MapperContext::STREAM => true], + ); + foreach ($result as $chunk) { + $sink += \strlen($chunk); + } + } + + #[ParamProviders('provideSizes')] + public function benchStreamIterateNoAttributeChecking(): void + { + $sink = 0; + $result = MapperFactory::autoMapperNoAttributeJsonStreamWriter()->write( + $this->person, + $this->type, + [MapperContext::STREAM => true], + ); + foreach ($result as $chunk) { + $sink += \strlen($chunk); + } + } + + #[ParamProviders('provideSizes')] + public function benchSymfonyJsonStreamerToString(): void + { + $sink = \strlen((string) MapperFactory::jsonStreamWriter()->write($this->person, $this->type)); + } + + #[ParamProviders('provideSizes')] + public function benchSymfonyJsonStreamerIterate(): void + { + $sink = 0; + foreach (MapperFactory::jsonStreamWriter()->write($this->person, $this->type) as $chunk) { + $sink += \strlen($chunk); + } + } +} diff --git a/composer.json b/composer.json index a9b688c4..848de4c8 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,7 @@ "doctrine/orm": "^3.5", "matthiasnoback/symfony-dependency-injection-test": "^6.1", "moneyphp/money": "^3.3.2", - "phpunit/phpunit": "^12.4", + "phpunit/phpunit": "^13.0", "symfony/browser-kit": "^7.4 || ^8.0", "symfony/console": "^7.4 || ^8.0", "symfony/filesystem": "^7.4 || ^8.0", @@ -46,6 +46,7 @@ "symfony/framework-bundle": "^7.4 || ^8.0", "symfony/http-client": "^7.4 || ^8.0", "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/json-streamer": "^7.4 || ^8.0", "symfony/object-mapper": "^7.4 || ^8.0", "symfony/phpunit-bridge": "^8.0", "symfony/serializer": "^7.4 || ^8.0", diff --git a/src/AutoMapper.php b/src/AutoMapper.php index ade877b7..a8694cd8 100644 --- a/src/AutoMapper.php +++ b/src/AutoMapper.php @@ -51,8 +51,8 @@ public function __construct( * @template Source of object * @template Target of object * - * @param class-string|'array' $source - * @param class-string|'array' $target + * @param class-string|'array' $source + * @param class-string|'array'|'json' $target * * @return ($source is class-string ? ($target is 'array' ? MapperInterface> : MapperInterface) : MapperInterface, Target>) */ diff --git a/src/AutoMapperRegistryInterface.php b/src/AutoMapperRegistryInterface.php index 4bb47353..8a85b331 100644 --- a/src/AutoMapperRegistryInterface.php +++ b/src/AutoMapperRegistryInterface.php @@ -17,8 +17,8 @@ interface AutoMapperRegistryInterface * @template Source of object * @template Target of object * - * @param class-string|'array' $source - * @param class-string|'array' $target + * @param class-string|'array' $source + * @param class-string|'array'|'json' $target * * @return ($source is class-string ? ($target is 'array' ? MapperInterface> : MapperInterface) : MapperInterface, Target>) */ diff --git a/src/EventListener/Doctrine/DoctrineIdentifierListener.php b/src/EventListener/Doctrine/DoctrineIdentifierListener.php index bdb68d03..e2521b28 100644 --- a/src/EventListener/Doctrine/DoctrineIdentifierListener.php +++ b/src/EventListener/Doctrine/DoctrineIdentifierListener.php @@ -16,12 +16,19 @@ public function __construct( public function __invoke(PropertyMetadataEvent $event): void { + if ($event->mapperMetadata->isTargetArrayLike()) { + return; + } + + /** @var class-string $target */ + $target = $event->mapperMetadata->target; + // isTransient loads the metadata when needed, unlike hasMetadataFor which only checks already loaded ones - if ($event->mapperMetadata->target === 'array' || $this->objectManager->getMetadataFactory()->isTransient($event->mapperMetadata->target)) { + if ($this->objectManager->getMetadataFactory()->isTransient($target)) { return; } - $metadata = $this->objectManager->getClassMetadata($event->mapperMetadata->target); + $metadata = $this->objectManager->getClassMetadata($target); if ($metadata->isIdentifier($event->target->property)) { $event->identifier = true; diff --git a/src/EventListener/Doctrine/DoctrineProviderListener.php b/src/EventListener/Doctrine/DoctrineProviderListener.php index 3803d9f2..ae4ea877 100644 --- a/src/EventListener/Doctrine/DoctrineProviderListener.php +++ b/src/EventListener/Doctrine/DoctrineProviderListener.php @@ -18,8 +18,15 @@ public function __construct( public function __invoke(GenerateMapperEvent $event): void { + if ($event->mapperMetadata->isTargetArrayLike()) { + return; + } + + /** @var class-string $target */ + $target = $event->mapperMetadata->target; + // isTransient loads the metadata when needed, unlike hasMetadataFor which only checks already loaded ones - if ($event->mapperMetadata->target === 'array' || $this->objectManager->getMetadataFactory()->isTransient($event->mapperMetadata->target)) { + if ($this->objectManager->getMetadataFactory()->isTransient($target)) { return; } diff --git a/src/EventListener/Symfony/NameConverterListener.php b/src/EventListener/Symfony/NameConverterListener.php index c7741b5e..523388ca 100644 --- a/src/EventListener/Symfony/NameConverterListener.php +++ b/src/EventListener/Symfony/NameConverterListener.php @@ -16,14 +16,14 @@ public function __construct( public function __invoke(PropertyMetadataEvent $event): void { - if (($event->mapperMetadata->source === 'array' || $event->mapperMetadata->source === \stdClass::class) && $event->source->property === $event->target->property) { + if ($event->mapperMetadata->isSourceArrayLike() && $event->source->property === $event->target->property) { /** @var class-string $target */ $target = $event->mapperMetadata->target; $event->source->property = $this->nameConverter->normalize($event->target->property, $target); } - if (($event->mapperMetadata->target === 'array' || $event->mapperMetadata->target === \stdClass::class) && $event->source->property === $event->target->property) { + if ($event->mapperMetadata->isTargetArrayLike() && $event->source->property === $event->target->property) { /** @var class-string $source */ $source = $event->mapperMetadata->source; diff --git a/src/EventListener/Symfony/SerializerGroupListener.php b/src/EventListener/Symfony/SerializerGroupListener.php index 94a69907..5accbd01 100644 --- a/src/EventListener/Symfony/SerializerGroupListener.php +++ b/src/EventListener/Symfony/SerializerGroupListener.php @@ -16,8 +16,13 @@ public function __construct( public function __invoke(PropertyMetadataEvent $event): void { - $event->target->groups = $this->getGroups($event->mapperMetadata->target, $event->target->property); - $event->source->groups = $this->getGroups($event->mapperMetadata->source, $event->source->property); + if (!$event->mapperMetadata->isTargetArrayLike()) { + $event->target->groups = $this->getGroups($event->mapperMetadata->target, $event->target->property); + } + + if (!$event->mapperMetadata->isSourceArrayLike()) { + $event->source->groups = $this->getGroups($event->mapperMetadata->source, $event->source->property); + } } /** @@ -25,10 +30,6 @@ public function __invoke(PropertyMetadataEvent $event): void */ private function getGroups(string $class, string $property): ?array { - if ('array' === $class || \stdClass::class === $class) { - return null; - } - $serializerClassMetadata = $this->classMetadataFactory->getMetadataFor($class); $anyGroupFound = false; $groups = []; diff --git a/src/EventListener/Symfony/SerializerIgnoreListener.php b/src/EventListener/Symfony/SerializerIgnoreListener.php index d641e33f..21315940 100644 --- a/src/EventListener/Symfony/SerializerIgnoreListener.php +++ b/src/EventListener/Symfony/SerializerIgnoreListener.php @@ -20,14 +20,14 @@ public function __invoke(PropertyMetadataEvent $event): void return; } - if (($event->mapperMetadata->source !== 'array' && $event->mapperMetadata->source !== \stdClass::class) && $this->isIgnoreProperty($event->mapperMetadata->source, $event->source->property)) { + if (!$event->mapperMetadata->isSourceArrayLike() && $this->isIgnoreProperty($event->mapperMetadata->source, $event->source->property)) { $event->ignored = true; $event->ignoreReason = 'Property is ignored by Symfony Serializer Attribute on Source'; return; } - if (($event->mapperMetadata->target !== 'array' && $event->mapperMetadata->target !== \stdClass::class) && $this->isIgnoreProperty($event->mapperMetadata->target, $event->target->property)) { + if (!$event->mapperMetadata->isTargetArrayLike() && $this->isIgnoreProperty($event->mapperMetadata->target, $event->target->property)) { $event->ignored = true; $event->ignoreReason = 'Property is ignored by Symfony Serializer Attribute on Target'; } diff --git a/src/EventListener/Symfony/SerializerMaxDepthListener.php b/src/EventListener/Symfony/SerializerMaxDepthListener.php index 24569438..1a8fd8b2 100644 --- a/src/EventListener/Symfony/SerializerMaxDepthListener.php +++ b/src/EventListener/Symfony/SerializerMaxDepthListener.php @@ -16,8 +16,8 @@ public function __construct( public function __invoke(PropertyMetadataEvent $event): void { - $targetMaxDepth = $this->getMaxDepth($event->mapperMetadata->target, $event->target->property); - $sourceMaxDepth = $this->getMaxDepth($event->mapperMetadata->source, $event->source->property); + $targetMaxDepth = $event->mapperMetadata->isTargetArrayLike() ? null : $this->getMaxDepth($event->mapperMetadata->target, $event->target->property); + $sourceMaxDepth = $event->mapperMetadata->isSourceArrayLike() ? null : $this->getMaxDepth($event->mapperMetadata->source, $event->source->property); // Extract the property metadata if ($targetMaxDepth !== null || $sourceMaxDepth !== null) { @@ -31,10 +31,6 @@ public function __invoke(PropertyMetadataEvent $event): void private function getMaxDepth(string $class, string $property): ?int { - if ('array' === $class || \stdClass::class === $class) { - return null; - } - $serializerClassMetadata = $this->classMetadataFactory->getMetadataFor($class); $maxDepth = null; diff --git a/src/Extractor/FromSourceMappingExtractor.php b/src/Extractor/FromSourceMappingExtractor.php index 9f35efea..0628ca84 100644 --- a/src/Extractor/FromSourceMappingExtractor.php +++ b/src/Extractor/FromSourceMappingExtractor.php @@ -110,7 +110,7 @@ private function transformSourceType(string $target, ?Type $type = null): ?Type // Transform objects to array or \stdClass given the target if ($type instanceof Type\ObjectType && \stdClass::class !== $type->getClassName()) { - return $target === 'array' ? Type::arrayShape([]) : Type::object(\stdClass::class); + return \in_array($target, ['array', 'json'], true) ? Type::arrayShape([]) : Type::object(\stdClass::class); } return $type; diff --git a/src/Extractor/JsonWriteMutator.php b/src/Extractor/JsonWriteMutator.php new file mode 100644 index 00000000..8b56d17e --- /dev/null +++ b/src/Extractor/JsonWriteMutator.php @@ -0,0 +1,38 @@ +writeInfoExtractor->getWriteInfo($target, $property, $context); if (null === $writeInfo || PropertyWriteInfo::TYPE_NONE === $writeInfo->getType()) { diff --git a/src/Generator/JsonMapMethodStatementsGenerator.php b/src/Generator/JsonMapMethodStatementsGenerator.php new file mode 100644 index 00000000..56fb53e7 --- /dev/null +++ b/src/Generator/JsonMapMethodStatementsGenerator.php @@ -0,0 +1,80 @@ +variableRegistry; + $separatorVar = JsonPropertyStatementsGenerator::separatorVariable(); + + $bodyStatements = [ + // A scratch array so transformers referencing the target's existing value keep working. + new Stmt\Expression(new Expr\Assign($variableRegistry->getResult(), new Expr\Array_())), + new Stmt\Expression(new Expr\Yield_(new Scalar\String_('{'))), + new Stmt\Expression(new Expr\Assign($separatorVar, new Scalar\String_(''))), + ]; + + foreach ($metadata->propertiesMetadata as $propertyMetadata) { + $bodyStatements = [...$bodyStatements, ...$this->jsonPropertyStatementsGenerator->generate($metadata, $propertyMetadata)]; + } + + $bodyStatements[] = new Stmt\Expression(new Expr\Yield_(new Scalar\String_('}'))); + + /** @var class-string $closureUseClass */ + $closureUseClass = class_exists(ClosureUse::class) ? ClosureUse::class : Arg::class; + + $streamVar = new Expr\Variable('stream'); + $streamClosure = new Expr\Closure([ + 'uses' => [ + new $closureUseClass($variableRegistry->getSourceInput()), + new $closureUseClass($variableRegistry->getContext()), + ], + 'stmts' => $bodyStatements, + ]); + + return [ + new Stmt\Expression(new Expr\Assign($streamVar, new Expr\FuncCall($streamClosure))), + new Stmt\Return_($streamVar), + ]; + } + + /** + * The nested object → json dependencies referenced by this mapper, so they can be injected. + * + * @return MapperDependency[] + */ + public function jsonDependencies(GeneratorMetadata $metadata): array + { + return $this->jsonPropertyStatementsGenerator->jsonDependencies($metadata); + } +} diff --git a/src/Generator/JsonPropertyStatementsGenerator.php b/src/Generator/JsonPropertyStatementsGenerator.php new file mode 100644 index 00000000..094e43e5 --- /dev/null +++ b/src/Generator/JsonPropertyStatementsGenerator.php @@ -0,0 +1,247 @@ +ignored) { + return []; + } + + $variableRegistry = $metadata->variableRegistry; + $fieldValueExpr = $propertyMetadata->source->accessor?->getExpression($variableRegistry->getSourceInput()); + + if (null === $fieldValueExpr) { + if (!$propertyMetadata->transformer instanceof AllowNullValueTransformerInterface) { + return []; + } + + $fieldValueExpr = new Expr\ConstFetch(new Name('null')); + } + + $keyPrefix = new Expr\BinaryOp\Concat( + self::separatorVariable(), + new Scalar\String_('"' . $this->escapeKey($propertyMetadata->target->property) . '":'), + ); + + $propStatements = $this->propertyStatements($metadata, $propertyMetadata, $fieldValueExpr, $keyPrefix); + + $condition = $this->propertyConditionsGenerator->generate($metadata, $propertyMetadata); + + if ($condition) { + return [new Stmt\If_($condition, ['stmts' => $propStatements])]; + } + + return $propStatements; + } + + /** + * The nested object → json dependencies referenced by this mapper, so they can be injected. + * + * @return MapperDependency[] + */ + public function jsonDependencies(GeneratorMetadata $metadata): array + { + $dependencies = []; + + foreach ($metadata->propertiesMetadata as $propertyMetadata) { + if ($propertyMetadata->ignored) { + continue; + } + + $transformer = $propertyMetadata->transformer; + $objectTransformer = $transformer instanceof ObjectTransformer ? $transformer : $this->objectListItemTransformer($transformer); + + if ($objectTransformer === null || ($source = $this->streamableSource($objectTransformer)) === null) { + continue; + } + + $dependencies[$source] = new MapperDependency($this->jsonDependencyName($source), $source, 'json'); + } + + return array_values($dependencies); + } + + /** + * @return Stmt[] + */ + private function propertyStatements( + GeneratorMetadata $metadata, + PropertyMetadata $propertyMetadata, + Expr $fieldValueExpr, + Expr $keyPrefix, + ): array { + $transformer = $propertyMetadata->transformer; + $variableRegistry = $metadata->variableRegistry; + $advanceSep = new Stmt\Expression(new Expr\Assign(self::separatorVariable(), new Scalar\String_(','))); + + // A nested object mapped by a sub-mapper: stream it via the nested object → json + // sub-mapper's own map() so nested collections stream too. + if ($transformer instanceof ObjectTransformer && ($source = $this->streamableSource($transformer)) !== null) { + $valueVar = new Expr\Variable($variableRegistry->getUniqueVariableScope()->getUniqueName('jsonValue')); + + return [ + new Stmt\Expression(new Expr\Assign($valueVar, $fieldValueExpr)), + new Stmt\Expression(new Expr\Yield_($keyPrefix)), + $advanceSep, + new Stmt\If_(new Expr\BinaryOp\Identical(new Expr\ConstFetch(new Name('null')), $valueVar), [ + 'stmts' => [new Stmt\Expression(new Expr\Yield_(new Scalar\String_('null')))], + 'else' => new Stmt\Else_([$this->yieldFromMapper($source, $valueVar, $variableRegistry)]), + ]), + ]; + } + + // A list of objects mapped by a sub-mapper: stream `[` + each element + `]`. + if (($itemTransformer = $this->objectListItemTransformer($transformer)) !== null + && ($source = $this->streamableSource($itemTransformer)) !== null) { + $scope = $variableRegistry->getUniqueVariableScope(); + $itemVar = new Expr\Variable($scope->getUniqueName('jsonItem')); + $itemSepVar = new Expr\Variable($scope->getUniqueName('jsonItemSep')); + + return [ + new Stmt\Expression(new Expr\Yield_($keyPrefix)), + $advanceSep, + new Stmt\Expression(new Expr\Yield_(new Scalar\String_('['))), + new Stmt\Expression(new Expr\Assign($itemSepVar, new Scalar\String_(''))), + new Stmt\Foreach_(new Expr\BinaryOp\Coalesce($fieldValueExpr, new Expr\Array_()), $itemVar, [ + 'stmts' => [ + new Stmt\Expression(new Expr\Yield_($itemSepVar)), + new Stmt\Expression(new Expr\Assign($itemSepVar, new Scalar\String_(','))), + new Stmt\If_(new Expr\BinaryOp\Identical(new Expr\ConstFetch(new Name('null')), $itemVar), [ + 'stmts' => [new Stmt\Expression(new Expr\Yield_(new Scalar\String_('null')))], + 'else' => new Stmt\Else_([$this->yieldFromMapper($source, $itemVar, $variableRegistry)]), + ]), + ], + ]), + new Stmt\Expression(new Expr\Yield_(new Scalar\String_(']'))), + ]; + } + + // Leaf value (scalar, date, enum, scalar list, dict, ...): transform then json_encode + // the result in one native call. + [$output, $propStatements] = $transformer->transform( + $fieldValueExpr, + $variableRegistry->getResult(), + $propertyMetadata, + $variableRegistry->getUniqueVariableScope(), + $variableRegistry->getSourceInput(), + ); + + $propStatements[] = new Stmt\Expression(new Expr\Yield_(new Expr\BinaryOp\Concat( + $keyPrefix, + new Expr\FuncCall(new Name('json_encode'), [new Arg($output)]), + ))); + $propStatements[] = new Stmt\Expression(new Expr\Assign(self::separatorVariable(), new Scalar\String_(','))); + + return $propStatements; + } + + private function yieldFromMapper(string $source, Expr $value, VariableRegistry $variableRegistry): Stmt + { + return new Stmt\Expression(new Expr\YieldFrom(new Expr\MethodCall( + new Expr\ArrayDimFetch( + new Expr\PropertyFetch(new Expr\Variable('this'), 'mappers'), + new Scalar\String_($this->jsonDependencyName($source)), + ), + 'map', + [ + new Arg($value), + new Arg($variableRegistry->getContext()), + ], + ))); + } + + /** + * The per-item {@see ObjectTransformer} when the property is a *list* of objects + * (bare or lazy-collection wrapped), or null otherwise. + */ + private function objectListItemTransformer(TransformerInterface $transformer): ?ObjectTransformer + { + if ($transformer instanceof ArrayTransformer) { + $itemTransformer = $transformer->getItemTransformer(); + } elseif ($transformer instanceof LazyCollectionTransformer && $transformer->getEagerTransformer() instanceof ArrayTransformer) { + $itemTransformer = $transformer->getItemTransformer(); + } else { + return null; + } + + return $itemTransformer instanceof ObjectTransformer ? $itemTransformer : null; + } + + /** + * The source class of the nested object mapping when it is an object → array mapping (so a + * json sibling mapper exists for it), or null otherwise. + * + * @return class-string|null + */ + private function streamableSource(ObjectTransformer $transformer): ?string + { + foreach ($transformer->getDependencies() as $dependency) { + if ('array' !== $dependency->source && 'array' === $dependency->target) { + /** @var class-string */ + return $dependency->source; + } + } + + return null; + } + + private function jsonDependencyName(string $source): string + { + return 'Mapper_' . $source . '_json'; + } + + private function escapeKey(string $key): string + { + // Keys are property names; encode to be safe and strip the surrounding quotes. + return substr(json_encode($key, JSON_THROW_ON_ERROR), 1, -1); + } +} diff --git a/src/Generator/MapperGenerator.php b/src/Generator/MapperGenerator.php index c8a63d7b..ac01ba7d 100644 --- a/src/Generator/MapperGenerator.php +++ b/src/Generator/MapperGenerator.php @@ -12,11 +12,15 @@ use AutoMapper\Generator\Shared\CachedReflectionStatementsGenerator; use AutoMapper\Generator\Shared\ClassDiscriminatorResolver; use AutoMapper\Generator\Shared\DiscriminatorStatementsGenerator; +use AutoMapper\LazyMapper; use AutoMapper\Metadata\GeneratorMetadata; +use AutoMapper\Transformer\MapperDependency; use PhpParser\Builder; +use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Name; use PhpParser\Node\Param; +use PhpParser\Node\Scalar; use PhpParser\Node\Stmt; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; @@ -35,6 +39,7 @@ private MapperConstructorGenerator $mapperConstructorGenerator; private InjectMapperMethodStatementsGenerator $injectMapperMethodStatementsGenerator; private MapMethodStatementsGenerator $mapMethodStatementsGenerator; + private JsonMapMethodStatementsGenerator $jsonMapMethodStatementsGenerator; private IdentifierHashGenerator $identifierHashGenerator; private bool $disableGeneratedMapper; @@ -54,6 +59,10 @@ public function __construct( $expressionLanguage, ); + $this->jsonMapMethodStatementsGenerator = new JsonMapMethodStatementsGenerator( + new JsonPropertyStatementsGenerator(new PropertyConditionsGenerator($expressionLanguage)), + ); + $this->injectMapperMethodStatementsGenerator = new InjectMapperMethodStatementsGenerator(); $this->identifierHashGenerator = new IdentifierHashGenerator(); @@ -79,6 +88,12 @@ public function generate(GeneratorMetadata $metadata): array $statements[] = new Stmt\Declare_([create_declare_item('strict_types', create_scalar_int(1))]); } + if ($metadata->mapperMetadata->isJsonTarget()) { + $statements[] = $this->generateJsonMapper($metadata); + + return $statements; + } + [$constructorStatements, $duplicatedStatements, $setterStatements] = $this->mapMethodStatementsGenerator->getMappingStatements($metadata); $builder = (new Builder\Class_($metadata->mapperMetadata->className)) @@ -211,6 +226,87 @@ private function doMapMethod(GeneratorMetadata $metadata, array $setterStatement ->getNode(); } + /** + * Create a mapper whose `map()` streams the source as JSON (target `json`). + * + * It reuses the same read/transform pipeline as the object → array mapper, but emits the + * value as a JSON stream (`map()` returns an `iterable` of chunks) instead of + * building an array. + */ + private function generateJsonMapper(GeneratorMetadata $metadata): Stmt\Class_ + { + return (new Builder\Class_($metadata->mapperMetadata->className)) + ->makeFinal() + ->extend(GeneratedMapper::class) + ->addStmt($this->constructorMethod($metadata)) + ->addStmt($this->jsonMapMethod($metadata)) + ->addStmt($this->jsonRegisterMappersMethod($metadata)) + ->getNode(); + } + + /** + * The `map()` method of a `json` mapper: it returns a generator that yields the JSON chunk + * by chunk. The body is wrapped in a closure so `map()` itself is not a generator and can + * return the stream (by reference, like every other mapper). + */ + private function jsonMapMethod(GeneratorMetadata $metadata): Stmt\ClassMethod + { + $variableRegistry = $metadata->variableRegistry; + + return (new Builder\Method('map')) + ->makePublic() + ->setReturnType('mixed') + ->makeReturnByRef() + ->addParam(new Param($variableRegistry->getSourceInput())) + ->addParam(new Param($variableRegistry->getContext(), default: new Expr\Array_(), type: new Name('array'))) + ->addStmts($this->jsonMapMethodStatementsGenerator->getStatements($metadata)) + ->setDocComment( + \sprintf( + '/** @param %s $%s */', + '\\' . $metadata->mapperMetadata->source, + 'value' + ) + ) + ->getNode(); + } + + /** + * The `registerMappers()` of a `json` mapper: the regular dependencies (used by leaf + * transformers) plus the nested object → json sub-mappers used to stream nested objects. + */ + private function jsonRegisterMappersMethod(GeneratorMetadata $metadata): Stmt\ClassMethod + { + $registryVariable = new Expr\Variable('autoMapperRegistry'); + + $statements = $this->injectMapperMethodStatementsGenerator->getStatements($registryVariable, $metadata); + + foreach ($this->jsonMapMethodStatementsGenerator->jsonDependencies($metadata) as $dependency) { + $statements[] = $this->injectMapperStatement($registryVariable, $dependency); + } + + return (new Builder\Method('registerMappers')) + ->makePublic() + ->setReturnType('void') + ->addParam(new Param(var: $registryVariable, type: new Name(AutoMapperRegistryInterface::class))) + ->addStmts($statements) + ->getNode(); + } + + private function injectMapperStatement(Expr\Variable $registryVariable, MapperDependency $dependency): Stmt + { + return new Stmt\Expression(new Expr\Assign( + new Expr\ArrayDimFetch( + new Expr\PropertyFetch(new Expr\Variable('this'), 'mappers'), + new Scalar\String_($dependency->name), + ), + new Expr\New_(new Name(LazyMapper::class), [ + new Arg($registryVariable), + new Arg(new Scalar\String_($dependency->source)), + new Arg(new Scalar\String_($dependency->target)), + ]), + )); + } + /** * Create the registerMappers methods for this mapper. * diff --git a/src/JsonStreamer/JsonStreamReader.php b/src/JsonStreamer/JsonStreamReader.php new file mode 100644 index 00000000..88b0dbc7 --- /dev/null +++ b/src/JsonStreamer/JsonStreamReader.php @@ -0,0 +1,127 @@ + + * + * @phpstan-import-type MapperContextArray from MapperContext + */ +final class JsonStreamReader implements StreamReaderInterface +{ + public function __construct( + private readonly AutoMapperInterface $mapper, + /** @var StreamReaderInterface> */ + private readonly StreamReaderInterface $fallbackStreamReader, + ) { + } + + public function read($input, Type $type, array $options = []): mixed + { + $unwrapped = $this->unwrap($type); + + if ($unwrapped instanceof CollectionType) { + $className = $this->ownedClassName($this->unwrap($unwrapped->getCollectionValueType())); + + if ($className !== null) { + // The `iterable` builtin is the only shape the underlying reader decodes lazily + // (`list`/`array`/`dict` are always materialized through `iterator_to_array`). + // The key type drives whether the JSON is read as a list or as a dict. + $shape = Type::iterable(Type::dict(), $unwrapped->getCollectionKeyType()); + $rawItems = $this->fallbackStreamReader->read($input, $shape, $options); + + if (!is_iterable($rawItems)) { + return $rawItems; + } + + $buffered = !($options[MapperContext::STREAM] ?? false); + + /** @var iterable $rawItems */ + return new LazyCollection( + fn (mixed $rawItem): mixed => \is_array($rawItem) || \is_object($rawItem) + ? $this->mapper->map($rawItem, $className, $options) + : $rawItem, + $rawItems, + $buffered, + ); + } + } + + $className = $this->ownedClassName($unwrapped); + + if ($className !== null) { + $raw = $this->fallbackStreamReader->read($input, Type::dict(), $options); + + if (!\is_array($raw)) { + return $raw; + } + + return $this->mapper->map($raw, $className, $options); + } + + return $this->fallbackStreamReader->read($input, $type, $options); + } + + /** + * Unwrap nullable and generic wrappers, but never a collection (whose wrapped type + * is the underlying `array`/`iterable` builtin, not the value we care about). + */ + private function unwrap(Type $type): Type + { + while ($type instanceof NullableType || $type instanceof GenericType) { + $type = $type->getWrappedType(); + } + + return $type; + } + + /** + * Return the class name when the AutoMapper should own its construction, or null when + * the underlying reader is a better fit (scalars, enums, and value objects handled by the + * Symfony value transformers). + * + * @return class-string|null + */ + private function ownedClassName(Type $type): ?string + { + if (!$type instanceof ObjectType) { + return null; + } + + /** @var class-string $className */ + $className = $type->getClassName(); + + if ( + is_a($className, \DateTimeInterface::class, true) + || is_a($className, \DateInterval::class, true) + || is_a($className, \DateTimeZone::class, true) + ) { + return null; + } + + return $className; + } +} diff --git a/src/JsonStreamer/JsonStreamWriter.php b/src/JsonStreamer/JsonStreamWriter.php new file mode 100644 index 00000000..bd22e595 --- /dev/null +++ b/src/JsonStreamer/JsonStreamWriter.php @@ -0,0 +1,181 @@ + + * + * @phpstan-import-type MapperContextArray from \AutoMapper\MapperContext + */ +final class JsonStreamWriter implements StreamWriterInterface +{ + public function __construct( + private readonly AutoMapperInterface $mapper, + /** @var StreamWriterInterface> */ + private readonly StreamWriterInterface $fallbackStreamWriter, + ) { + } + + public function write( + mixed $data, + Type $type, + array $options = [], + ): \Traversable&\Stringable { + $unwrapped = $this->unwrap($type); + + if ($unwrapped instanceof CollectionType) { + $className = $this->ownedClassName($this->unwrap($unwrapped->getCollectionValueType())); + + if ($className !== null && is_iterable($data) && $this->jsonMapper($className) !== null) { + /** @var iterable $data */ + return $this->wrap(fn (): \Generator => $this->collectionChunks($data, $className, $options)); + } + } + + if ( + $unwrapped instanceof ObjectType + && \is_object($data) + && $unwrapped->getClassName() === $data::class + && ($mapper = $this->jsonMapper($data::class)) !== null + ) { + return $this->wrap(static fn (): iterable => $mapper->map($data, $options) ?? []); + } + + return $this->fallbackStreamWriter->write($data, $type, $options); + } + + /** + * Yield the JSON of a collection: `[` + each element's JSON + `]`, one element + * at a time so a large collection is never held in memory at once. + * + * @param iterable $data + * @param class-string $className + * @param MapperContextArray $options + * + * @return \Generator + */ + private function collectionChunks(iterable $data, string $className, array $options): \Generator + { + $mapper = $this->jsonMapper($className); + + yield '['; + $sep = ''; + foreach ($data as $item) { + yield $sep; + if (\is_object($item) && $item::class === $className && $mapper !== null) { + yield from $mapper->map($item, $options) ?? []; + } else { + yield json_encode($item) ?: 'null'; + } + $sep = ','; + } + yield ']'; + } + + /** + * Return the `json` mapper for the class, whose `map()` yields the JSON stream, or null when + * the AutoMapper cannot provide one. + * + * @param class-string $className + * + * @return MapperInterface>|null + */ + private function jsonMapper(string $className): ?MapperInterface + { + if (!$this->mapper instanceof AutoMapperRegistryInterface) { + return null; + } + + /** @var MapperInterface> $mapper */ + $mapper = $this->mapper->getMapper($className, 'json'); + + return $mapper; + } + + /** + * Wrap a chunk-generator factory so it can be consumed either streamed + * (`getIterator`) or buffered (`__toString`). The factory is re-invoked per + * consumption so the result stays re-iterable. + * + * @param \Closure(): iterable $factory + * + * @return \Traversable&\Stringable + */ + private function wrap(\Closure $factory): \Traversable&\Stringable + { + return new /** + * @implements \IteratorAggregate + */ class($factory) implements \IteratorAggregate, \Stringable { + /** + * @param \Closure(): iterable $factory + */ + public function __construct( + private \Closure $factory, + ) { + } + + public function getIterator(): \Traversable + { + yield from ($this->factory)(); + } + + public function __toString(): string + { + $json = ''; + foreach (($this->factory)() as $chunk) { + $json .= $chunk; + } + + return $json; + } + }; + } + + private function unwrap(Type $type): Type + { + while ($type instanceof NullableType || $type instanceof GenericType) { + $type = $type->getWrappedType(); + } + + return $type; + } + + /** + * @return class-string|null + */ + private function ownedClassName(Type $type): ?string + { + if (!$type instanceof ObjectType) { + return null; + } + + /** @var class-string $className */ + $className = $type->getClassName(); + + if ( + is_a($className, \DateTimeInterface::class, true) + || is_a($className, \DateInterval::class, true) + || is_a($className, \DateTimeZone::class, true) + ) { + return null; + } + + return $className; + } +} diff --git a/src/Lazy/LazyCollection.php b/src/Lazy/LazyCollection.php index 70a1dc41..8d175178 100644 --- a/src/Lazy/LazyCollection.php +++ b/src/Lazy/LazyCollection.php @@ -4,92 +4,139 @@ namespace AutoMapper\Lazy; -use AutoMapper\MapperInterface; - /** - * @template S of object|array - * @template T of object|array + * A collection whose elements are mapped lazily, as they are pulled from a source iterator. + * + * By default the collection is buffered: each element is mapped once, on first access, and + * memoized so the collection can be iterated several times, rewound and counted. Peak memory + * is therefore bounded by what has actually been traversed, up to the full collection. + * + * When constructed with `$buffered = false`, elements are streamed without memoization: the + * collection never holds more than a single element, but it can only be iterated once (and it + * cannot be counted or rewound). This is meant for known single-pass pipelines. * - * @implements \Iterator + * Concurrent iteration (two loops over the same instance at once) is only safe under the + * single-threaded execution model: only one iterator ever advances the shared source at a time. * - * @phpstan-import-type MapperContextArray from \AutoMapper\MapperContext + * @template T + * + * @implements \IteratorAggregate */ -final class LazyCollection implements \Countable, \Iterator +final class LazyCollection implements \IteratorAggregate, \Countable, \JsonSerializable { - /** @var array */ + /** @var list */ private array $buffer = []; - private bool $valid = true; + private bool $sourceExhausted = false; + + /** @var \Iterator */ + private readonly \Iterator $source; /** - * @param MapperInterface $mapper - * @param iterable $sourceValues - * @param MapperContextArray $context + * @param callable(mixed, int|string): T $mapItem + * @param iterable $source */ public function __construct( - private readonly MapperInterface $mapper, - private iterable $sourceValues, - private array $context = [], + private $mapItem, + iterable $source, + private readonly bool $buffered = true, ) { + $this->source = self::toIterator($source); } - public function current(): mixed + /** + * @param iterable $items + * + * @return \Iterator + */ + private static function toIterator(iterable $items): \Iterator { - /** @var T */ - return current($this->buffer); + while ($items instanceof \IteratorAggregate) { + $items = $items->getIterator(); + } + + if ($items instanceof \Iterator) { + return $items; + } + + return new \ArrayIterator(\is_array($items) ? $items : iterator_to_array($items)); } - public function next(): void + public function getIterator(): \Generator { - if (false === next($this->buffer)) { + if (!$this->buffered) { + yield from $this->stream(); + return; } - // Get the next value from the source values - if (false !== next($this->sourceValues)) { - /** @var S $current */ - $current = current($this->sourceValues); - /** @var int|string|null */ - $key = key($this->sourceValues); - - if (null !== $key) { - $this->buffer[$key] = $this->mapper->map($current, $this->context); - } else { - $this->buffer[] = $this->mapper->map($current, $this->context); + $index = 0; + + while (true) { + if ($index < \count($this->buffer)) { + [$key, $value] = $this->buffer[$index++]; + + yield $key => $value; + + continue; } - return; - } + if ($this->sourceExhausted || !$this->source->valid()) { + $this->sourceExhausted = true; - $this->valid = false; - } + return; + } - public function key(): mixed - { - /** @var int|string */ - return key($this->buffer); + $key = $this->source->key(); + $value = ($this->mapItem)($this->source->current(), $key); + $this->source->next(); + + $this->buffer[] = [$key, $value]; + + yield $key => $value; + + ++$index; + } } - public function valid(): bool + public function count(): int { - return $this->valid; + // The source may know its length without mapping any value. + if ([] === $this->buffer && !$this->sourceExhausted && $this->source instanceof \Countable) { + return \count($this->source); + } + + $count = 0; + + foreach ($this as $ignored) { + ++$count; + } + + return $count; } - public function rewind(): void + public function jsonSerialize(): mixed { - reset($this->buffer); + return iterator_to_array($this->getIterator()); } - public function count(): int + /** + * @return \Generator + */ + private function stream(): \Generator { - if (\is_array($this->sourceValues)) { - return \count($this->sourceValues); + if ($this->sourceExhausted) { + throw new \LogicException('This lazy collection was created without buffering and has already been consumed.'); } - if ($this->sourceValues instanceof \Countable) { - return \count($this->sourceValues); - } + $this->sourceExhausted = true; + + while ($this->source->valid()) { + $key = $this->source->key(); - return iterator_count($this->sourceValues); + yield $key => ($this->mapItem)($this->source->current(), $key); + + $this->source->next(); + } } } diff --git a/src/LazyMapper.php b/src/LazyMapper.php index 86a2c278..4b04c8f6 100644 --- a/src/LazyMapper.php +++ b/src/LazyMapper.php @@ -23,7 +23,7 @@ public function __construct( private readonly AutoMapperRegistryInterface $registry, /** @var 'array'|class-string */ private readonly string $source, - /** @var 'array'|class-string */ + /** @var 'array'|'json'|class-string */ private readonly string $target, ) { } diff --git a/src/MapperContext.php b/src/MapperContext.php index d0a98b1a..b4a04676 100644 --- a/src/MapperContext.php +++ b/src/MapperContext.php @@ -35,6 +35,7 @@ * "normalizer_format"?: string, * "initialize_lazy_object"?: bool, * "lazy_mapping"?: bool, + * "stream"?: bool, * } */ class MapperContext @@ -60,6 +61,12 @@ class MapperContext public const string INITIALIZE_LAZY_OBJECT = 'initialize_lazy_object'; public const string LAZY_MAPPING = 'lazy_mapping'; + /** + * When true, lazy collections are streamed without buffering: they can only be iterated + * once and cannot be counted or rewound, but never hold more than a single element. + */ + public const string STREAM = 'stream'; + /** @var MapperContextArray */ private array $context = [ self::DEPTH => 0, @@ -354,4 +361,12 @@ public static function shouldLazyLoad(array $context): bool { return $context[self::LAZY_MAPPING] ?? false; } + + /** + * @param array{stream?: bool} $context + */ + public static function shouldStream(array $context): bool + { + return $context[self::STREAM] ?? false; + } } diff --git a/src/Metadata/MapperMetadata.php b/src/Metadata/MapperMetadata.php index 97021154..4f457791 100644 --- a/src/Metadata/MapperMetadata.php +++ b/src/Metadata/MapperMetadata.php @@ -4,6 +4,7 @@ namespace AutoMapper\Metadata; +use AutoMapper\Lazy\LazyMap; use Composer\InstalledVersions; class MapperMetadata @@ -47,6 +48,29 @@ public function __construct( $this->className = $className; } + public function isJsonTarget(): bool + { + return 'json' === $this->target; + } + + /** + * Whether the target is built by projecting the source (array shape) rather than hydrating a + * class. `json` behaves like `array` here: it is the same object → array projection, only + * serialized instead of assigned. + */ + public function isTargetArrayLike(): bool + { + return \in_array($this->target, ['array', \stdClass::class, LazyMap::class, 'json'], true); + } + + /** + * Whether the source is read as an array shape rather than an object. + */ + public function isSourceArrayLike(): bool + { + return \in_array($this->source, ['array', \stdClass::class, LazyMap::class], true); + } + public function getHash(): string { $hash = ''; diff --git a/src/Metadata/MetadataFactory.php b/src/Metadata/MetadataFactory.php index 0e313d93..9a2bb87d 100644 --- a/src/Metadata/MetadataFactory.php +++ b/src/Metadata/MetadataFactory.php @@ -28,7 +28,6 @@ use AutoMapper\Extractor\FromTargetMappingExtractor; use AutoMapper\Extractor\SourceTargetMappingExtractor; use AutoMapper\Generator\Shared\ClassDiscriminatorResolver; -use AutoMapper\Lazy\LazyMap; use AutoMapper\Transformer\AllowNullValueTransformerInterface; use AutoMapper\Transformer\ArrayShapeTransformerFactory; use AutoMapper\Transformer\ArrayTransformerFactory; @@ -86,8 +85,8 @@ public function __construct( } /** - * @param class-string|'array' $source - * @param class-string|'array' $target + * @param class-string|'array' $source + * @param class-string|'array'|'json' $target * * @internal */ @@ -173,11 +172,11 @@ private function createGeneratorMetadata(MapperMetadata $mapperMetadata): Genera { $extractor = $this->sourceTargetPropertiesMappingExtractor; - if ('array' === $mapperMetadata->source || 'stdClass' === $mapperMetadata->source || LazyMap::class === $mapperMetadata->source) { + if ($mapperMetadata->isSourceArrayLike()) { $extractor = $this->fromTargetPropertiesMappingExtractor; } - if ('array' === $mapperMetadata->target || 'stdClass' === $mapperMetadata->target || LazyMap::class === $mapperMetadata->target) { + if ($mapperMetadata->isTargetArrayLike()) { $extractor = $this->fromSourcePropertiesMappingExtractor; } diff --git a/src/Metadata/MetadataRegistry.php b/src/Metadata/MetadataRegistry.php index 1cb66ac8..5b901a0a 100644 --- a/src/Metadata/MetadataRegistry.php +++ b/src/Metadata/MetadataRegistry.php @@ -26,20 +26,23 @@ public function __construct( } /** - * @param class-string|'array' $source - * @param class-string|'array' $target + * @param class-string|'array' $source + * @param class-string|'array'|'json' $target */ public function get(string $source, string $target, bool $registered = false): MapperMetadata { $source = $this->getRealClassName($source); - $target = $this->getRealClassName($target); + // `json` is a projection format that behaves like `array` for every metadata concern, so + // it is kept as a plain array-like target downstream rather than a distinct type. + /** @var class-string|'array' $target */ + $target = 'json' === $target ? 'json' : $this->getRealClassName($target); return $this->registry[$source][$target] ??= new MapperMetadata($source, $target, $registered, $this->configuration->classPrefix); } /** - * @param class-string|'array' $source - * @param class-string|'array' $target + * @param class-string|'array' $source + * @param class-string|'array'|'json' $target */ public function register(string $source, string $target): void { @@ -47,13 +50,14 @@ public function register(string $source, string $target): void } /** - * @param class-string|'array' $source - * @param class-string|'array' $target + * @param class-string|'array' $source + * @param class-string|'array'|'json' $target */ public function has(string $source, string $target, bool $onlyRegistered): bool { $source = $this->getRealClassName($source); - $target = $this->getRealClassName($target); + /** @var class-string|'array' $target */ + $target = 'json' === $target ? 'json' : $this->getRealClassName($target); if (!isset($this->registry[$source][$target])) { return false; diff --git a/src/Transformer/AbstractArrayTransformer.php b/src/Transformer/AbstractArrayTransformer.php index 09d69939..cf353f38 100644 --- a/src/Transformer/AbstractArrayTransformer.php +++ b/src/Transformer/AbstractArrayTransformer.php @@ -27,6 +27,11 @@ public function __construct( abstract protected function getAssignExpr(Expr $valuesVar, Expr $outputVar, Expr $loopKeyVar, bool $assignByRef): Expr; + public function getItemTransformer(): TransformerInterface + { + return $this->itemTransformer; + } + public function transform(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source, ?Expr $existingValue = null): array { /** diff --git a/src/Transformer/ArrayTransformerFactory.php b/src/Transformer/ArrayTransformerFactory.php index de0b349d..6ff83527 100644 --- a/src/Transformer/ArrayTransformerFactory.php +++ b/src/Transformer/ArrayTransformerFactory.php @@ -64,15 +64,50 @@ public function getTransformer(SourcePropertyMetadata $source, TargetPropertyMet $sourceCollectionKeyType = $sourceType instanceof Type\CollectionType ? $sourceType->getCollectionKeyType() : Type::mixed(); if ($sourceCollectionKeyType instanceof Type\BuiltinType && $sourceCollectionKeyType->getTypeIdentifier() === TypeIdentifier::INT) { - return new ArrayTransformer($subItemTransformer); + $collectionTransformer = new ArrayTransformer($subItemTransformer); + } else { + $collectionTransformer = new DictionaryTransformer($subItemTransformer); } - return new DictionaryTransformer($subItemTransformer); + if ($this->targetCanHoldLazyCollection($targetType, $mapperMetadata)) { + return new LazyCollectionTransformer($collectionTransformer, $subItemTransformer); + } + + return $collectionTransformer; } return null; } + /** + * Whether the target can store a {@see \AutoMapper\Lazy\LazyCollection} instead of a plain + * array: either the target is an array shape (any value is accepted), or the property is typed + * as a non-array iterable. A concrete `array`/`list`/`dict` property cannot, and must stay eager. + */ + private function targetCanHoldLazyCollection(Type $targetType, MapperMetadata $mapperMetadata): bool + { + if (isset($mapperMetadata->target) + && \in_array($mapperMetadata->target, ['array', \stdClass::class, LazyMap::class], true)) { + return true; + } + + if ($targetType instanceof Type\NullableType) { + $targetType = $targetType->getWrappedType(); + } + + if (!$targetType instanceof Type\CollectionType) { + return false; + } + + $wrappedType = $targetType->getWrappedType(); + + while ($wrappedType instanceof Type\GenericType) { + $wrappedType = $wrappedType->getWrappedType(); + } + + return $wrappedType instanceof Type\BuiltinType && $wrappedType->getTypeIdentifier() === TypeIdentifier::ITERABLE; + } + /** * @return array{Type, bool} Overridden source type and whether to wrap with NullableTransformer */ diff --git a/src/Transformer/LazyCollectionTransformer.php b/src/Transformer/LazyCollectionTransformer.php new file mode 100644 index 00000000..5904b63c --- /dev/null +++ b/src/Transformer/LazyCollectionTransformer.php @@ -0,0 +1,144 @@ + + * + * @internal + */ +final readonly class LazyCollectionTransformer implements TransformerInterface, DependentTransformerInterface, CheckTypeInterface, \Stringable +{ + public function __construct( + private AbstractArrayTransformer $eagerTransformer, + private TransformerInterface $itemTransformer, + ) { + } + + public function getEagerTransformer(): AbstractArrayTransformer + { + return $this->eagerTransformer; + } + + public function getItemTransformer(): TransformerInterface + { + return $this->itemTransformer; + } + + public function transform(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source, ?Expr $existingValue = null): array + { + // Adder/remover targets are populated element by element by side effect, there is no + // single collection value to make lazy: keep the eager transformation. + if ($propertyMapping->target->writeMutator?->isAdderRemover()) { + return $this->eagerTransformer->transform($input, $target, $propertyMapping, $uniqueVariableScope, $source, $existingValue); + } + + [$eagerOutput, $eagerStatements] = $this->eagerTransformer->transform($input, $target, $propertyMapping, $uniqueVariableScope, $source, $existingValue); + + $outputVar = new Expr\Variable($uniqueVariableScope->getUniqueName('lazyCollection')); + $contextVar = new Expr\Variable('context'); + + /* + * ```php + * if (MapperContext::shouldLazyLoad($context)) { + * $lazyCollection = new LazyCollection(function ($item, $key) use ($value, $context) { + * ... // per-item transformation + * return $output; + * }, $input ?? [], !MapperContext::shouldStream($context)); + * } else { + * ... // eager transformation + * $lazyCollection = $values; + * } + * ``` + */ + $lazyBranch = new Stmt\Expression(new Expr\Assign($outputVar, new Expr\New_( + new Name\FullyQualified(LazyCollection::class), + [ + new Arg($this->createMapItemClosure($target, $propertyMapping, $uniqueVariableScope, $source, $contextVar)), + new Arg(new Expr\BinaryOp\Coalesce($input, new Expr\Array_())), + new Arg(new Expr\BooleanNot(new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'shouldStream', [new Arg($contextVar)]))), + ] + ))); + + $eagerBranch = [...$eagerStatements, new Stmt\Expression(new Expr\Assign($outputVar, $eagerOutput))]; + + $if = new Stmt\If_( + new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'shouldLazyLoad', [new Arg($contextVar)]), + [ + 'stmts' => [$lazyBranch], + 'else' => new Stmt\Else_($eagerBranch), + ] + ); + + return [$outputVar, [$if]]; + } + + private function createMapItemClosure(Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source, Expr\Variable $contextVar): Expr\Closure + { + $itemVar = new Expr\Variable($uniqueVariableScope->getUniqueName('item')); + $keyVar = new Expr\Variable($uniqueVariableScope->getUniqueName('itemKey')); + + [$itemOutput, $itemStatements] = $this->itemTransformer->transform($itemVar, $target, $propertyMapping, $uniqueVariableScope, $source); + + /** @var class-string $closureUseClass */ + $closureUseClass = class_exists(ClosureUse::class) ? ClosureUse::class : Arg::class; + + $uses = [new $closureUseClass($contextVar)]; + + // The per-item transformation may reference the source root (e.g. MapFrom expressions). + if ($source instanceof Expr\Variable) { + $uses[] = new $closureUseClass($source); + } + + return new Expr\Closure([ + 'params' => [new Param($itemVar), new Param($keyVar)], + 'uses' => $uses, + 'stmts' => [...$itemStatements, new Stmt\Return_($itemOutput)], + ]); + } + + public function getCheckExpression(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source): ?Expr + { + if ($this->eagerTransformer instanceof CheckTypeInterface) { + return $this->eagerTransformer->getCheckExpression($input, $target, $propertyMapping, $uniqueVariableScope, $source); + } + + return null; + } + + public function getDependencies(): array + { + return $this->eagerTransformer->getDependencies(); + } + + public function __toString(): string + { + return \sprintf('%s<%s>', self::class, (string) $this->eagerTransformer); + } +} diff --git a/src/Transformer/MapperDependency.php b/src/Transformer/MapperDependency.php index 3b9a1378..90e51d58 100644 --- a/src/Transformer/MapperDependency.php +++ b/src/Transformer/MapperDependency.php @@ -14,8 +14,8 @@ final readonly class MapperDependency { /** - * @param class-string|'array' $source - * @param class-string|'array' $target + * @param class-string|'array' $source + * @param class-string|'array'|'json' $target */ public function __construct( public string $name, diff --git a/tests/AutoMapperTest/LazyCollectionMapping/expected.eager.data b/tests/AutoMapperTest/LazyCollectionMapping/expected.eager.data new file mode 100644 index 00000000..bc7cd532 --- /dev/null +++ b/tests/AutoMapperTest/LazyCollectionMapping/expected.eager.data @@ -0,0 +1,15 @@ +[ + "items" => [ + [ + "name" => "a" + ] + [ + "name" => "b" + ] + ] + "tags" => [ + "x" + "y" + "z" + ] +] diff --git a/tests/AutoMapperTest/LazyCollectionMapping/expected.lazy.data b/tests/AutoMapperTest/LazyCollectionMapping/expected.lazy.data new file mode 100644 index 00000000..bc7cd532 --- /dev/null +++ b/tests/AutoMapperTest/LazyCollectionMapping/expected.lazy.data @@ -0,0 +1,15 @@ +[ + "items" => [ + [ + "name" => "a" + ] + [ + "name" => "b" + ] + ] + "tags" => [ + "x" + "y" + "z" + ] +] diff --git a/tests/AutoMapperTest/LazyCollectionMapping/map.php b/tests/AutoMapperTest/LazyCollectionMapping/map.php new file mode 100644 index 00000000..b694c55a --- /dev/null +++ b/tests/AutoMapperTest/LazyCollectionMapping/map.php @@ -0,0 +1,42 @@ + */ + public iterable $tags = []; +} + +return (static function () { + $autoMapper = AutoMapperBuilder::buildAutoMapper(); + + $bag = new Bag(); + $bag->items = [new Item('a'), new Item('b')]; + $bag->tags = ['x', 'y', 'z']; + + // Eager mapping to array: nested collections are materialized. + yield 'eager' => $autoMapper->map($bag, 'array'); + + // Lazy mapping produces a LazyMap holding LazyCollection nodes; resolving it (both are + // JsonSerializable) must yield exactly the same structure as the eager mapping. + $lazy = $autoMapper->map($bag, 'array', [MapperContext::LAZY_MAPPING => true]); + + yield 'lazy' => json_decode(json_encode($lazy), true); +})(); diff --git a/tests/JsonStreamer/JsonStreamReaderTest.php b/tests/JsonStreamer/JsonStreamReaderTest.php new file mode 100644 index 00000000..c51b2030 --- /dev/null +++ b/tests/JsonStreamer/JsonStreamReaderTest.php @@ -0,0 +1,181 @@ +read($this->stream($json), Type::object(Fixtures\User::class)); + + self::assertInstanceOf(Fixtures\User::class, $user); + self::assertSame(1, $user->getId()); + self::assertSame('yolo', $user->name); + self::assertInstanceOf(Fixtures\Address::class, $user->address); + self::assertCount(1, $user->addresses); + self::assertInstanceOf(Fixtures\Address::class, $user->addresses[0]); + } + + public function testReadCollectionOfObjectsIsLazy(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper( + mapPrivatePropertiesAndMethod: true, + classPrefix: 'JsonStreamReaderPrivate_', + ); + + $reader = new JsonStreamReader($autoMapper, FallbackJsonStreamReader::create()); + + $json = '[{"id":1,"name":"a","age":"10"},{"id":2,"name":"b","age":"20"},{"id":3,"name":"c","age":"30"}]'; + + $users = $reader->read($this->stream($json), Type::list(Type::object(Fixtures\User::class))); + + self::assertInstanceOf(\Traversable::class, $users, 'A collection of objects is streamed lazily.'); + + $result = []; + foreach ($users as $key => $user) { + self::assertInstanceOf(Fixtures\User::class, $user); + $result[$key] = $user->name; + } + + self::assertSame([0 => 'a', 1 => 'b', 2 => 'c'], $result); + } + + public function testCollectionIsBufferedAndReiterable(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper( + mapPrivatePropertiesAndMethod: true, + classPrefix: 'JsonStreamReaderPrivate_', + ); + + $reader = new JsonStreamReader($autoMapper, FallbackJsonStreamReader::create()); + + $json = '[{"id":1,"name":"a","age":"10"},{"id":2,"name":"b","age":"20"}]'; + + $users = $reader->read($this->stream($json), Type::list(Type::object(Fixtures\User::class))); + + self::assertInstanceOf(\Countable::class, $users); + self::assertCount(2, $users); + + $firstPass = iterator_to_array($users); + $secondPass = iterator_to_array($users); + + self::assertSame(['a', 'b'], array_map(static fn (Fixtures\User $u) => $u->name, $firstPass)); + // Buffered: the same mapped instances are replayed, not re-mapped. + self::assertSame($firstPass[0], $secondPass[0]); + self::assertSame($firstPass[1], $secondPass[1]); + } + + public function testStreamOptionDisablesBufferingAndIsSinglePass(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper( + mapPrivatePropertiesAndMethod: true, + classPrefix: 'JsonStreamReaderPrivate_', + ); + + $reader = new JsonStreamReader($autoMapper, FallbackJsonStreamReader::create()); + + $json = '[{"id":1,"name":"a","age":"10"},{"id":2,"name":"b","age":"20"}]'; + + $users = $reader->read( + $this->stream($json), + Type::list(Type::object(Fixtures\User::class)), + [MapperContext::STREAM => true], + ); + + $names = []; + foreach ($users as $user) { + $names[] = $user->name; + } + self::assertSame(['a', 'b'], $names); + + // Second iteration must fail: the stream has already been consumed. + $this->expectException(\LogicException::class); + iterator_to_array($users); + } + + public function testReadDictOfObjectsPreservesKeys(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper( + mapPrivatePropertiesAndMethod: true, + classPrefix: 'JsonStreamReaderPrivate_', + ); + + $reader = new JsonStreamReader($autoMapper, FallbackJsonStreamReader::create()); + + $json = '{"first":{"id":1,"name":"a","age":"10"},"second":{"id":2,"name":"b","age":"20"}}'; + + $users = $reader->read( + $this->stream($json), + Type::dict(Type::object(Fixtures\User::class)), + ); + + $result = []; + foreach ($users as $key => $user) { + self::assertInstanceOf(Fixtures\User::class, $user); + $result[$key] = $user->getId(); + } + + self::assertSame(['first' => 1, 'second' => 2], $result); + } + + public function testRoundTripWithWriter(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper( + mapPrivatePropertiesAndMethod: true, + classPrefix: 'JsonStreamReaderPrivate_', + ); + + $reader = new JsonStreamReader($autoMapper, FallbackJsonStreamReader::create()); + $writer = new \AutoMapper\JsonStreamer\JsonStreamWriter( + $autoMapper, + \Symfony\Component\JsonStreamer\JsonStreamWriter::create(), + ); + + $address = new Fixtures\Address(); + $address->setCity('Toulon'); + $user = new Fixtures\User(1, 'yolo', '13'); + $user->address = $address; + $user->addresses[] = $address; + $user->money = 20.1; + + $json = (string) $writer->write($user, Type::object(Fixtures\User::class)); + + $user2 = $reader->read($this->stream($json), Type::object(Fixtures\User::class)); + + self::assertEquals($user, $user2); + } +} diff --git a/tests/JsonStreamer/JsonStreamWriterTest.php b/tests/JsonStreamer/JsonStreamWriterTest.php new file mode 100644 index 00000000..dbc249fa --- /dev/null +++ b/tests/JsonStreamer/JsonStreamWriterTest.php @@ -0,0 +1,103 @@ +setUpVarDumper( + [ + \Throwable::class => static function (\Throwable $e) { + return [ + 'class' => $e::class, + 'message' => $e->getMessage(), + ]; + }, + ], + CliDumper::DUMP_LIGHT_ARRAY, + ); + } + + public function testJsonStreamerMap(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper( + mapPrivatePropertiesAndMethod: true, + classPrefix: 'JsonStreamerPrivate_' + ); + + $address = new Fixtures\Address(); + $address->setCity('Toulon'); + $user = new Fixtures\User(1, 'yolo', '13'); + $user->address = $address; + $user->addresses[] = $address; + $user->money = 20.1; + + $jsonStreamWriter = new JsonStreamWriter( + $autoMapper, + FallbackJsonStreamWriter::create(), + ); + + $json = $jsonStreamWriter->write( + $user, + Type::object(Fixtures\User::class), + ); + + $data = json_decode((string) $json, true); + $user2 = $autoMapper->map($data, Fixtures\User::class); + + $this->assertEquals($user, $user2); + } + + public function testGetJsonMapperStreamsThroughMapMethod(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper( + mapPrivatePropertiesAndMethod: true, + classPrefix: 'JsonStreamerPrivate_' + ); + + $address = new Fixtures\Address(); + $address->setCity('Toulon'); + $user = new Fixtures\User(1, 'yolo', '13'); + $user->address = $address; + $user->addresses[] = $address; + $user->money = 20.1; + + // The `json` mapper exposes the stream straight through map(). + $stream = $autoMapper->getMapper(Fixtures\User::class, 'json')->map($user); + + self::assertInstanceOf(\Traversable::class, $stream, 'A json mapper streams its result.'); + + $json = ''; + foreach ($stream as $chunk) { + self::assertIsString($chunk); + $json .= $chunk; + } + + $data = json_decode($json, true, flags: \JSON_THROW_ON_ERROR); + self::assertSame('Toulon', $data['address']['city']); + self::assertCount(1, $data['addresses']); + + // And it round-trips back to the original object. + $user2 = $autoMapper->map($data, Fixtures\User::class); + self::assertEquals($user, $user2); + } +} diff --git a/tests/ObjectMapper/ObjectMapperTest.php b/tests/ObjectMapper/ObjectMapperTest.php index c63ad698..505bf3fe 100644 --- a/tests/ObjectMapper/ObjectMapperTest.php +++ b/tests/ObjectMapper/ObjectMapperTest.php @@ -319,8 +319,8 @@ public function testTransformToWrongValueType(): void $u = new \stdClass(); $u->foo = 'bar'; - $metadata = $this->createStub(ObjectMapperMetadataFactoryInterface::class); - $metadata->method('create')->with($u)->willReturn([new Mapping(target: \stdClass::class, transform: static fn () => 'str')]); + $metadata = $this->createMock(ObjectMapperMetadataFactoryInterface::class); + $metadata->expects($this->once())->method('create')->with($u)->willReturn([new Mapping(target: \stdClass::class, transform: static fn () => 'str')]); $mapper = new ObjectMapper(metadataFactory: $metadata); $mapper->map($u); } @@ -333,8 +333,8 @@ public function testTransformToWrongObject(): void $u = new \stdClass(); $u->foo = 'bar'; - $metadata = $this->createStub(ObjectMapperMetadataFactoryInterface::class); - $metadata->method('create')->with($u)->willReturn([new Mapping(target: ClassWithoutTarget::class, transform: static fn () => new \stdClass())]); + $metadata = $this->createMock(ObjectMapperMetadataFactoryInterface::class); + $metadata->expects($this->once())->method('create')->with($u)->willReturn([new Mapping(target: ClassWithoutTarget::class, transform: static fn () => new \stdClass())]); $mapper = new ObjectMapper(metadataFactory: $metadata); $mapper->map($u); } diff --git a/tests/Transformer/ArrayTransformerFactoryTest.php b/tests/Transformer/ArrayTransformerFactoryTest.php index f5e41cf5..1c15b48a 100644 --- a/tests/Transformer/ArrayTransformerFactoryTest.php +++ b/tests/Transformer/ArrayTransformerFactoryTest.php @@ -19,11 +19,11 @@ class ArrayTransformerFactoryTest extends TestCase public function testGetTransformer(): void { $factory = new ArrayTransformerFactory(); - $chainFactory = $this->getMockBuilder(ChainTransformerFactory::class)->disableOriginalConstructor()->getMock(); - $chainFactory->expects($this->any())->method('getTransformer')->willReturn(new CopyTransformer()); + $chainFactory = $this->createMock(ChainTransformerFactory::class); + $chainFactory->expects($this->once())->method('getTransformer')->willReturn(new CopyTransformer()); $factory->setChainTransformerFactory($chainFactory); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::array(key: Type::int())); $targetMapperMetadata = new TargetPropertyMetadata('foo', type: Type::array()); @@ -37,7 +37,7 @@ public function testNoTransformerTargetNoCollection(): void $chainFactory = new ChainTransformerFactory(); $factory = new ArrayTransformerFactory(); $factory->setChainTransformerFactory($chainFactory); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::array()); $targetMapperMetadata = new TargetPropertyMetadata('foo', type: Type::string()); @@ -51,7 +51,7 @@ public function testNoTransformerSourceNoCollection(): void $chainFactory = new ChainTransformerFactory(); $factory = new ArrayTransformerFactory(); $factory->setChainTransformerFactory($chainFactory); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::string()); $targetMapperMetadata = new TargetPropertyMetadata('foo', type: Type::array()); @@ -65,7 +65,7 @@ public function testNoTransformerIfNoSubTypeTransformerNoCollection(): void $chainFactory = new ChainTransformerFactory(); $factory = new ArrayTransformerFactory(); $factory->setChainTransformerFactory($chainFactory); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::array(key: Type::string())); $targetMapperMetadata = new TargetPropertyMetadata('foo', type: Type::array(key: Type::string())); diff --git a/tests/Transformer/BuiltinTransformerFactoryTest.php b/tests/Transformer/BuiltinTransformerFactoryTest.php index c82c8033..2d3e9641 100644 --- a/tests/Transformer/BuiltinTransformerFactoryTest.php +++ b/tests/Transformer/BuiltinTransformerFactoryTest.php @@ -17,7 +17,7 @@ class BuiltinTransformerFactoryTest extends TestCase public function testGetTransformer(): void { $factory = new BuiltinTransformerFactory(); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::string()); $targetMapperMetadata = new TargetPropertyMetadata('foo', type: Type::string()); @@ -35,7 +35,7 @@ public function testGetTransformer(): void public function testNoTransformer(): void { $factory = new BuiltinTransformerFactory(); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::array()); $targetMapperMetadata = new TargetPropertyMetadata('foo', type: Type::string()); diff --git a/tests/Transformer/ChainTransformerFactoryTest.php b/tests/Transformer/ChainTransformerFactoryTest.php index f6240468..37b2fcf6 100644 --- a/tests/Transformer/ChainTransformerFactoryTest.php +++ b/tests/Transformer/ChainTransformerFactoryTest.php @@ -17,13 +17,13 @@ class ChainTransformerFactoryTest extends TestCase public function testGetTransformer(): void { $transformer = new CopyTransformer(); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $subTransformer = $this ->getMockBuilder(TransformerFactoryInterface::class) ->getMock() ; - $subTransformer->expects($this->any())->method('getTransformer')->willReturn($transformer); + $subTransformer->expects($this->once())->method('getTransformer')->willReturn($transformer); $chainTransformerFactory = new ChainTransformerFactory([$subTransformer]); @@ -36,13 +36,13 @@ public function testGetTransformer(): void public function testNoTransformer(): void { - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $subTransformer = $this ->getMockBuilder(TransformerFactoryInterface::class) ->getMock() ; - $subTransformer->expects($this->any())->method('getTransformer')->willReturn(null); + $subTransformer->expects($this->once())->method('getTransformer')->willReturn(null); $chainTransformerFactory = new ChainTransformerFactory([$subTransformer]); $sourceMapperMetadata = new SourcePropertyMetadata('foo'); diff --git a/tests/Transformer/DateTimeTransformerFactoryTest.php b/tests/Transformer/DateTimeTransformerFactoryTest.php index 35488d05..3fbf6e4a 100644 --- a/tests/Transformer/DateTimeTransformerFactoryTest.php +++ b/tests/Transformer/DateTimeTransformerFactoryTest.php @@ -20,7 +20,7 @@ class DateTimeTransformerFactoryTest extends TestCase public function testGetTransformer(): void { $factory = new DateTimeTransformerFactory(); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::object(\DateTime::class)); $targetMapperMetadata = new TargetPropertyMetadata('foo', type: Type::object(\DateTime::class)); @@ -47,7 +47,7 @@ public function testGetTransformer(): void public function testGetTransformerImmutable(): void { $factory = new DateTimeTransformerFactory(); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::object(\DateTimeImmutable::class)); $targetMapperMetadata = new TargetPropertyMetadata('foo', type: Type::object(\DateTime::class)); @@ -60,7 +60,7 @@ public function testGetTransformerImmutable(): void public function testGetTransformerMutable(): void { $factory = new DateTimeTransformerFactory(); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::object(\DateTime::class)); $targetMapperMetadata = new TargetPropertyMetadata('foo', type: Type::object(\DateTimeImmutable::class)); @@ -73,7 +73,7 @@ public function testGetTransformerMutable(): void public function testNoTransformer(): void { $factory = new DateTimeTransformerFactory(); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::string()); $targetMapperMetadata = new TargetPropertyMetadata('foo', type: Type::string()); diff --git a/tests/Transformer/MultipleTransformerFactoryTest.php b/tests/Transformer/MultipleTransformerFactoryTest.php index 865c297d..5f94d4d3 100644 --- a/tests/Transformer/MultipleTransformerFactoryTest.php +++ b/tests/Transformer/MultipleTransformerFactoryTest.php @@ -22,7 +22,7 @@ public function testGetTransformer(): void $factory = new MultipleTransformerFactory(); $factory->setChainTransformerFactory($chainFactory); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::union(Type::string(), Type::int())); $targetMapperMetadata = new TargetPropertyMetadata('foo'); @@ -47,7 +47,7 @@ public function testNoTransformerIfNoSubTransformer(): void $factory = new MultipleTransformerFactory(); $factory->setChainTransformerFactory($chainFactory); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::union(Type::string(), Type::int())); $targetMapperMetadata = new TargetPropertyMetadata('foo'); @@ -62,7 +62,7 @@ public function testNoTransformer(): void $factory = new MultipleTransformerFactory(); $factory->setChainTransformerFactory($chainFactory); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo'); $targetMapperMetadata = new TargetPropertyMetadata('foo'); diff --git a/tests/Transformer/NullableTransformerFactoryTest.php b/tests/Transformer/NullableTransformerFactoryTest.php index 295c1ec6..7515b29a 100644 --- a/tests/Transformer/NullableTransformerFactoryTest.php +++ b/tests/Transformer/NullableTransformerFactoryTest.php @@ -29,7 +29,7 @@ public function testGetTransformer(): void $factory = new NullableTransformerFactory(); $factory->setChainTransformerFactory($chainFactory); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::nullable(Type::string())); $targetMapperMetadata = new TargetPropertyMetadata('foo', type: Type::string()); @@ -70,7 +70,7 @@ public function testNullTransformerIfSourceTypeNotNullable(): void $factory = new NullableTransformerFactory(); $factory->setChainTransformerFactory($chainFactory); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::string()); $targetMapperMetadata = new TargetPropertyMetadata('foo', type: Type::string()); @@ -85,7 +85,7 @@ public function testNullTransformerIfMultipleSource(): void $factory = new NullableTransformerFactory(); $factory->setChainTransformerFactory($chainFactory); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::union(Type::nullable(Type::string()), Type::string())); $targetMapperMetadata = new TargetPropertyMetadata('foo', type: Type::string()); diff --git a/tests/Transformer/ObjectTransformerFactoryTest.php b/tests/Transformer/ObjectTransformerFactoryTest.php index f7532ac5..3c8cd7f9 100644 --- a/tests/Transformer/ObjectTransformerFactoryTest.php +++ b/tests/Transformer/ObjectTransformerFactoryTest.php @@ -16,7 +16,7 @@ class ObjectTransformerFactoryTest extends TestCase { public function testGetTransformer(): void { - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $factory = new ObjectTransformerFactory(); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::object(\stdClass::class)); @@ -43,7 +43,7 @@ public function testGetTransformer(): void public function testNoTransformer(): void { - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $factory = new ObjectTransformerFactory(); $sourceMapperMetadata = new SourcePropertyMetadata('foo'); diff --git a/tests/Transformer/SymfonyUidTransformerFactoryTest.php b/tests/Transformer/SymfonyUidTransformerFactoryTest.php index 5d3ae0d8..078aaca0 100644 --- a/tests/Transformer/SymfonyUidTransformerFactoryTest.php +++ b/tests/Transformer/SymfonyUidTransformerFactoryTest.php @@ -18,7 +18,7 @@ class SymfonyUidTransformerFactoryTest extends TestCase public function testNoTransformer(): void { $factory = new SymfonyUidTransformerFactory(); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::object()); $targetMapperMetadata = new TargetPropertyMetadata('foo', type: Type::object()); @@ -30,7 +30,7 @@ public function testNoTransformer(): void public function testGetUlidCopyTransformer(): void { $factory = new SymfonyUidTransformerFactory(); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::object(Ulid::class)); $targetMapperMetadata = new TargetPropertyMetadata('foo', type: Type::object(Ulid::class)); diff --git a/tests/Transformer/UniqueTypeTransformerFactoryTest.php b/tests/Transformer/UniqueTypeTransformerFactoryTest.php index 9a3e2b1d..6f1f0d3c 100644 --- a/tests/Transformer/UniqueTypeTransformerFactoryTest.php +++ b/tests/Transformer/UniqueTypeTransformerFactoryTest.php @@ -22,7 +22,7 @@ public function testGetTransformer(): void $factory = new UniqueTypeTransformerFactory(); $factory->setChainTransformerFactory($chainFactory); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo', type: Type::string()); $targetMapperMetadata = new TargetPropertyMetadata('foo', type: Type::union(Type::string(), Type::string())); @@ -38,7 +38,7 @@ public function testNullTransformer(): void $factory = new UniqueTypeTransformerFactory(); $factory->setChainTransformerFactory($chainFactory); - $mapperMetadata = $this->getMockBuilder(MapperMetadata::class)->disableOriginalConstructor()->getMock(); + $mapperMetadata = $this->createStub(MapperMetadata::class); $sourceMapperMetadata = new SourcePropertyMetadata('foo'); $targetMapperMetadata = new TargetPropertyMetadata('foo');