From 4d85d8af7284221dbaddc95c920531c792112db8 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Tue, 21 Jul 2026 18:08:47 +0200 Subject: [PATCH 01/13] initial json streamer support --- composer.json | 3 +- src/JsonStreamer/JsonStreamReader.php | 127 ++++++++++++ src/JsonStreamer/JsonStreamWriter.php | 131 +++++++++++++ src/Lazy/LazyCollection.php | 147 +++++++++----- src/MapperContext.php | 15 ++ src/Transformer/ArrayTransformerFactory.php | 39 +++- src/Transformer/LazyCollectionTransformer.php | 134 +++++++++++++ .../LazyCollectionMapping/expected.eager.data | 15 ++ .../LazyCollectionMapping/expected.lazy.data | 15 ++ .../LazyCollectionMapping/map.php | 42 ++++ tests/JsonStreamer/JsonStreamReaderTest.php | 181 ++++++++++++++++++ tests/JsonStreamer/JsonStreamWriterTest.php | 69 +++++++ tests/ObjectMapper/ObjectMapperTest.php | 8 +- .../ArrayTransformerFactoryTest.php | 12 +- .../BuiltinTransformerFactoryTest.php | 4 +- .../ChainTransformerFactoryTest.php | 8 +- .../DateTimeTransformerFactoryTest.php | 8 +- .../MultipleTransformerFactoryTest.php | 6 +- .../NullableTransformerFactoryTest.php | 6 +- .../ObjectTransformerFactoryTest.php | 4 +- .../SymfonyUidTransformerFactoryTest.php | 4 +- .../UniqueTypeTransformerFactoryTest.php | 4 +- 22 files changed, 897 insertions(+), 85 deletions(-) create mode 100644 src/JsonStreamer/JsonStreamReader.php create mode 100644 src/JsonStreamer/JsonStreamWriter.php create mode 100644 src/Transformer/LazyCollectionTransformer.php create mode 100644 tests/AutoMapperTest/LazyCollectionMapping/expected.eager.data create mode 100644 tests/AutoMapperTest/LazyCollectionMapping/expected.lazy.data create mode 100644 tests/AutoMapperTest/LazyCollectionMapping/map.php create mode 100644 tests/JsonStreamer/JsonStreamReaderTest.php create mode 100644 tests/JsonStreamer/JsonStreamWriterTest.php 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/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..d258bc62 --- /dev/null +++ b/src/JsonStreamer/JsonStreamWriter.php @@ -0,0 +1,131 @@ + + * + * @phpstan-import-type MapperContextArray from 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 { + if ( + $type instanceof ObjectType + && \is_object($data) + && $type->getClassName() === $data::class + ) { + $normalizedLazy = $this->mapper->map($data, 'array', [ + ...$options, + 'lazy_mapping' => true, + ]); + + $chunks = $this->dataToChunks($normalizedLazy); + + return new /** + * @implements \IteratorAggregate + */ class($chunks, ) implements \IteratorAggregate, \Stringable { + /** + * @param \Traversable $chunks + */ + public function __construct( + private \Traversable $chunks, + ) { + } + + public function getIterator(): \Traversable + { + return $this->chunks; + } + + public function __toString(): string + { + $string = ''; + foreach ($this->chunks as $chunk) { + $string .= $chunk; + } + + return $string; + } + }; + } + + return $this->fallbackStreamWriter->write($data, $type, $options); + } + + private function dataToChunks(mixed $data): \Generator + { + if ($data instanceof LazyMap) { + yield from $this->lazyMapToChunks($data); + } elseif ($data instanceof LazyCollection) { + yield from $this->lazyCollectionToChunks($data); + } else { + yield json_encode($data); + } + } + + private function lazyMapToChunks(LazyMap $map): \Generator + { + yield '{'; + + $values = iterator_to_array($map); + $count = \count($values); + $current = 0; + + foreach ($values as $key => $value) { + yield '"' . $key . '":'; + yield from $this->dataToChunks($value); + + ++$current; + if ($current < $count) { + yield ','; + } + } + + yield '}'; + } + + /** + * @param LazyCollection> $collection + */ + private function lazyCollectionToChunks( + LazyCollection $collection, + ): \Generator { + yield '['; + + $first = true; + + foreach ($collection as $value) { + if (!$first) { + yield ','; + } + + yield from $this->dataToChunks($value); + + $first = false; + } + + yield ']'; + } +} 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/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/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..ff461879 --- /dev/null +++ b/src/Transformer/LazyCollectionTransformer.php @@ -0,0 +1,134 @@ + + * + * @internal + */ +final readonly class LazyCollectionTransformer implements TransformerInterface, DependentTransformerInterface, CheckTypeInterface, \Stringable +{ + public function __construct( + private AbstractArrayTransformer $eagerTransformer, + private TransformerInterface $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/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..eb135019 --- /dev/null +++ b/tests/JsonStreamer/JsonStreamWriterTest.php @@ -0,0 +1,69 @@ +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); + } +} 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'); From fa2ee980baed5ec4df9a84ed25daa6ba30341baa Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Wed, 22 Jul 2026 00:30:34 +0200 Subject: [PATCH 02/13] wip better json streaming --- bench/.gitignore | 3 + bench/README.md | 336 ++- bench/bin/verify.php | 56 + bench/bootstrap.php | 5 + bench/composer.json | 32 +- bench/composer.lock | 2676 ++++++++++++++--- bench/phpbench.json | 18 + bench/src/CollectionBench.php | 185 ++ bench/src/DenormalizeBench.php | 75 + bench/src/DeserializeBench.php | 115 + bench/src/Factory/MapperFactory.php | 235 ++ bench/src/Factory/PayloadFactory.php | 200 ++ bench/src/ManualMapper.php | 93 + bench/src/Model/Address.php | 18 + bench/src/Model/Person.php | 30 + bench/src/NormalizeBench.php | 57 + bench/src/ObjectMapping/AddressSource.php | 16 + bench/src/ObjectMapping/AddressTarget.php | 13 + bench/src/ObjectMapping/PersonSource.php | 34 + bench/src/ObjectMapping/PersonTarget.php | 20 + bench/src/ObjectToObjectBench.php | 83 + bench/src/SerializeBench.php | 96 + bench/src/Verifier.php | 264 ++ bench/src/WriteCollectionBench.php | 113 + bench/src/WriteWideObjectBench.php | 141 + .../JsonStreamMethodStatementsGenerator.php | 221 ++ src/Generator/MapperGenerator.php | 34 + src/JsonStreamer/JsonStreamWriter.php | 184 +- src/LazyMapper.php | 14 + src/Transformer/AbstractArrayTransformer.php | 5 + src/Transformer/LazyCollectionTransformer.php | 10 + 31 files changed, 4922 insertions(+), 460 deletions(-) create mode 100644 bench/.gitignore create mode 100644 bench/bin/verify.php create mode 100644 bench/bootstrap.php create mode 100644 bench/phpbench.json create mode 100644 bench/src/CollectionBench.php create mode 100644 bench/src/DenormalizeBench.php create mode 100644 bench/src/DeserializeBench.php create mode 100644 bench/src/Factory/MapperFactory.php create mode 100644 bench/src/Factory/PayloadFactory.php create mode 100644 bench/src/ManualMapper.php create mode 100644 bench/src/Model/Address.php create mode 100644 bench/src/Model/Person.php create mode 100644 bench/src/NormalizeBench.php create mode 100644 bench/src/ObjectMapping/AddressSource.php create mode 100644 bench/src/ObjectMapping/AddressTarget.php create mode 100644 bench/src/ObjectMapping/PersonSource.php create mode 100644 bench/src/ObjectMapping/PersonTarget.php create mode 100644 bench/src/ObjectToObjectBench.php create mode 100644 bench/src/SerializeBench.php create mode 100644 bench/src/Verifier.php create mode 100644 bench/src/WriteCollectionBench.php create mode 100644 bench/src/WriteWideObjectBench.php create mode 100644 src/Generator/JsonStreamMethodStatementsGenerator.php 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/src/Generator/JsonStreamMethodStatementsGenerator.php b/src/Generator/JsonStreamMethodStatementsGenerator.php new file mode 100644 index 00000000..2f49e1ed --- /dev/null +++ b/src/Generator/JsonStreamMethodStatementsGenerator.php @@ -0,0 +1,221 @@ +variableRegistry; + $sepVar = new Expr\Variable('sep'); + + $statements = [ + // A scratch array so transformers that reference the target's existing + // value ($result[...]) keep working; it is never returned. + 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($sepVar, new Scalar\String_(''))), + ]; + + foreach ($metadata->propertiesMetadata as $propertyMetadata) { + if ($propertyMetadata->ignored) { + continue; + } + + $fieldValueExpr = $propertyMetadata->source->accessor?->getExpression($variableRegistry->getSourceInput()); + + if (null === $fieldValueExpr) { + if (!$propertyMetadata->transformer instanceof AllowNullValueTransformerInterface) { + continue; + } + + $fieldValueExpr = new Expr\ConstFetch(new Name('null')); + } + + $keyPrefix = new Expr\BinaryOp\Concat( + $sepVar, + new Scalar\String_('"' . $this->escapeKey($propertyMetadata->target->property) . '":'), + ); + + $propStatements = $this->propertyStatements($metadata, $propertyMetadata, $fieldValueExpr, $keyPrefix, $sepVar); + + $condition = $this->propertyConditionsGenerator->generate($metadata, $propertyMetadata); + + if ($condition) { + $propStatements = [new Stmt\If_($condition, ['stmts' => $propStatements])]; + } + + $statements = [...$statements, ...$propStatements]; + } + + $statements[] = new Stmt\Expression(new Expr\Yield_(new Scalar\String_('}'))); + + return $statements; + } + + /** + * @return Stmt[] + */ + private function propertyStatements( + GeneratorMetadata $metadata, + PropertyMetadata $propertyMetadata, + Expr $fieldValueExpr, + Expr $keyPrefix, + Expr\Variable $sepVar, + ): array { + $transformer = $propertyMetadata->transformer; + $variableRegistry = $metadata->variableRegistry; + $advanceSep = new Stmt\Expression(new Expr\Assign($sepVar, new Scalar\String_(','))); + + // A nested object mapped by a sub-mapper: stream it via the sub-mapper's own + // mapToJsonStream() so nested collections stream too. + if ($transformer instanceof ObjectTransformer + && ($dependencyName = $this->streamableDependency($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($dependencyName, $valueVar, $variableRegistry)]), + ]), + ]; + } + + // A list of objects mapped by a sub-mapper: stream `[` + each element + `]`. + if (($itemTransformer = $this->objectListItemTransformer($transformer)) !== null + && ($dependencyName = $this->streamableDependency($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($dependencyName, $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[] = $advanceSep; + + return $propStatements; + } + + private function yieldFromMapper(string $dependencyName, 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_($dependencyName), + ), + 'mapToJsonStream', + [ + 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 sub-mapper key when it is an object → array mapper (so it has a + * mapToJsonStream() method), or null otherwise. + */ + private function streamableDependency(ObjectTransformer $transformer): ?string + { + foreach ($transformer->getDependencies() as $dependency) { + if ('array' !== $dependency->source && 'array' === $dependency->target) { + return $dependency->name; + } + } + + return null; + } + + 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..b10f245b 100644 --- a/src/Generator/MapperGenerator.php +++ b/src/Generator/MapperGenerator.php @@ -35,6 +35,7 @@ private MapperConstructorGenerator $mapperConstructorGenerator; private InjectMapperMethodStatementsGenerator $injectMapperMethodStatementsGenerator; private MapMethodStatementsGenerator $mapMethodStatementsGenerator; + private JsonStreamMethodStatementsGenerator $jsonStreamMethodStatementsGenerator; private IdentifierHashGenerator $identifierHashGenerator; private bool $disableGeneratedMapper; @@ -54,6 +55,10 @@ public function __construct( $expressionLanguage, ); + $this->jsonStreamMethodStatementsGenerator = new JsonStreamMethodStatementsGenerator( + new PropertyConditionsGenerator($expressionLanguage), + ); + $this->injectMapperMethodStatementsGenerator = new InjectMapperMethodStatementsGenerator(); $this->identifierHashGenerator = new IdentifierHashGenerator(); @@ -96,6 +101,12 @@ public function generate(GeneratorMetadata $metadata): array ->addStmt($this->doMapMethod($metadata, $setterStatements)) ->addStmt($this->registerMappersMethod($metadata)); + // Object → array mappers also get a generator method that streams the JSON + // straight from the source object, reusing the same read/transform pipeline. + if ('array' === $metadata->mapperMetadata->target && 'array' !== $metadata->mapperMetadata->source) { + $builder->addStmt($this->mapToJsonStreamMethod($metadata)); + } + if ($sourceHashMethod = $this->identifierHashGenerator->getSourceHashMethod($metadata)) { $builder->addStmt($sourceHashMethod); } @@ -211,6 +222,29 @@ private function doMapMethod(GeneratorMetadata $metadata, array $setterStatement ->getNode(); } + /** + * Create the mapToJsonStream generator method for this mapper. + * + * ```php + * public function mapToJsonStream($value, array $context = []): iterable { + * yield '{'; + * yield '"id":' . json_encode($value->getId()); + * ... + * yield '}'; + * } + * ``` + */ + private function mapToJsonStreamMethod(GeneratorMetadata $metadata): Stmt\ClassMethod + { + return (new Builder\Method('mapToJsonStream')) + ->makePublic() + ->setReturnType('iterable') + ->addParam(new Param($metadata->variableRegistry->getSourceInput())) + ->addParam(new Param($metadata->variableRegistry->getContext(), default: new Expr\Array_(), type: new Name('array'))) + ->addStmts($this->jsonStreamMethodStatementsGenerator->getStatements($metadata)) + ->getNode(); + } + /** * Create the registerMappers methods for this mapper. * diff --git a/src/JsonStreamer/JsonStreamWriter.php b/src/JsonStreamer/JsonStreamWriter.php index d258bc62..556d312c 100644 --- a/src/JsonStreamer/JsonStreamWriter.php +++ b/src/JsonStreamer/JsonStreamWriter.php @@ -5,17 +5,22 @@ namespace AutoMapper\JsonStreamer; use AutoMapper\AutoMapperInterface; -use AutoMapper\Lazy\LazyCollection; -use AutoMapper\Lazy\LazyMap; -use AutoMapper\MapperContext; +use AutoMapper\AutoMapperRegistryInterface; use Symfony\Component\JsonStreamer\StreamWriterInterface; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\GenericType; +use Symfony\Component\TypeInfo\Type\NullableType; use Symfony\Component\TypeInfo\Type\ObjectType; /** + * Streams an object (or a collection of objects) to JSON by delegating to the + * AutoMapper's generated `mapToJsonStream()` generator, which yields the JSON chunk + * by chunk straight from the source object — no intermediate array or lazy walk. + * * @implements StreamWriterInterface * - * @phpstan-import-type MapperContextArray from MapperContext + * @phpstan-import-type MapperContextArray from \AutoMapper\MapperContext */ final class JsonStreamWriter implements StreamWriterInterface { @@ -31,101 +36,136 @@ public function write( 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->jsonStreamMapper($className) !== null) { + /** @var iterable $data */ + return $this->wrap(fn (): \Generator => $this->collectionChunks($data, $className, $options)); + } + } + if ( - $type instanceof ObjectType + $unwrapped instanceof ObjectType && \is_object($data) - && $type->getClassName() === $data::class + && $unwrapped->getClassName() === $data::class + && ($mapper = $this->jsonStreamMapper($data::class)) !== null ) { - $normalizedLazy = $this->mapper->map($data, 'array', [ - ...$options, - 'lazy_mapping' => true, - ]); - - $chunks = $this->dataToChunks($normalizedLazy); - - return new /** - * @implements \IteratorAggregate - */ class($chunks, ) implements \IteratorAggregate, \Stringable { - /** - * @param \Traversable $chunks - */ - public function __construct( - private \Traversable $chunks, - ) { - } + return $this->wrap(fn (): iterable => $mapper->mapToJsonStream($data, $options)); + } - public function getIterator(): \Traversable - { - return $this->chunks; - } + return $this->fallbackStreamWriter->write($data, $type, $options); + } - public function __toString(): string - { - $string = ''; - foreach ($this->chunks as $chunk) { - $string .= $chunk; - } + /** + * 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 array $options + */ + private function collectionChunks(iterable $data, string $className, array $options): \Generator + { + $mapper = $this->jsonStreamMapper($className); - return $string; - } - }; + yield '['; + $sep = ''; + foreach ($data as $item) { + yield $sep; + if (\is_object($item) && $item::class === $className && $mapper !== null) { + yield from $mapper->mapToJsonStream($item, $options); + } else { + yield json_encode($item) ?: 'null'; + } + $sep = ','; } - - return $this->fallbackStreamWriter->write($data, $type, $options); + yield ']'; } - private function dataToChunks(mixed $data): \Generator + /** + * Return the object → array mapper for the class if it exposes the generated + * `mapToJsonStream()` method, otherwise null. + */ + private function jsonStreamMapper(string $className): ?object { - if ($data instanceof LazyMap) { - yield from $this->lazyMapToChunks($data); - } elseif ($data instanceof LazyCollection) { - yield from $this->lazyCollectionToChunks($data); - } else { - yield json_encode($data); + if (!$this->mapper instanceof AutoMapperRegistryInterface) { + return null; } + + $mapper = $this->mapper->getMapper($className, 'array'); + + return method_exists($mapper, 'mapToJsonStream') ? $mapper : null; } - private function lazyMapToChunks(LazyMap $map): \Generator + /** + * 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 + */ + private function wrap(\Closure $factory): \Traversable&\Stringable { - yield '{'; + return new /** + * @implements \IteratorAggregate + */ class($factory) implements \IteratorAggregate, \Stringable { + /** + * @param \Closure(): iterable $factory + */ + public function __construct( + private \Closure $factory, + ) { + } - $values = iterator_to_array($map); - $count = \count($values); - $current = 0; + public function getIterator(): \Traversable + { + yield from ($this->factory)(); + } - foreach ($values as $key => $value) { - yield '"' . $key . '":'; - yield from $this->dataToChunks($value); + public function __toString(): string + { + $json = ''; + foreach (($this->factory)() as $chunk) { + $json .= $chunk; + } - ++$current; - if ($current < $count) { - yield ','; + return $json; } + }; + } + + private function unwrap(Type $type): Type + { + while ($type instanceof NullableType || $type instanceof GenericType) { + $type = $type->getWrappedType(); } - yield '}'; + return $type; } /** - * @param LazyCollection> $collection + * @return class-string|null */ - private function lazyCollectionToChunks( - LazyCollection $collection, - ): \Generator { - yield '['; - - $first = true; - - foreach ($collection as $value) { - if (!$first) { - yield ','; - } + private function ownedClassName(Type $type): ?string + { + if (!$type instanceof ObjectType) { + return null; + } - yield from $this->dataToChunks($value); + /** @var class-string $className */ + $className = $type->getClassName(); - $first = false; + if ( + is_a($className, \DateTimeInterface::class, true) + || is_a($className, \DateInterval::class, true) + || is_a($className, \DateTimeZone::class, true) + ) { + return null; } - yield ']'; + return $className; } } diff --git a/src/LazyMapper.php b/src/LazyMapper.php index 86a2c278..9fd2ba11 100644 --- a/src/LazyMapper.php +++ b/src/LazyMapper.php @@ -66,6 +66,20 @@ public function &map(mixed $value, array $context = []): mixed return $this->getMapper()->map($value, $context); } + /** + * Delegates to the generated object → array mapper's JSON streaming method. + * + * @param array $context + * + * @return iterable + */ + public function mapToJsonStream(mixed $value, array $context = []): iterable + { + $mapper = $this->getMapper(); + + return method_exists($mapper, 'mapToJsonStream') ? $mapper->mapToJsonStream($value, $context) : []; + } + /** * @return MapperInterface */ 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/LazyCollectionTransformer.php b/src/Transformer/LazyCollectionTransformer.php index ff461879..5904b63c 100644 --- a/src/Transformer/LazyCollectionTransformer.php +++ b/src/Transformer/LazyCollectionTransformer.php @@ -41,6 +41,16 @@ public function __construct( ) { } + 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 From ebb44477611684ec592b3836f7a5ca7f3a11247f Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Wed, 22 Jul 2026 14:28:51 +0200 Subject: [PATCH 03/13] make json a target possibilty by generating mapper that yield json chunks --- src/AutoMapper.php | 4 +- src/AutoMapperRegistryInterface.php | 4 +- .../Doctrine/DoctrineIdentifierListener.php | 11 +- .../Doctrine/DoctrineProviderListener.php | 9 +- .../Symfony/NameConverterListener.php | 4 +- .../Symfony/SerializerGroupListener.php | 13 +- .../Symfony/SerializerIgnoreListener.php | 4 +- .../Symfony/SerializerMaxDepthListener.php | 8 +- src/Extractor/FromSourceMappingExtractor.php | 2 +- src/Extractor/JsonWriteMutator.php | 38 +++++ src/Extractor/MappingExtractor.php | 12 +- .../JsonMapMethodStatementsGenerator.php | 80 ++++++++++ ...hp => JsonPropertyStatementsGenerator.php} | 148 ++++++++++-------- src/Generator/MapperGenerator.php | 110 ++++++++++--- src/JsonStreamer/JsonStreamWriter.php | 46 +++--- src/LazyMapper.php | 16 +- src/Metadata/MapperMetadata.php | 24 +++ src/Metadata/MetadataFactory.php | 9 +- src/Metadata/MetadataRegistry.php | 20 ++- src/Transformer/MapperDependency.php | 4 +- tests/JsonStreamer/JsonStreamWriterTest.php | 34 ++++ 21 files changed, 442 insertions(+), 158 deletions(-) create mode 100644 src/Extractor/JsonWriteMutator.php create mode 100644 src/Generator/JsonMapMethodStatementsGenerator.php rename src/Generator/{JsonStreamMethodStatementsGenerator.php => JsonPropertyStatementsGenerator.php} (57%) 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/JsonStreamMethodStatementsGenerator.php b/src/Generator/JsonPropertyStatementsGenerator.php similarity index 57% rename from src/Generator/JsonStreamMethodStatementsGenerator.php rename to src/Generator/JsonPropertyStatementsGenerator.php index 2f49e1ed..094e43e5 100644 --- a/src/Generator/JsonStreamMethodStatementsGenerator.php +++ b/src/Generator/JsonPropertyStatementsGenerator.php @@ -9,6 +9,7 @@ use AutoMapper\Transformer\AllowNullValueTransformerInterface; use AutoMapper\Transformer\ArrayTransformer; use AutoMapper\Transformer\LazyCollectionTransformer; +use AutoMapper\Transformer\MapperDependency; use AutoMapper\Transformer\ObjectTransformer; use AutoMapper\Transformer\TransformerInterface; use PhpParser\Node\Arg; @@ -18,76 +19,95 @@ use PhpParser\Node\Stmt; /** - * Generates the body of a `mapToJsonStream()` generator method for an object → array - * mapper: instead of building an array and returning it, it yields the JSON string - * chunk by chunk, straight from the source object. + * The `json` counterpart of {@see PropertyStatementsGenerator}: instead of assigning the + * transformed value to the target, it yields the property's JSON, chunk by chunk, straight from + * the source object. * - * It reuses the mapper's read accessors and transformers (so renames, `#[MapTo]`, - * date/enum handling, ... all still apply). For leaf values it `json_encode()`s the - * transformed value; for a nested object or a list of objects it `yield from`s the - * sub-mapper's own `mapToJsonStream()` (found through the transformer's mapper - * dependency), so a large nested collection is streamed element by element instead - * of being materialized and encoded whole. + * It reuses the mapper's read accessors and transformers (so renames, `#[MapTo]`, date/enum + * handling, ... all still apply). For leaf values it `json_encode()`s the transformed value; for + * a nested object or a list of objects it `yield from`s the nested object → json sub-mapper's own + * `map()`, so a large nested collection streams element by element instead of being materialized + * and encoded whole. * * @internal */ -final readonly class JsonStreamMethodStatementsGenerator +final readonly class JsonPropertyStatementsGenerator { public function __construct( private PropertyConditionsGenerator $propertyConditionsGenerator, ) { } + /** + * The variable holding the `,` separator between properties, shared with the framing emitted + * by the mapper's map() method. + */ + public static function separatorVariable(): Expr\Variable + { + return new Expr\Variable('sep'); + } + /** * @return Stmt[] */ - public function getStatements(GeneratorMetadata $metadata): array + public function generate(GeneratorMetadata $metadata, PropertyMetadata $propertyMetadata): array { - $variableRegistry = $metadata->variableRegistry; - $sepVar = new Expr\Variable('sep'); + if ($propertyMetadata->ignored) { + return []; + } - $statements = [ - // A scratch array so transformers that reference the target's existing - // value ($result[...]) keep working; it is never returned. - 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($sepVar, new Scalar\String_(''))), - ]; + $variableRegistry = $metadata->variableRegistry; + $fieldValueExpr = $propertyMetadata->source->accessor?->getExpression($variableRegistry->getSourceInput()); - foreach ($metadata->propertiesMetadata as $propertyMetadata) { - if ($propertyMetadata->ignored) { - continue; + if (null === $fieldValueExpr) { + if (!$propertyMetadata->transformer instanceof AllowNullValueTransformerInterface) { + return []; } - $fieldValueExpr = $propertyMetadata->source->accessor?->getExpression($variableRegistry->getSourceInput()); + $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); - if (null === $fieldValueExpr) { - if (!$propertyMetadata->transformer instanceof AllowNullValueTransformerInterface) { - continue; - } + $condition = $this->propertyConditionsGenerator->generate($metadata, $propertyMetadata); - $fieldValueExpr = new Expr\ConstFetch(new Name('null')); - } + if ($condition) { + return [new Stmt\If_($condition, ['stmts' => $propStatements])]; + } + + return $propStatements; + } - $keyPrefix = new Expr\BinaryOp\Concat( - $sepVar, - new Scalar\String_('"' . $this->escapeKey($propertyMetadata->target->property) . '":'), - ); + /** + * The nested object → json dependencies referenced by this mapper, so they can be injected. + * + * @return MapperDependency[] + */ + public function jsonDependencies(GeneratorMetadata $metadata): array + { + $dependencies = []; - $propStatements = $this->propertyStatements($metadata, $propertyMetadata, $fieldValueExpr, $keyPrefix, $sepVar); + foreach ($metadata->propertiesMetadata as $propertyMetadata) { + if ($propertyMetadata->ignored) { + continue; + } - $condition = $this->propertyConditionsGenerator->generate($metadata, $propertyMetadata); + $transformer = $propertyMetadata->transformer; + $objectTransformer = $transformer instanceof ObjectTransformer ? $transformer : $this->objectListItemTransformer($transformer); - if ($condition) { - $propStatements = [new Stmt\If_($condition, ['stmts' => $propStatements])]; + if ($objectTransformer === null || ($source = $this->streamableSource($objectTransformer)) === null) { + continue; } - $statements = [...$statements, ...$propStatements]; + $dependencies[$source] = new MapperDependency($this->jsonDependencyName($source), $source, 'json'); } - $statements[] = new Stmt\Expression(new Expr\Yield_(new Scalar\String_('}'))); - - return $statements; + return array_values($dependencies); } /** @@ -98,16 +118,14 @@ private function propertyStatements( PropertyMetadata $propertyMetadata, Expr $fieldValueExpr, Expr $keyPrefix, - Expr\Variable $sepVar, ): array { $transformer = $propertyMetadata->transformer; $variableRegistry = $metadata->variableRegistry; - $advanceSep = new Stmt\Expression(new Expr\Assign($sepVar, new Scalar\String_(','))); + $advanceSep = new Stmt\Expression(new Expr\Assign(self::separatorVariable(), new Scalar\String_(','))); - // A nested object mapped by a sub-mapper: stream it via the sub-mapper's own - // mapToJsonStream() so nested collections stream too. - if ($transformer instanceof ObjectTransformer - && ($dependencyName = $this->streamableDependency($transformer)) !== null) { + // 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 [ @@ -116,14 +134,14 @@ private function propertyStatements( $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($dependencyName, $valueVar, $variableRegistry)]), + '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 - && ($dependencyName = $this->streamableDependency($itemTransformer)) !== null) { + && ($source = $this->streamableSource($itemTransformer)) !== null) { $scope = $variableRegistry->getUniqueVariableScope(); $itemVar = new Expr\Variable($scope->getUniqueName('jsonItem')); $itemSepVar = new Expr\Variable($scope->getUniqueName('jsonItemSep')); @@ -139,7 +157,7 @@ private function propertyStatements( 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($dependencyName, $itemVar, $variableRegistry)]), + 'else' => new Stmt\Else_([$this->yieldFromMapper($source, $itemVar, $variableRegistry)]), ]), ], ]), @@ -147,8 +165,8 @@ private function propertyStatements( ]; } - // Leaf value (scalar, date, enum, scalar list, dict, ...): transform then - // json_encode the result in one native call. + // 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(), @@ -161,19 +179,19 @@ private function propertyStatements( $keyPrefix, new Expr\FuncCall(new Name('json_encode'), [new Arg($output)]), ))); - $propStatements[] = $advanceSep; + $propStatements[] = new Stmt\Expression(new Expr\Assign(self::separatorVariable(), new Scalar\String_(','))); return $propStatements; } - private function yieldFromMapper(string $dependencyName, Expr $value, VariableRegistry $variableRegistry): Stmt + 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_($dependencyName), + new Scalar\String_($this->jsonDependencyName($source)), ), - 'mapToJsonStream', + 'map', [ new Arg($value), new Arg($variableRegistry->getContext()), @@ -199,20 +217,28 @@ private function objectListItemTransformer(TransformerInterface $transformer): ? } /** - * The sub-mapper key when it is an object → array mapper (so it has a - * mapToJsonStream() method), or null otherwise. + * 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 streamableDependency(ObjectTransformer $transformer): ?string + private function streamableSource(ObjectTransformer $transformer): ?string { foreach ($transformer->getDependencies() as $dependency) { if ('array' !== $dependency->source && 'array' === $dependency->target) { - return $dependency->name; + /** @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. diff --git a/src/Generator/MapperGenerator.php b/src/Generator/MapperGenerator.php index b10f245b..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,7 +39,7 @@ private MapperConstructorGenerator $mapperConstructorGenerator; private InjectMapperMethodStatementsGenerator $injectMapperMethodStatementsGenerator; private MapMethodStatementsGenerator $mapMethodStatementsGenerator; - private JsonStreamMethodStatementsGenerator $jsonStreamMethodStatementsGenerator; + private JsonMapMethodStatementsGenerator $jsonMapMethodStatementsGenerator; private IdentifierHashGenerator $identifierHashGenerator; private bool $disableGeneratedMapper; @@ -55,8 +59,8 @@ public function __construct( $expressionLanguage, ); - $this->jsonStreamMethodStatementsGenerator = new JsonStreamMethodStatementsGenerator( - new PropertyConditionsGenerator($expressionLanguage), + $this->jsonMapMethodStatementsGenerator = new JsonMapMethodStatementsGenerator( + new JsonPropertyStatementsGenerator(new PropertyConditionsGenerator($expressionLanguage)), ); $this->injectMapperMethodStatementsGenerator = new InjectMapperMethodStatementsGenerator(); @@ -84,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)) @@ -101,12 +111,6 @@ public function generate(GeneratorMetadata $metadata): array ->addStmt($this->doMapMethod($metadata, $setterStatements)) ->addStmt($this->registerMappersMethod($metadata)); - // Object → array mappers also get a generator method that streams the JSON - // straight from the source object, reusing the same read/transform pipeline. - if ('array' === $metadata->mapperMetadata->target && 'array' !== $metadata->mapperMetadata->source) { - $builder->addStmt($this->mapToJsonStreamMethod($metadata)); - } - if ($sourceHashMethod = $this->identifierHashGenerator->getSourceHashMethod($metadata)) { $builder->addStmt($sourceHashMethod); } @@ -223,28 +227,86 @@ private function doMapMethod(GeneratorMetadata $metadata, array $setterStatement } /** - * Create the mapToJsonStream generator method for this mapper. + * Create a mapper whose `map()` streams the source as JSON (target `json`). * - * ```php - * public function mapToJsonStream($value, array $context = []): iterable { - * yield '{'; - * yield '"id":' . json_encode($value->getId()); - * ... - * yield '}'; - * } - * ``` + * 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 mapToJsonStreamMethod(GeneratorMetadata $metadata): Stmt\ClassMethod + private function generateJsonMapper(GeneratorMetadata $metadata): Stmt\Class_ { - return (new Builder\Method('mapToJsonStream')) + 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('iterable') - ->addParam(new Param($metadata->variableRegistry->getSourceInput())) - ->addParam(new Param($metadata->variableRegistry->getContext(), default: new Expr\Array_(), type: new Name('array'))) - ->addStmts($this->jsonStreamMethodStatementsGenerator->getStatements($metadata)) + ->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/JsonStreamWriter.php b/src/JsonStreamer/JsonStreamWriter.php index 556d312c..bd22e595 100644 --- a/src/JsonStreamer/JsonStreamWriter.php +++ b/src/JsonStreamer/JsonStreamWriter.php @@ -6,6 +6,7 @@ use AutoMapper\AutoMapperInterface; use AutoMapper\AutoMapperRegistryInterface; +use AutoMapper\MapperInterface; use Symfony\Component\JsonStreamer\StreamWriterInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; @@ -14,9 +15,9 @@ use Symfony\Component\TypeInfo\Type\ObjectType; /** - * Streams an object (or a collection of objects) to JSON by delegating to the - * AutoMapper's generated `mapToJsonStream()` generator, which yields the JSON chunk - * by chunk straight from the source object — no intermediate array or lazy walk. + * Streams an object (or a collection of objects) to JSON by delegating to the AutoMapper's + * generated `json` mapper, whose `map()` yields the JSON chunk by chunk straight from the source + * object — no intermediate array or lazy walk. * * @implements StreamWriterInterface * @@ -41,7 +42,7 @@ public function write( if ($unwrapped instanceof CollectionType) { $className = $this->ownedClassName($this->unwrap($unwrapped->getCollectionValueType())); - if ($className !== null && is_iterable($data) && $this->jsonStreamMapper($className) !== null) { + if ($className !== null && is_iterable($data) && $this->jsonMapper($className) !== null) { /** @var iterable $data */ return $this->wrap(fn (): \Generator => $this->collectionChunks($data, $className, $options)); } @@ -51,9 +52,9 @@ public function write( $unwrapped instanceof ObjectType && \is_object($data) && $unwrapped->getClassName() === $data::class - && ($mapper = $this->jsonStreamMapper($data::class)) !== null + && ($mapper = $this->jsonMapper($data::class)) !== null ) { - return $this->wrap(fn (): iterable => $mapper->mapToJsonStream($data, $options)); + return $this->wrap(static fn (): iterable => $mapper->map($data, $options) ?? []); } return $this->fallbackStreamWriter->write($data, $type, $options); @@ -63,20 +64,22 @@ public function write( * 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 array $options + * @param iterable $data + * @param class-string $className + * @param MapperContextArray $options + * + * @return \Generator */ private function collectionChunks(iterable $data, string $className, array $options): \Generator { - $mapper = $this->jsonStreamMapper($className); + $mapper = $this->jsonMapper($className); yield '['; $sep = ''; foreach ($data as $item) { yield $sep; if (\is_object($item) && $item::class === $className && $mapper !== null) { - yield from $mapper->mapToJsonStream($item, $options); + yield from $mapper->map($item, $options) ?? []; } else { yield json_encode($item) ?: 'null'; } @@ -86,18 +89,23 @@ private function collectionChunks(iterable $data, string $className, array $opti } /** - * Return the object → array mapper for the class if it exposes the generated - * `mapToJsonStream()` method, otherwise null. + * 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 jsonStreamMapper(string $className): ?object + private function jsonMapper(string $className): ?MapperInterface { if (!$this->mapper instanceof AutoMapperRegistryInterface) { return null; } - $mapper = $this->mapper->getMapper($className, 'array'); + /** @var MapperInterface> $mapper */ + $mapper = $this->mapper->getMapper($className, 'json'); - return method_exists($mapper, 'mapToJsonStream') ? $mapper : null; + return $mapper; } /** @@ -105,7 +113,9 @@ private function jsonStreamMapper(string $className): ?object * (`getIterator`) or buffered (`__toString`). The factory is re-invoked per * consumption so the result stays re-iterable. * - * @param \Closure(): iterable $factory + * @param \Closure(): iterable $factory + * + * @return \Traversable&\Stringable */ private function wrap(\Closure $factory): \Traversable&\Stringable { @@ -113,7 +123,7 @@ private function wrap(\Closure $factory): \Traversable&\Stringable * @implements \IteratorAggregate */ class($factory) implements \IteratorAggregate, \Stringable { /** - * @param \Closure(): iterable $factory + * @param \Closure(): iterable $factory */ public function __construct( private \Closure $factory, diff --git a/src/LazyMapper.php b/src/LazyMapper.php index 9fd2ba11..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, ) { } @@ -66,20 +66,6 @@ public function &map(mixed $value, array $context = []): mixed return $this->getMapper()->map($value, $context); } - /** - * Delegates to the generated object → array mapper's JSON streaming method. - * - * @param array $context - * - * @return iterable - */ - public function mapToJsonStream(mixed $value, array $context = []): iterable - { - $mapper = $this->getMapper(); - - return method_exists($mapper, 'mapToJsonStream') ? $mapper->mapToJsonStream($value, $context) : []; - } - /** * @return MapperInterface */ 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 0ecb68cc..7071ac3c 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/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/JsonStreamer/JsonStreamWriterTest.php b/tests/JsonStreamer/JsonStreamWriterTest.php index eb135019..dbc249fa 100644 --- a/tests/JsonStreamer/JsonStreamWriterTest.php +++ b/tests/JsonStreamer/JsonStreamWriterTest.php @@ -66,4 +66,38 @@ classPrefix: 'JsonStreamerPrivate_' $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); + } } From 419b780c5cb8be03fa83dafc22c7c6c709e6002a Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Wed, 22 Jul 2026 18:01:28 +0200 Subject: [PATCH 04/13] refactor(lazy): introduce LazyMapInterface marker for array-shape sources Replace hardcoded LazyMap::class routing checks with an is_a() check against a new generic LazyMapInterface marker (extending \ArrayAccess). Any implementer is now treated like array/\stdClass: its property list comes from the other side of the mapping, reads go through offsetGet/offsetExists, and nested objects keep the same source type so laziness propagates recursively. FromTargetMappingExtractor now reuses the actual source class for nested objects instead of hardcoding LazyMap, so alternative implementations (e.g. a streaming JSON decoder) stay themselves all the way down. --- src/Extractor/FromTargetMappingExtractor.php | 10 ++++--- src/Extractor/MappingExtractor.php | 6 ++--- src/Lazy/LazyMap.php | 4 +-- src/Lazy/LazyMapInterface.php | 26 +++++++++++++++++++ src/Metadata/MapperMetadata.php | 6 ++--- .../ArrayShapeTransformerFactory.php | 4 +-- src/Transformer/ArrayTransformerFactory.php | 6 ++--- 7 files changed, 45 insertions(+), 17 deletions(-) create mode 100644 src/Lazy/LazyMapInterface.php diff --git a/src/Extractor/FromTargetMappingExtractor.php b/src/Extractor/FromTargetMappingExtractor.php index 91682647..3cebfd5e 100644 --- a/src/Extractor/FromTargetMappingExtractor.php +++ b/src/Extractor/FromTargetMappingExtractor.php @@ -4,7 +4,6 @@ namespace AutoMapper\Extractor; -use AutoMapper\Lazy\LazyMap; use AutoMapper\Metadata\SourcePropertyMetadata; use AutoMapper\Metadata\TargetPropertyMetadata; use Symfony\Component\TypeInfo\Type; @@ -21,8 +20,8 @@ final class FromTargetMappingExtractor extends MappingExtractor { /** - * @param 'array' $source - * @param class-string $target + * @param 'array'|class-string $source + * @param class-string $target */ public function getTypes(string $source, SourcePropertyMetadata $sourceProperty, string $target, TargetPropertyMetadata $targetProperty, bool $extractTypesFromGetter): array { @@ -131,7 +130,10 @@ private function transformTargetType(string $source, ?Type $type = null): ?Type return Type::object(\stdClass::class); } - return Type::object(LazyMap::class); + // Any other source here is a LazyMap-like class (see MapperMetadata::isSourceArrayLike): + // keep the same source type on nested objects so on-demand access propagates recursively. + /** @var class-string $source */ + return Type::object($source); } return $type; diff --git a/src/Extractor/MappingExtractor.php b/src/Extractor/MappingExtractor.php index 06be4e8f..a7ef34a8 100644 --- a/src/Extractor/MappingExtractor.php +++ b/src/Extractor/MappingExtractor.php @@ -6,7 +6,7 @@ use AutoMapper\Configuration; use AutoMapper\Event\PropertyMetadataEvent; -use AutoMapper\Lazy\LazyMap; +use AutoMapper\Lazy\LazyMapInterface; use Symfony\Component\PropertyInfo\PropertyListExtractorInterface; use Symfony\Component\PropertyInfo\PropertyReadInfo; use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface; @@ -38,7 +38,7 @@ public function __construct( */ public function getProperties(string $class, bool $withConstructorParameters = false): iterable { - if ($class === 'array' || $class === 'json' || $class === \stdClass::class || $class === LazyMap::class) { + if ($class === 'array' || $class === 'json' || $class === \stdClass::class || is_a($class, LazyMapInterface::class, true)) { return []; } @@ -245,7 +245,7 @@ private function doGetReadAccessor(string $class, string $property, bool $allowE return new PropertyReadAccessor($property); } - if (LazyMap::class === $class) { + if (is_a($class, LazyMapInterface::class, true)) { return new ArrayReadAccessor($property, true); } diff --git a/src/Lazy/LazyMap.php b/src/Lazy/LazyMap.php index 7c8c49e1..46870ea9 100644 --- a/src/Lazy/LazyMap.php +++ b/src/Lazy/LazyMap.php @@ -5,10 +5,10 @@ namespace AutoMapper\Lazy; /** - * @implements \ArrayAccess + * @implements LazyMapInterface * @implements \IteratorAggregate */ -final class LazyMap implements \ArrayAccess, \JsonSerializable, \IteratorAggregate +final class LazyMap implements LazyMapInterface, \JsonSerializable, \IteratorAggregate { /** @var array */ private mixed $mappedValue = []; diff --git a/src/Lazy/LazyMapInterface.php b/src/Lazy/LazyMapInterface.php new file mode 100644 index 00000000..67e999c5 --- /dev/null +++ b/src/Lazy/LazyMapInterface.php @@ -0,0 +1,26 @@ + + */ +interface LazyMapInterface extends \ArrayAccess +{ +} diff --git a/src/Metadata/MapperMetadata.php b/src/Metadata/MapperMetadata.php index 4f457791..636f426b 100644 --- a/src/Metadata/MapperMetadata.php +++ b/src/Metadata/MapperMetadata.php @@ -4,7 +4,7 @@ namespace AutoMapper\Metadata; -use AutoMapper\Lazy\LazyMap; +use AutoMapper\Lazy\LazyMapInterface; use Composer\InstalledVersions; class MapperMetadata @@ -60,7 +60,7 @@ public function isJsonTarget(): bool */ public function isTargetArrayLike(): bool { - return \in_array($this->target, ['array', \stdClass::class, LazyMap::class, 'json'], true); + return \in_array($this->target, ['array', \stdClass::class, 'json'], true) || is_a($this->target, LazyMapInterface::class, true); } /** @@ -68,7 +68,7 @@ public function isTargetArrayLike(): bool */ public function isSourceArrayLike(): bool { - return \in_array($this->source, ['array', \stdClass::class, LazyMap::class], true); + return \in_array($this->source, ['array', \stdClass::class], true) || is_a($this->source, LazyMapInterface::class, true); } public function getHash(): string diff --git a/src/Transformer/ArrayShapeTransformerFactory.php b/src/Transformer/ArrayShapeTransformerFactory.php index 7350840b..415d869d 100644 --- a/src/Transformer/ArrayShapeTransformerFactory.php +++ b/src/Transformer/ArrayShapeTransformerFactory.php @@ -4,7 +4,7 @@ namespace AutoMapper\Transformer; -use AutoMapper\Lazy\LazyMap; +use AutoMapper\Lazy\LazyMapInterface; use AutoMapper\Metadata\MapperMetadata; use AutoMapper\Metadata\SourcePropertyMetadata; use AutoMapper\Metadata\TargetPropertyMetadata; @@ -37,7 +37,7 @@ public function getTransformer(SourcePropertyMetadata $source, TargetPropertyMet $fieldTransformers = []; $sourceIsUntyped = isset($mapperMetadata->source) - && \in_array($mapperMetadata->source, ['array', \stdClass::class, LazyMap::class], true); + && (\in_array($mapperMetadata->source, ['array', \stdClass::class], true) || is_a($mapperMetadata->source, LazyMapInterface::class, true)); $sourceShape = $sourceType instanceof Type\ArrayShapeType ? $sourceType->getShape() : []; diff --git a/src/Transformer/ArrayTransformerFactory.php b/src/Transformer/ArrayTransformerFactory.php index 6ff83527..04256dc0 100644 --- a/src/Transformer/ArrayTransformerFactory.php +++ b/src/Transformer/ArrayTransformerFactory.php @@ -4,7 +4,7 @@ namespace AutoMapper\Transformer; -use AutoMapper\Lazy\LazyMap; +use AutoMapper\Lazy\LazyMapInterface; use AutoMapper\Metadata\MapperMetadata; use AutoMapper\Metadata\SourcePropertyMetadata; use AutoMapper\Metadata\TargetPropertyMetadata; @@ -42,7 +42,7 @@ public function getTransformer(SourcePropertyMetadata $source, TargetPropertyMet // overriding to mixed so the chain generates proper scalar casts. $wrapWithNullable = false; if (isset($mapperMetadata->source) - && \in_array($mapperMetadata->source, ['array', \stdClass::class, LazyMap::class], true)) { + && (\in_array($mapperMetadata->source, ['array', \stdClass::class], true) || is_a($mapperMetadata->source, LazyMapInterface::class, true))) { [$sourceCollectionType, $wrapWithNullable] = $this->overrideSourceCollectionType($sourceCollectionType, $targetCollectionType); } @@ -87,7 +87,7 @@ public function getTransformer(SourcePropertyMetadata $source, TargetPropertyMet private function targetCanHoldLazyCollection(Type $targetType, MapperMetadata $mapperMetadata): bool { if (isset($mapperMetadata->target) - && \in_array($mapperMetadata->target, ['array', \stdClass::class, LazyMap::class], true)) { + && (\in_array($mapperMetadata->target, ['array', \stdClass::class], true) || is_a($mapperMetadata->target, LazyMapInterface::class, true))) { return true; } From e85586437192c7443a3c293e06da2ba4ede87ba5 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Wed, 22 Jul 2026 18:01:34 +0200 Subject: [PATCH 05/13] chore(bench): add no-checking mapper variants and stream/iterable payloads --- bench/src/CollectionBench.php | 7 +++++++ bench/src/DeserializeBench.php | 13 ++++++++----- bench/src/Factory/MapperFactory.php | 19 ++++++++++++++++++- bench/src/Factory/PayloadFactory.php | 10 ++++++++++ bench/src/SerializeBench.php | 6 ++++++ bench/src/WriteCollectionBench.php | 24 ++++++++++++++++-------- 6 files changed, 65 insertions(+), 14 deletions(-) diff --git a/bench/src/CollectionBench.php b/bench/src/CollectionBench.php index 0e0bed78..73b47549 100644 --- a/bench/src/CollectionBench.php +++ b/bench/src/CollectionBench.php @@ -121,6 +121,13 @@ public function benchAutoMapperMapCollection(): void $sink = $this->consume(MapperFactory::autoMapper()->mapCollection($data, Person::class)); } + #[ParamProviders('provideSizes')] + public function benchAutoMapperNoCheckingMapCollection(): void + { + $data = json_decode((string) file_get_contents($this->file), true, 512, JSON_THROW_ON_ERROR); + $sink = $this->consume(MapperFactory::autoMapperNoChecking()->mapCollection($data, Person::class)); + } + #[ParamProviders('provideSizes')] public function benchSymfonySerializer(): void { diff --git a/bench/src/DeserializeBench.php b/bench/src/DeserializeBench.php index 1a0c174f..b3465f37 100644 --- a/bench/src/DeserializeBench.php +++ b/bench/src/DeserializeBench.php @@ -39,10 +39,13 @@ class DeserializeBench private Type $type; + private $stream; + public function setUp(): void { $this->json = PayloadFactory::personJson(1); $this->type = Type::object(Person::class); + $this->stream = PayloadFactory::stream($this->json); // 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); @@ -87,10 +90,10 @@ public function benchAutoMapper(): void $this->realize(MapperFactory::autoMapper()->map($data, Person::class)); } - public function benchAutoMapperNoAttributeChecking(): void + public function benchAutoMapperNoChecking(): void { $data = json_decode($this->json, true, 512, JSON_THROW_ON_ERROR); - $this->realize(MapperFactory::autoMapperNoAttributeChecking()->map($data, Person::class)); + $this->realize(MapperFactory::autoMapperNoChecking()->map($data, Person::class)); } public function benchSymfonySerializer(): void @@ -100,16 +103,16 @@ public function benchSymfonySerializer(): void public function benchSymfonyJsonStreamer(): void { - $this->realize(MapperFactory::jsonStreamReader()->read(PayloadFactory::stream($this->json), $this->type)); + $this->realize(MapperFactory::jsonStreamReader()->read($this->stream, $this->type)); } public function benchAutoMapperJsonStreamer(): void { - $this->realize(MapperFactory::autoMapperJsonStreamReader()->read(PayloadFactory::stream($this->json), $this->type)); + $this->realize(MapperFactory::autoMapperJsonStreamReader()->read($this->stream, $this->type)); } public function benchAutoMapperJsonStreamerNoAttributeChecking(): void { - $this->realize(MapperFactory::autoMapperNoAttributeJsonStreamReader()->read(PayloadFactory::stream($this->json), $this->type)); + $this->realize(MapperFactory::autoMapperNoAttributeJsonStreamReader()->read($this->stream, $this->type)); } } diff --git a/bench/src/Factory/MapperFactory.php b/bench/src/Factory/MapperFactory.php index 6b9d9527..e7189d8a 100644 --- a/bench/src/Factory/MapperFactory.php +++ b/bench/src/Factory/MapperFactory.php @@ -103,6 +103,23 @@ classPrefix: 'BenchNoAttr_', ); } + /** + * AutoMapper with attribute checking disabled (skips reading Serializer/AutoMapper + * attributes when building metadata — cheaper generation, same runtime shape here). + */ + public static function autoMapperNoChecking(): AutoMapperInterface + { + return self::$autoMappers['no_checking'] ??= AutoMapper::create( + new Configuration( + classPrefix: 'BenchNoCheck_', + attributeChecking: false, + mapPrivateProperties: false, + groupChecking: false, + ), + cacheDirectory: self::cacheDir('no_checking'), + ); + } + /** * A Serializer wired like Symfony FrameworkBundle's default service: the * property-info extractor and the class-metadata factory are both wrapped in a @@ -192,7 +209,7 @@ public static function autoMapperNoAttributeJsonStreamReader(): AutoMapperJsonSt public static function autoMapperNoAttributeJsonStreamWriter(): AutoMapperJsonStreamWriter { return self::$autoMapperNoAttributeJsonStreamWriter ??= new AutoMapperJsonStreamWriter( - self::autoMapperNoAttributeChecking(), + self::autoMapperNoChecking(), self::jsonStreamWriter(), ); } diff --git a/bench/src/Factory/PayloadFactory.php b/bench/src/Factory/PayloadFactory.php index bff08dda..21637e7e 100644 --- a/bench/src/Factory/PayloadFactory.php +++ b/bench/src/Factory/PayloadFactory.php @@ -110,6 +110,16 @@ public static function personList(int $count): array return $list; } + /** + * @return iterable + */ + public static function personIterable(int $count): \Generator + { + for ($i = 0; $i < $count; ++$i) { + yield self::person($i); + } + } + public static function personSource(int $seed = 0): PersonSource { $person = new PersonSource(); diff --git a/bench/src/SerializeBench.php b/bench/src/SerializeBench.php index 5642aa0f..cb034b97 100644 --- a/bench/src/SerializeBench.php +++ b/bench/src/SerializeBench.php @@ -74,6 +74,12 @@ public function benchAutoMapperNoAttributeChecking(): void $json = json_encode($data, JSON_THROW_ON_ERROR); } + public function benchAutoMapperNoChecking(): void + { + $data = MapperFactory::autoMapperNoChecking()->map($this->person, 'array'); + $json = json_encode($data, JSON_THROW_ON_ERROR); + } + public function benchSymfonySerializer(): void { MapperFactory::serializer()->serialize($this->person, 'json'); diff --git a/bench/src/WriteCollectionBench.php b/bench/src/WriteCollectionBench.php index 226d2080..898e8799 100644 --- a/bench/src/WriteCollectionBench.php +++ b/bench/src/WriteCollectionBench.php @@ -30,8 +30,7 @@ #[Groups(['write-collection'])] class WriteCollectionBench { - /** @var list */ - private array $persons; + private int $count; private Type $listType; @@ -51,7 +50,7 @@ public function provideSizes(): array */ public function setUp(array $params): void { - $this->persons = PayloadFactory::personList($params['count']); + $this->count = $params['count']; $this->listType = Type::iterable(Type::object(Person::class)); // Warm generation and assert both writers agree (canonically). @@ -69,7 +68,7 @@ public function setUp(array $params): void public function benchAutoMapperJsonStreamerToString(): void { $sink = \strlen((string) MapperFactory::autoMapperNoAttributeJsonStreamWriter()->write( - $this->persons, + PayloadFactory::personIterable($this->count), $this->listType, [MapperContext::STREAM => true], )); @@ -80,7 +79,7 @@ public function benchAutoMapperJsonStreamerStream(): void { $sink = 0; $result = MapperFactory::autoMapperNoAttributeJsonStreamWriter()->write( - $this->persons, + PayloadFactory::personIterable($this->count), $this->listType, [MapperContext::STREAM => true], ); @@ -92,14 +91,20 @@ public function benchAutoMapperJsonStreamerStream(): void #[ParamProviders('provideSizes')] public function benchSymfonyJsonStreamerToString(): void { - $sink = \strlen((string) MapperFactory::jsonStreamWriter()->write($this->persons, $this->listType)); + $sink = \strlen((string) MapperFactory::jsonStreamWriter()->write( + PayloadFactory::personIterable($this->count), + $this->listType + )); } #[ParamProviders('provideSizes')] public function benchSymfonyJsonStreamerIterate(): void { $sink = 0; - foreach (MapperFactory::jsonStreamWriter()->write($this->persons, $this->listType) as $chunk) { + foreach (MapperFactory::jsonStreamWriter()->write( + PayloadFactory::personIterable($this->count), + $this->listType + ) as $chunk) { $sink += \strlen($chunk); } } @@ -107,7 +112,10 @@ public function benchSymfonyJsonStreamerIterate(): void #[ParamProviders('provideSizes')] public function benchAutoMapperJsonEncode(): void { - $array = MapperFactory::autoMapperNoAttributeChecking()->mapCollection($this->persons, 'array'); + $array = MapperFactory::autoMapperNoAttributeChecking()->mapCollection( + PayloadFactory::personIterable($this->count), + 'array' + ); $sink = \strlen((string) json_encode($array)); } } From deac2146e5027a7f7441ce08cad22caad2d2270c Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Wed, 22 Jul 2026 18:01:48 +0200 Subject: [PATCH 06/13] feat(json-streamer): lazy JSON decoder for the read path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read a single JSON object through a purpose-built lazy decoder instead of materializing it via the fallback stream reader. The decoder exposes the object as a LazyJsonObject (a LazyMapInterface), so the generated mapper reads only the fields it maps, straight from the stream, with nested objects and lists staying lazy — no intermediate array. - JsonBuffer: seekable byte buffer over a string or a (non-seekable) stream, pulling bytes on demand so fields can be revisited out of document order. - JsonParser: position-based scan primitives; structures are skipped by brace matching, leaf values decoded natively via json_decode. - LazyJsonObject / LazyJsonList: lazy, re-iterable object and array nodes. - JsonDecoder: entry point returning the lazy root value. Top-level collections still go through the fallback for now (their single-pass streaming semantics need a discarding buffer). --- src/JsonStreamer/JsonStreamReader.php | 17 +- src/JsonStreamer/Read/JsonBuffer.php | 96 +++++++++++ src/JsonStreamer/Read/JsonDecoder.php | 29 ++++ src/JsonStreamer/Read/JsonParser.php | 140 +++++++++++++++ src/JsonStreamer/Read/LazyJsonList.php | 60 +++++++ src/JsonStreamer/Read/LazyJsonObject.php | 167 ++++++++++++++++++ tests/JsonStreamer/Read/JsonDecoderTest.php | 182 ++++++++++++++++++++ 7 files changed, 686 insertions(+), 5 deletions(-) create mode 100644 src/JsonStreamer/Read/JsonBuffer.php create mode 100644 src/JsonStreamer/Read/JsonDecoder.php create mode 100644 src/JsonStreamer/Read/JsonParser.php create mode 100644 src/JsonStreamer/Read/LazyJsonList.php create mode 100644 src/JsonStreamer/Read/LazyJsonObject.php create mode 100644 tests/JsonStreamer/Read/JsonDecoderTest.php diff --git a/src/JsonStreamer/JsonStreamReader.php b/src/JsonStreamer/JsonStreamReader.php index 88b0dbc7..36af78a5 100644 --- a/src/JsonStreamer/JsonStreamReader.php +++ b/src/JsonStreamer/JsonStreamReader.php @@ -5,6 +5,8 @@ namespace AutoMapper\JsonStreamer; use AutoMapper\AutoMapperInterface; +use AutoMapper\JsonStreamer\Read\JsonDecoder; +use AutoMapper\JsonStreamer\Read\LazyJsonObject; use AutoMapper\Lazy\LazyCollection; use AutoMapper\MapperContext; use Symfony\Component\JsonStreamer\StreamReaderInterface; @@ -73,13 +75,18 @@ public function read($input, Type $type, array $options = []): mixed $className = $this->ownedClassName($unwrapped); if ($className !== null) { - $raw = $this->fallbackStreamReader->read($input, Type::dict(), $options); - - if (!\is_array($raw)) { - return $raw; + // Decode lazily: the object is exposed as an array-like source (LazyJsonObject) so the + // AutoMapper reads only the fields it maps, straight from the stream, and nested + // objects/lists stay lazy — no intermediate array is materialized. + $decoded = JsonDecoder::decode($input); + + if (!$decoded instanceof LazyJsonObject) { + // The JSON is not an object (null, scalar, list): nothing for the AutoMapper to + // hydrate, hand the decoded value back as-is. + return $decoded; } - return $this->mapper->map($raw, $className, $options); + return $this->mapper->map($decoded, $className, $options); } return $this->fallbackStreamReader->read($input, $type, $options); diff --git a/src/JsonStreamer/Read/JsonBuffer.php b/src/JsonStreamer/Read/JsonBuffer.php new file mode 100644 index 00000000..45648803 --- /dev/null +++ b/src/JsonStreamer/Read/JsonBuffer.php @@ -0,0 +1,96 @@ + $chunkSize + */ + public function __construct( + mixed $input, + private readonly int $chunkSize = 8192, + ) { + if (\is_string($input)) { + $this->data = $input; + $this->eof = true; + $this->stream = null; + + return; + } + + if (!\is_resource($input)) { + throw new \InvalidArgumentException(\sprintf('The JSON buffer expects a string or a stream resource, "%s" given.', get_debug_type($input))); + } + + $this->stream = $input; + } + + /** + * The byte at $offset, or null once the input is exhausted at that offset. + */ + public function byteAt(int $offset): ?string + { + $this->fillTo($offset); + + return $offset < \strlen($this->data) ? $this->data[$offset] : null; + } + + /** + * The $length bytes starting at $offset (possibly shorter at the end of the input). + */ + public function slice(int $offset, int $length): string + { + $this->fillTo($offset + $length - 1); + + return substr($this->data, $offset, $length); + } + + /** + * Pull bytes from the stream until $offset is available or the input is exhausted. + */ + private function fillTo(int $offset): void + { + while (!$this->eof && $offset >= \strlen($this->data)) { + if (!\is_resource($this->stream)) { + $this->eof = true; + + break; + } + + $chunk = fread($this->stream, $this->chunkSize); + + if ($chunk === false || $chunk === '') { + $this->eof = true; + + break; + } + + $this->data .= $chunk; + } + } +} diff --git a/src/JsonStreamer/Read/JsonDecoder.php b/src/JsonStreamer/Read/JsonDecoder.php new file mode 100644 index 00000000..a49757d9 --- /dev/null +++ b/src/JsonStreamer/Read/JsonDecoder.php @@ -0,0 +1,29 @@ +byteAt($pos)) !== null && ($c === ' ' || $c === "\t" || $c === "\n" || $c === "\r")) { + ++$pos; + } + + return $pos; + } + + /** + * The offset right after the value that starts at $pos (which must sit on the first, + * whitespace-stripped, byte of the value). + */ + public static function skipValue(JsonBuffer $buffer, int $pos): int + { + $c = $buffer->byteAt($pos); + + if ($c === '{' || $c === '[') { + return self::skipStructure($buffer, $pos); + } + + if ($c === '"') { + return self::skipString($buffer, $pos); + } + + return self::skipScalar($buffer, $pos); + } + + /** + * Decode the value starting at $pos: a scalar becomes its PHP value, while an object or an + * array becomes a lazy node that is not parsed until it is itself accessed. + */ + public static function parseValue(JsonBuffer $buffer, int $pos): mixed + { + $c = $buffer->byteAt($pos); + + if ($c === '{') { + return new LazyJsonObject($buffer, $pos); + } + + if ($c === '[') { + return new LazyJsonList($buffer, $pos); + } + + $end = $c === '"' ? self::skipString($buffer, $pos) : self::skipScalar($buffer, $pos); + + return json_decode($buffer->slice($pos, $end - $pos), true, 512, \JSON_THROW_ON_ERROR); + } + + /** + * Match a `{...}` or `[...]` structure and return the offset right after its closing bracket, + * without decoding its content. Strings are skipped as a whole so brackets inside them do not + * affect the depth count. + */ + private static function skipStructure(JsonBuffer $buffer, int $pos): int + { + $depth = 0; + + while (($c = $buffer->byteAt($pos)) !== null) { + if ($c === '"') { + $pos = self::skipString($buffer, $pos); + + continue; + } + + if ($c === '{' || $c === '[') { + ++$depth; + } elseif ($c === '}' || $c === ']') { + --$depth; + + if ($depth === 0) { + return $pos + 1; + } + } + + ++$pos; + } + + return $pos; + } + + /** + * The offset right after the string that starts at the opening quote at $pos. + */ + private static function skipString(JsonBuffer $buffer, int $pos): int + { + ++$pos; // opening quote + + while (($c = $buffer->byteAt($pos)) !== null) { + if ($c === '\\') { + $pos += 2; // an escaped char never ends the string (\uXXXX hex digits are plain bytes) + + continue; + } + + if ($c === '"') { + return $pos + 1; + } + + ++$pos; + } + + return $pos; + } + + /** + * The offset right after a scalar literal (number, `true`, `false`, `null`) starting at $pos. + */ + private static function skipScalar(JsonBuffer $buffer, int $pos): int + { + while (($c = $buffer->byteAt($pos)) !== null && $c !== ',' && $c !== '}' && $c !== ']' + && $c !== ' ' && $c !== "\t" && $c !== "\n" && $c !== "\r") { + ++$pos; + } + + return $pos; + } +} diff --git a/src/JsonStreamer/Read/LazyJsonList.php b/src/JsonStreamer/Read/LazyJsonList.php new file mode 100644 index 00000000..dafbfcf5 --- /dev/null +++ b/src/JsonStreamer/Read/LazyJsonList.php @@ -0,0 +1,60 @@ + + * + * @internal + */ +final class LazyJsonList implements \IteratorAggregate, \JsonSerializable +{ + public function __construct( + private readonly JsonBuffer $buffer, + private readonly int $start, + ) { + } + + /** + * @return \Generator + */ + public function getIterator(): \Generator + { + $pos = JsonParser::skipWhitespace($this->buffer, $this->start + 1); // move past '[' + + if ($this->buffer->byteAt($pos) === ']') { + return; + } + + $index = 0; + + while (true) { + yield $index++ => JsonParser::parseValue($this->buffer, $pos); + + $pos = JsonParser::skipWhitespace($this->buffer, JsonParser::skipValue($this->buffer, $pos)); + + if ($this->buffer->byteAt($pos) !== ',') { + return; // closing bracket or exhausted input + } + + $pos = JsonParser::skipWhitespace($this->buffer, $pos + 1); + } + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return iterator_to_array($this); + } +} diff --git a/src/JsonStreamer/Read/LazyJsonObject.php b/src/JsonStreamer/Read/LazyJsonObject.php new file mode 100644 index 00000000..b8e9ea69 --- /dev/null +++ b/src/JsonStreamer/Read/LazyJsonObject.php @@ -0,0 +1,167 @@ + + * @implements \IteratorAggregate + * + * @internal + */ +final class LazyJsonObject implements LazyMapInterface, \IteratorAggregate, \JsonSerializable +{ + /** + * Discovered keys, in document order, so iteration and re-lookup are stable. + * + * @var list + */ + private array $keys = []; + + /** @var array key => offset of its value */ + private array $offsets = []; + + /** Offset to resume scanning key/value pairs from. */ + private int $cursor; + + private bool $complete = false; + + public function __construct( + private readonly JsonBuffer $buffer, + int $start, + ) { + $this->cursor = $start + 1; // move past the opening brace + } + + public function offsetExists(mixed $offset): bool + { + return $this->locate((string) $offset) !== null; + } + + public function offsetGet(mixed $offset): mixed + { + $valueOffset = $this->locate((string) $offset); + + if ($valueOffset === null) { + return null; + } + + return JsonParser::parseValue($this->buffer, $valueOffset); + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new \LogicException('A lazily decoded JSON object is read-only.'); + } + + public function offsetUnset(mixed $offset): void + { + throw new \LogicException('A lazily decoded JSON object is read-only.'); + } + + /** + * @return \Generator + */ + public function getIterator(): \Generator + { + $index = 0; + + while (true) { + if ($index < \count($this->keys)) { + $key = $this->keys[$index++]; + + yield $key => JsonParser::parseValue($this->buffer, $this->offsets[$key]); + + continue; + } + + if (!$this->scanNext()) { + return; + } + } + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return iterator_to_array($this); + } + + /** + * Offset of the value for $key, scanning further into the object if needed, or null when the + * key is absent. + */ + private function locate(string $key): ?int + { + if (\array_key_exists($key, $this->offsets)) { + return $this->offsets[$key]; + } + + while (!$this->complete) { + if ($this->scanNext() && $this->keys[\count($this->keys) - 1] === $key) { + return $this->offsets[$key]; + } + } + + return null; + } + + /** + * Read the next key/value pair, recording it, and return whether one was found (false at the + * object's closing brace or at the end of the input). + */ + private function scanNext(): bool + { + if ($this->complete) { + return false; + } + + $pos = JsonParser::skipWhitespace($this->buffer, $this->cursor); + $c = $this->buffer->byteAt($pos); + + if ($c === ',') { + $pos = JsonParser::skipWhitespace($this->buffer, $pos + 1); + $c = $this->buffer->byteAt($pos); + } + + if ($c !== '"') { // closing brace, or malformed/exhausted input + $this->complete = true; + + return false; + } + + [$key, $afterKey] = $this->readKey($pos); + $valueOffset = JsonParser::skipWhitespace($this->buffer, $afterKey + 1); // move past ':' + + $this->keys[] = $key; + $this->offsets[$key] = $valueOffset; + $this->cursor = JsonParser::skipValue($this->buffer, $valueOffset); + + return true; + } + + /** + * @return array{string, int} the decoded key and the offset of the byte after it (the `:`) + */ + private function readKey(int $pos): array + { + $end = JsonParser::skipValue($this->buffer, $pos); + /** @var string $key */ + $key = json_decode($this->buffer->slice($pos, $end - $pos), true, 512, \JSON_THROW_ON_ERROR); + + return [$key, JsonParser::skipWhitespace($this->buffer, $end)]; + } +} diff --git a/tests/JsonStreamer/Read/JsonDecoderTest.php b/tests/JsonStreamer/Read/JsonDecoderTest.php new file mode 100644 index 00000000..f6876a9e --- /dev/null +++ b/tests/JsonStreamer/Read/JsonDecoderTest.php @@ -0,0 +1,182 @@ +offsetExists('name')); + self::assertFalse($object->offsetExists('missing')); + self::assertSame('yolo', $object['name']); + self::assertSame(13, $object['age']); + self::assertNull($object['missing']); + } + + public function testScalarTypesArePreserved(): void + { + $object = JsonDecoder::decode('{"i":-42,"f":20.1,"e":1.5e3,"t":true,"f2":false,"n":null,"s":"x"}'); + + self::assertInstanceOf(LazyJsonObject::class, $object); + self::assertSame(-42, $object['i']); + self::assertSame(20.1, $object['f']); + self::assertSame(1500.0, $object['e']); + self::assertTrue($object['t']); + self::assertFalse($object['f2']); + self::assertNull($object['n']); + self::assertSame('x', $object['s']); + } + + public function testFieldsCanBeReadOutOfDocumentOrder(): void + { + $object = JsonDecoder::decode('{"a":1,"b":2,"c":3}'); + + self::assertInstanceOf(LazyJsonObject::class, $object); + // Read the last field first, then an earlier one: the buffer lets us come back. + self::assertSame(3, $object['c']); + self::assertSame(1, $object['a']); + self::assertSame(2, $object['b']); + } + + public function testStringEscapesAndUnicodeAreDecoded(): void + { + $object = JsonDecoder::decode('{"quote":"a\"b","unicode":"é","slash":"a\\/b","brace":"{not:a,field}"}'); + + self::assertInstanceOf(LazyJsonObject::class, $object); + self::assertSame('a"b', $object['quote']); + self::assertSame('é', $object['unicode']); + self::assertSame('a/b', $object['slash']); + // Braces inside a string must not be mistaken for structure. + self::assertSame('{not:a,field}', $object['brace']); + } + + public function testNestedObjectStaysLazy(): void + { + $object = JsonDecoder::decode('{"user":{"city":"Toulon","zip":"83000"}}'); + + self::assertInstanceOf(LazyJsonObject::class, $object); + $nested = $object['user']; + self::assertInstanceOf(LazyJsonObject::class, $nested); + self::assertSame('Toulon', $nested['city']); + self::assertSame('83000', $nested['zip']); + } + + public function testListIsLazyAndReIterable(): void + { + $object = JsonDecoder::decode('{"items":[1,2,3]}'); + + self::assertInstanceOf(LazyJsonObject::class, $object); + $list = $object['items']; + self::assertInstanceOf(LazyJsonList::class, $list); + + self::assertSame([1, 2, 3], iterator_to_array($list)); + // Re-iterable: iterating again yields the same values. + self::assertSame([1, 2, 3], iterator_to_array($list)); + } + + public function testTopLevelListDecodesToLazyList(): void + { + $list = JsonDecoder::decode('[{"id":1},{"id":2}]'); + + self::assertInstanceOf(LazyJsonList::class, $list); + + $ids = []; + foreach ($list as $item) { + self::assertInstanceOf(LazyJsonObject::class, $item); + $ids[] = $item['id']; + } + + self::assertSame([1, 2], $ids); + } + + public function testEmptyObjectAndArray(): void + { + $object = JsonDecoder::decode('{"empty_obj":{},"empty_arr":[]}'); + + self::assertInstanceOf(LazyJsonObject::class, $object); + self::assertSame([], iterator_to_array($object['empty_obj'])); + self::assertSame([], iterator_to_array($object['empty_arr'])); + } + + public function testIterationPreservesKeyOrder(): void + { + $object = JsonDecoder::decode('{"b":1,"a":2,"c":3}'); + + self::assertInstanceOf(LazyJsonObject::class, $object); + self::assertSame(['b' => 1, 'a' => 2, 'c' => 3], iterator_to_array($object)); + } + + public function testIterationAfterPartialAccessStillYieldsEverything(): void + { + $object = JsonDecoder::decode('{"a":1,"b":2,"c":3}'); + + self::assertInstanceOf(LazyJsonObject::class, $object); + self::assertSame(2, $object['b']); // partially scan first + self::assertSame(['a' => 1, 'b' => 2, 'c' => 3], iterator_to_array($object)); + } + + public function testWhitespaceIsTolerated(): void + { + $object = JsonDecoder::decode("{\n \"a\" : 1 ,\n \"b\" : [ 1 , 2 ]\n}"); + + self::assertInstanceOf(LazyJsonObject::class, $object); + self::assertSame(1, $object['a']); + self::assertSame([1, 2], iterator_to_array($object['b'])); + } + + public function testJsonSerializeRoundTrips(): void + { + $json = '{"name":"yolo","address":{"city":"Toulon"},"tags":["a","b"]}'; + $object = JsonDecoder::decode($json); + + self::assertInstanceOf(LazyJsonObject::class, $object); + self::assertJsonStringEqualsJsonString($json, json_encode($object)); + } + + public function testReadOnly(): void + { + $object = JsonDecoder::decode('{"a":1}'); + + self::assertInstanceOf(LazyJsonObject::class, $object); + $this->expectException(\LogicException::class); + $object['a'] = 2; + } + + public function testDecodesFromANonRewoundChunkedStream(): void + { + $json = '{"name":"yolo","address":{"city":"Toulon"},"items":[1,2,3]}'; + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $json); + rewind($stream); + + // Tiny chunk size to force many reads and exercise the on-demand fill path. + $buffer = new JsonBuffer($stream, chunkSize: 4); + $object = JsonParser::parseValue($buffer, JsonParser::skipWhitespace($buffer, 0)); + + self::assertInstanceOf(LazyJsonObject::class, $object); + self::assertSame('yolo', $object['name']); + self::assertSame('Toulon', $object['address']['city']); + self::assertSame([1, 2, 3], iterator_to_array($object['items'])); + } +} From 3374e455da92f3fa76f45d11c0c02ae3833354be Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Wed, 22 Jul 2026 18:23:49 +0200 Subject: [PATCH 07/13] fix(generator): gate key existence for LazyMapInterface sources getCheckExists() and the property-exists condition only recognized 'array' and \stdClass sources, so a LazyMapInterface (ArrayAccess) source got no existence gate: an absent key was mapped as null instead of being skipped, which throws on a non-nullable target property. Both now treat LazyMapInterface sources like an array, emitting $source->offsetExists('prop') as the gate. Fixes a latent bug for LazyMap and is required for the streaming JSON decoder's collection path. --- src/Extractor/MappingExtractor.php | 2 +- src/Generator/PropertyConditionsGenerator.php | 30 ++++++++++++++----- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/Extractor/MappingExtractor.php b/src/Extractor/MappingExtractor.php index a7ef34a8..91f3914e 100644 --- a/src/Extractor/MappingExtractor.php +++ b/src/Extractor/MappingExtractor.php @@ -178,7 +178,7 @@ public function getWriteMutator(string $source, string $target, string $property public function getCheckExists(string $class, string $property): bool { - if ('array' === $class || \stdClass::class === $class) { + if ('array' === $class || \stdClass::class === $class || is_a($class, LazyMapInterface::class, true)) { return true; } diff --git a/src/Generator/PropertyConditionsGenerator.php b/src/Generator/PropertyConditionsGenerator.php index 60554cca..8c18d92f 100644 --- a/src/Generator/PropertyConditionsGenerator.php +++ b/src/Generator/PropertyConditionsGenerator.php @@ -8,6 +8,7 @@ use AutoMapper\AttributeReference\Reference; use AutoMapper\Exception\CompileException; use AutoMapper\Extractor\NestedReadAccessor; +use AutoMapper\Lazy\LazyMapInterface; use AutoMapper\MapperContext; use AutoMapper\Metadata\GeneratorMetadata; use AutoMapper\Metadata\PropertyMetadata; @@ -126,22 +127,37 @@ private function propertyExistsForStdClass(GeneratorMetadata $metadata, Property } /** - * In case of source is an array we ensure that the key exists. + * When the source is read as an array shape we ensure that the key exists, so an absent key is + * skipped instead of mapped as null. * * ```php - * array_key_exists('propertyName', $source). + * array_key_exists('propertyName', $source) // plain array source + * $source->offsetExists('propertyName') // LazyMapInterface (ArrayAccess) source * ``` */ private function propertyExistsForArray(GeneratorMetadata $metadata, PropertyMetadata $propertyMetadata): ?Expr { - if (!$propertyMetadata->source->checkExists || 'array' !== $metadata->mapperMetadata->source) { + if (!$propertyMetadata->source->checkExists) { return null; } - return new Expr\FuncCall(new Name('array_key_exists'), [ - new Arg(new Scalar\String_($propertyMetadata->source->property)), - new Arg($metadata->variableRegistry->getSourceInput()), - ]); + $source = $metadata->mapperMetadata->source; + $input = $metadata->variableRegistry->getSourceInput(); + + if ('array' === $source) { + return new Expr\FuncCall(new Name('array_key_exists'), [ + new Arg(new Scalar\String_($propertyMetadata->source->property)), + new Arg($input), + ]); + } + + if (is_a($source, LazyMapInterface::class, true)) { + return new Expr\MethodCall($input, 'offsetExists', [ + new Arg(new Scalar\String_($propertyMetadata->source->property)), + ]); + } + + return null; } /** From 9fbba4f07ec0be959f29f7cae9b8b1729c05a878 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Wed, 22 Jul 2026 18:23:54 +0200 Subject: [PATCH 08/13] feat(json-streamer): stream top-level collections through the lazy decoder Route collection reads through the lazy decoder instead of the fallback: the top-level array/object is exposed as a LazyJsonList/LazyJsonObject and fed to LazyCollection, which maps each element as it is pulled. LazyCollection keeps its role as the map-and-policy layer (buffered re-iteration, single-pass streaming); the decoder is the lazy JSON iteration layer underneath. For the STREAM option, JsonBuffer gains discardBefore(): the streaming LazyJsonList frees each element from the buffer once it has advanced past it, so a large top-level array stays bounded to one element's worth of bytes. Absolute offsets remain stable and accessing a discarded offset throws. Top-level dict-of-objects streaming still holds bytes (its per-entry discarding is a follow-up); single-pass is still enforced by LazyCollection. --- src/JsonStreamer/JsonStreamReader.php | 25 +++++----- src/JsonStreamer/Read/JsonBuffer.php | 44 +++++++++++++++-- src/JsonStreamer/Read/JsonDecoder.php | 8 +++- src/JsonStreamer/Read/LazyJsonList.php | 15 +++++- tests/JsonStreamer/Read/JsonDecoderTest.php | 53 +++++++++++++++++++++ 5 files changed, 127 insertions(+), 18 deletions(-) diff --git a/src/JsonStreamer/JsonStreamReader.php b/src/JsonStreamer/JsonStreamReader.php index 36af78a5..164ced34 100644 --- a/src/JsonStreamer/JsonStreamReader.php +++ b/src/JsonStreamer/JsonStreamReader.php @@ -6,6 +6,7 @@ use AutoMapper\AutoMapperInterface; use AutoMapper\JsonStreamer\Read\JsonDecoder; +use AutoMapper\JsonStreamer\Read\LazyJsonList; use AutoMapper\JsonStreamer\Read\LazyJsonObject; use AutoMapper\Lazy\LazyCollection; use AutoMapper\MapperContext; @@ -49,24 +50,24 @@ public function read($input, Type $type, array $options = []): mixed $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 */ + // Decode lazily: each element is exposed as an array-like source (LazyJsonObject) so + // the AutoMapper reads only the fields it maps. In streaming mode the decoder frees + // each element once iterated past, so a large top-level array stays memory-bounded; + // LazyCollection then maps each element as it is pulled. + $decoded = JsonDecoder::decode($input, streaming: !$buffered); + + if (!$decoded instanceof LazyJsonList && !$decoded instanceof LazyJsonObject) { + // The JSON is not a list or an object (null, scalar): nothing to iterate. + return $decoded; + } + return new LazyCollection( fn (mixed $rawItem): mixed => \is_array($rawItem) || \is_object($rawItem) ? $this->mapper->map($rawItem, $className, $options) : $rawItem, - $rawItems, + $decoded, $buffered, ); } diff --git a/src/JsonStreamer/Read/JsonBuffer.php b/src/JsonStreamer/Read/JsonBuffer.php index 45648803..92818e41 100644 --- a/src/JsonStreamer/Read/JsonBuffer.php +++ b/src/JsonStreamer/Read/JsonBuffer.php @@ -16,6 +16,11 @@ * For a plain string input the whole content is available immediately. For a resource, bytes are * read on demand, chunk by chunk, up to the highest offset requested so far. * + * Offsets are absolute and stable for the lifetime of the buffer, even after {@see discardBefore()} + * frees already-consumed leading bytes (so a large top-level collection streamed element by element + * never holds more than one element's worth of bytes). Accessing a discarded offset is a bug and + * throws. + * * @internal */ final class JsonBuffer @@ -24,6 +29,9 @@ final class JsonBuffer private bool $eof = false; + /** Absolute offset of the first byte still held in $data (bytes before it were discarded). */ + private int $base = 0; + /** @var resource|null */ private $stream; @@ -56,8 +64,9 @@ public function __construct( public function byteAt(int $offset): ?string { $this->fillTo($offset); + $local = $this->localize($offset); - return $offset < \strlen($this->data) ? $this->data[$offset] : null; + return $local < \strlen($this->data) ? $this->data[$local] : null; } /** @@ -67,7 +76,36 @@ public function slice(int $offset, int $length): string { $this->fillTo($offset + $length - 1); - return substr($this->data, $offset, $length); + return substr($this->data, $this->localize($offset), $length); + } + + /** + * Free every byte before $offset. Later access below $offset is no longer possible; callers + * must only discard past data they are certain no live node will read again (the streaming + * top-level list discards each element once it has advanced past it). + */ + public function discardBefore(int $offset): void + { + if ($offset <= $this->base) { + return; + } + + $this->fillTo($offset - 1); + $drop = min($offset - $this->base, \strlen($this->data)); + $this->data = substr($this->data, $drop); + $this->base += $drop; + } + + /** + * Translate an absolute offset into an index inside $data, failing loudly on discarded bytes. + */ + private function localize(int $offset): int + { + if ($offset < $this->base) { + throw new \LogicException(\sprintf('Byte at offset %d was already discarded from the JSON buffer.', $offset)); + } + + return $offset - $this->base; } /** @@ -75,7 +113,7 @@ public function slice(int $offset, int $length): string */ private function fillTo(int $offset): void { - while (!$this->eof && $offset >= \strlen($this->data)) { + while (!$this->eof && $offset - $this->base >= \strlen($this->data)) { if (!\is_resource($this->stream)) { $this->eof = true; diff --git a/src/JsonStreamer/Read/JsonDecoder.php b/src/JsonStreamer/Read/JsonDecoder.php index a49757d9..5768d5c7 100644 --- a/src/JsonStreamer/Read/JsonDecoder.php +++ b/src/JsonStreamer/Read/JsonDecoder.php @@ -18,12 +18,18 @@ final class JsonDecoder { /** * @param resource|string $input + * @param bool $streaming when the top-level value is an array, free each element from + * the buffer once iterated past (single-pass, memory-bounded) */ - public static function decode(mixed $input): mixed + public static function decode(mixed $input, bool $streaming = false): mixed { $buffer = new JsonBuffer($input); $pos = JsonParser::skipWhitespace($buffer, 0); + if ($streaming && $buffer->byteAt($pos) === '[') { + return new LazyJsonList($buffer, $pos, streaming: true); + } + return JsonParser::parseValue($buffer, $pos); } } diff --git a/src/JsonStreamer/Read/LazyJsonList.php b/src/JsonStreamer/Read/LazyJsonList.php index dafbfcf5..cda747c7 100644 --- a/src/JsonStreamer/Read/LazyJsonList.php +++ b/src/JsonStreamer/Read/LazyJsonList.php @@ -9,8 +9,13 @@ * * Iterating decodes one element at a time straight from the buffer, so a large array of objects is * never materialized at once and each element's own laziness is preserved (an object element is a - * {@see LazyJsonObject}). The node is stateless over the buffer, so it can be iterated more than - * once — useful when the AutoMapper both counts and maps a collection. + * {@see LazyJsonObject}). + * + * By default the node is stateless over the buffer, so it can be iterated more than once — useful + * when the AutoMapper both counts and maps a collection. In `$streaming` mode it instead frees each + * element from the buffer once it has advanced past it, so a huge top-level array is bounded to one + * element's worth of bytes; the trade-off is that it can then be iterated only once and each + * element must be fully consumed before the next is pulled. * * @implements \IteratorAggregate * @@ -21,6 +26,7 @@ final class LazyJsonList implements \IteratorAggregate, \JsonSerializable public function __construct( private readonly JsonBuffer $buffer, private readonly int $start, + private readonly bool $streaming = false, ) { } @@ -38,6 +44,11 @@ public function getIterator(): \Generator $index = 0; while (true) { + if ($this->streaming) { + // The previous element has been fully consumed by now: free it. + $this->buffer->discardBefore($pos); + } + yield $index++ => JsonParser::parseValue($this->buffer, $pos); $pos = JsonParser::skipWhitespace($this->buffer, JsonParser::skipValue($this->buffer, $pos)); diff --git a/tests/JsonStreamer/Read/JsonDecoderTest.php b/tests/JsonStreamer/Read/JsonDecoderTest.php index f6876a9e..c84a11ca 100644 --- a/tests/JsonStreamer/Read/JsonDecoderTest.php +++ b/tests/JsonStreamer/Read/JsonDecoderTest.php @@ -163,6 +163,59 @@ public function testReadOnly(): void $object['a'] = 2; } + public function testDiscardBeforeFreesEarlierBytesAndGuardsAccess(): void + { + $buffer = new JsonBuffer('0123456789'); + + self::assertSame('5', $buffer->byteAt(5)); + $buffer->discardBefore(5); + + // Offsets stay absolute: 5 onward is still reachable... + self::assertSame('5', $buffer->byteAt(5)); + self::assertSame('789', $buffer->slice(7, 3)); + + // ...but a discarded offset now fails loudly instead of returning stale data. + $this->expectException(\LogicException::class); + $buffer->byteAt(4); + } + + public function testStreamingListDecodesEachElement(): void + { + $list = JsonDecoder::decode('[{"id":1},{"id":2},{"id":3}]', streaming: true); + + self::assertInstanceOf(LazyJsonList::class, $list); + + $ids = []; + foreach ($list as $item) { + self::assertInstanceOf(LazyJsonObject::class, $item); + $ids[] = $item['id']; + } + + self::assertSame([1, 2, 3], $ids); + } + + public function testStreamingListIsSinglePass(): void + { + $list = JsonDecoder::decode('[{"id":1},{"id":2}]', streaming: true); + + self::assertInstanceOf(LazyJsonList::class, $list); + iterator_to_array($list); // consuming discards the earlier elements + + // A second pass would start on already-freed bytes. + $this->expectException(\LogicException::class); + iterator_to_array($list); + } + + public function testStreamingIsOptInSoTopLevelListStaysReIterableByDefault(): void + { + $list = JsonDecoder::decode('[{"id":1},{"id":2}]'); + + self::assertInstanceOf(LazyJsonList::class, $list); + self::assertSame([['id' => 1], ['id' => 2]], array_map(iterator_to_array(...), iterator_to_array($list))); + // Not streaming: it can be iterated again. + self::assertCount(2, iterator_to_array($list)); + } + public function testDecodesFromANonRewoundChunkedStream(): void { $json = '{"name":"yolo","address":{"city":"Toulon"},"items":[1,2,3]}'; From 71f5499a2d1776fef25db651626860c5163f5f6f Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Thu, 23 Jul 2026 09:21:43 +0200 Subject: [PATCH 09/13] wip make it work with json stream decode function --- src/Attribute/Mapper.php | 4 ++ src/Event/GenerateMapperEvent.php | 4 ++ src/EventListener/MapperListener.php | 1 + src/Generator/PropertyConditionsGenerator.php | 24 +++------ .../JsonStreamDocumentListener.php | 49 +++++++++++++++++++ src/JsonStreamer/JsonStreamReader.php | 31 ++++++++---- src/Metadata/MapperMetadata.php | 33 ++++++++++++- src/Metadata/MetadataFactory.php | 15 ++++-- src/Transformer/AbstractArrayTransformer.php | 22 ++++++++- src/Transformer/ObjectTransformerFactory.php | 5 +- 10 files changed, 154 insertions(+), 34 deletions(-) create mode 100644 src/JsonStreamer/JsonStreamDocumentListener.php diff --git a/src/Attribute/Mapper.php b/src/Attribute/Mapper.php index ec1024a7..d0013b8d 100644 --- a/src/Attribute/Mapper.php +++ b/src/Attribute/Mapper.php @@ -17,6 +17,9 @@ * @param class-string|'array'|array|'array'>|null $source The source class or classes * @param class-string|'array'|array|'array'>|null $target The target class or classes * @param string|null $dateTimeFormat The date-time format to use when transforming this property + * @param bool|null $arrayLike Whether this class should be read/written as a keyed array shape (dynamic + * keys, `ArrayAccess`, ...) rather than as a typed object. Left null it is + * inferred (`array`, `stdClass`, `json`, {@see \AutoMapper\Lazy\LazyMapInterface}). */ public function __construct( public string|array|null $source = null, @@ -30,6 +33,7 @@ public function __construct( public ?string $dateTimeFormat = null, public ?bool $allowExtraProperties = null, public ?Discriminator $discriminator = null, + public ?bool $arrayLike = null, ) { } } diff --git a/src/Event/GenerateMapperEvent.php b/src/Event/GenerateMapperEvent.php index c4a58ff1..9d7c9d2a 100644 --- a/src/Event/GenerateMapperEvent.php +++ b/src/Event/GenerateMapperEvent.php @@ -30,6 +30,10 @@ public function __construct( public ?bool $allowExtraProperties = null, public ?Discriminator $sourceDiscriminator = null, public ?Discriminator $targetDiscriminator = null, + /** Whether the source is read as a keyed array shape rather than a typed object (null = infer). */ + public ?bool $sourceArrayLike = null, + /** Whether the target is built as a keyed array shape rather than a typed object (null = infer). */ + public ?bool $targetArrayLike = null, ) { } } diff --git a/src/EventListener/MapperListener.php b/src/EventListener/MapperListener.php index 366dd129..95ce0567 100644 --- a/src/EventListener/MapperListener.php +++ b/src/EventListener/MapperListener.php @@ -33,6 +33,7 @@ public function __invoke(GenerateMapperEvent $event): void $event->strictTypes ??= $directMapperAttribute->strictTypes; $event->allowExtraProperties ??= $directMapperAttribute->allowExtraProperties; $event->mapperMetadata->dateTimeFormat = $directMapperAttribute->dateTimeFormat; + $event->sourceArrayLike ??= $directMapperAttribute->arrayLike; if ($directMapperAttribute->discriminator) { if ($fromSource) { diff --git a/src/Generator/PropertyConditionsGenerator.php b/src/Generator/PropertyConditionsGenerator.php index 8c18d92f..c5bac024 100644 --- a/src/Generator/PropertyConditionsGenerator.php +++ b/src/Generator/PropertyConditionsGenerator.php @@ -7,8 +7,8 @@ use AutoMapper\AttributeReference\AttributeInstance; use AutoMapper\AttributeReference\Reference; use AutoMapper\Exception\CompileException; +use AutoMapper\Extractor\ArrayReadAccessor; use AutoMapper\Extractor\NestedReadAccessor; -use AutoMapper\Lazy\LazyMapInterface; use AutoMapper\MapperContext; use AutoMapper\Metadata\GeneratorMetadata; use AutoMapper\Metadata\PropertyMetadata; @@ -132,8 +132,12 @@ private function propertyExistsForStdClass(GeneratorMetadata $metadata, Property * * ```php * array_key_exists('propertyName', $source) // plain array source - * $source->offsetExists('propertyName') // LazyMapInterface (ArrayAccess) source + * $source->offsetExists('propertyName') // ArrayAccess source * ``` + * + * The exact check is provided by the read accessor itself, so it stays correct whatever the + * array-like source is (plain array, `LazyMapInterface`, native `ArrayAccess` document, ...) + * instead of being guessed from the source name. */ private function propertyExistsForArray(GeneratorMetadata $metadata, PropertyMetadata $propertyMetadata): ?Expr { @@ -141,20 +145,8 @@ private function propertyExistsForArray(GeneratorMetadata $metadata, PropertyMet return null; } - $source = $metadata->mapperMetadata->source; - $input = $metadata->variableRegistry->getSourceInput(); - - if ('array' === $source) { - return new Expr\FuncCall(new Name('array_key_exists'), [ - new Arg(new Scalar\String_($propertyMetadata->source->property)), - new Arg($input), - ]); - } - - if (is_a($source, LazyMapInterface::class, true)) { - return new Expr\MethodCall($input, 'offsetExists', [ - new Arg(new Scalar\String_($propertyMetadata->source->property)), - ]); + if ($propertyMetadata->source->accessor instanceof ArrayReadAccessor) { + return $propertyMetadata->source->accessor->getIsDefinedExpression($metadata->variableRegistry->getSourceInput(), nullable: true); } return null; diff --git a/src/JsonStreamer/JsonStreamDocumentListener.php b/src/JsonStreamer/JsonStreamDocumentListener.php new file mode 100644 index 00000000..17827b60 --- /dev/null +++ b/src/JsonStreamer/JsonStreamDocumentListener.php @@ -0,0 +1,49 @@ +mapperMetadata->source, self::DOCUMENT_CLASS, true)) { + $event->sourceArrayLike ??= true; + } + + if (is_a($event->mapperMetadata->target, self::DOCUMENT_CLASS, true)) { + $event->targetArrayLike ??= true; + } + } + + public function onPropertyMetadata(PropertyMetadataEvent $event): void + { + if (is_a($event->mapperMetadata->source, self::DOCUMENT_CLASS, true)) { + $event->source->accessor ??= new ArrayReadAccessor($event->source->property, isArrayAccess: true); + // Guard reads with offsetExists so a missing key is skipped rather than fetched (which + // would emit an "undefined key" warning on the read-only document). + $event->source->checkExists ??= true; + } + } +} diff --git a/src/JsonStreamer/JsonStreamReader.php b/src/JsonStreamer/JsonStreamReader.php index 164ced34..05471439 100644 --- a/src/JsonStreamer/JsonStreamReader.php +++ b/src/JsonStreamer/JsonStreamReader.php @@ -56,11 +56,18 @@ public function read($input, Type $type, array $options = []): mixed // the AutoMapper reads only the fields it maps. In streaming mode the decoder frees // each element once iterated past, so a large top-level array stays memory-bounded; // LazyCollection then maps each element as it is pulled. - $decoded = JsonDecoder::decode($input, streaming: !$buffered); - - if (!$decoded instanceof LazyJsonList && !$decoded instanceof LazyJsonObject) { - // The JSON is not a list or an object (null, scalar): nothing to iterate. - return $decoded; + if (function_exists('json_stream_decode')) { + // Only release consumed content (transient) in streaming mode: a buffered + // collection must stay re-readable, and releasing would also break the mapper's + // out-of-order field reads on a large document. + $decoded = json_stream_decode($input, $buffered ? 0 : JSON_STREAM_TRANSIENT); + } else { + $decoded = JsonDecoder::decode($input, streaming: !$buffered); + + if (!$decoded instanceof LazyJsonList && !$decoded instanceof LazyJsonObject) { + // The JSON is not a list or an object (null, scalar): nothing to iterate. + return $decoded; + } } return new LazyCollection( @@ -79,12 +86,16 @@ public function read($input, Type $type, array $options = []): mixed // Decode lazily: the object is exposed as an array-like source (LazyJsonObject) so the // AutoMapper reads only the fields it maps, straight from the stream, and nested // objects/lists stay lazy — no intermediate array is materialized. - $decoded = JsonDecoder::decode($input); + if (function_exists('json_stream_decode')) { + $decoded = json_stream_decode($input); + } else { + $decoded = JsonDecoder::decode($input, streaming: !$buffered); - if (!$decoded instanceof LazyJsonObject) { - // The JSON is not an object (null, scalar, list): nothing for the AutoMapper to - // hydrate, hand the decoded value back as-is. - return $decoded; + if (!$decoded instanceof LazyJsonObject) { + // The JSON is not an object (null, scalar, list): nothing for the AutoMapper to + // hydrate, hand the decoded value back as-is. + return $decoded; + } } return $this->mapper->map($decoded, $className, $options); diff --git a/src/Metadata/MapperMetadata.php b/src/Metadata/MapperMetadata.php index 636f426b..395388a0 100644 --- a/src/Metadata/MapperMetadata.php +++ b/src/Metadata/MapperMetadata.php @@ -18,6 +18,18 @@ class MapperMetadata /** @var \ReflectionClass|null */ public readonly ?\ReflectionClass $targetReflectionClass; + /** + * Whether the source is read as a keyed array shape rather than a typed object. Resolved once + * during metadata discovery: a `#[Mapper(arrayLike: ...)]` override, otherwise inferred. + */ + public ?bool $sourceArrayLike = null; + + /** + * Whether the target is built as a keyed array shape rather than a typed object. Resolved once + * during metadata discovery: a `#[Mapper(arrayLike: ...)]` override, otherwise inferred. + */ + public ?bool $targetArrayLike = null; + /** * @param class-string|'array' $source * @param class-string|'array' $target @@ -57,16 +69,33 @@ public function isJsonTarget(): bool * 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. + * + * Returns the value resolved during discovery ({@see $targetArrayLike}) when set, otherwise the + * inferred default. */ public function isTargetArrayLike(): bool { - return \in_array($this->target, ['array', \stdClass::class, 'json'], true) || is_a($this->target, LazyMapInterface::class, true); + return $this->targetArrayLike ?? $this->inferTargetArrayLike(); } /** - * Whether the source is read as an array shape rather than an object. + * Whether the source is read as an array shape rather than an object. Returns the value resolved + * during discovery ({@see $sourceArrayLike}) when set, otherwise the inferred default. */ public function isSourceArrayLike(): bool + { + return $this->sourceArrayLike ?? $this->inferSourceArrayLike(); + } + + /** + * The default array-like inference used when no `#[Mapper(arrayLike: ...)]` override is given. + */ + public function inferTargetArrayLike(): bool + { + return \in_array($this->target, ['array', \stdClass::class, 'json'], true) || is_a($this->target, LazyMapInterface::class, true); + } + + public function inferSourceArrayLike(): bool { return \in_array($this->source, ['array', \stdClass::class], true) || is_a($this->source, LazyMapInterface::class, true); } diff --git a/src/Metadata/MetadataFactory.php b/src/Metadata/MetadataFactory.php index 7071ac3c..b867dcb6 100644 --- a/src/Metadata/MetadataFactory.php +++ b/src/Metadata/MetadataFactory.php @@ -170,6 +170,14 @@ public function listMetadata(): iterable private function createGeneratorMetadata(MapperMetadata $mapperMetadata): GeneratorMetadata { + $mapperEvent = new GenerateMapperEvent($mapperMetadata); + $this->eventDispatcher->dispatch($mapperEvent); + + // Resolve "array-like" once, before choosing the extractor and before any property metadata + // event: honor a #[Mapper(arrayLike: ...)] override (via the event), otherwise infer it. + $mapperMetadata->sourceArrayLike = $mapperEvent->sourceArrayLike ?? $mapperMetadata->inferSourceArrayLike(); + $mapperMetadata->targetArrayLike = $mapperEvent->targetArrayLike ?? $mapperMetadata->inferTargetArrayLike(); + $extractor = $this->sourceTargetPropertiesMappingExtractor; if ($mapperMetadata->isSourceArrayLike()) { @@ -182,9 +190,6 @@ private function createGeneratorMetadata(MapperMetadata $mapperMetadata): Genera $propertyEvents = []; - $mapperEvent = new GenerateMapperEvent($mapperMetadata); - $this->eventDispatcher->dispatch($mapperEvent); - // First get properties from the source foreach ($extractor->getProperties($mapperMetadata->source) as $property) { $propertyEvent = new PropertyMetadataEvent($mapperMetadata, new SourcePropertyMetadataEvent($property), new TargetPropertyMetadataEvent($property), isFromDefaultExtractor: true); @@ -391,6 +396,10 @@ public static function create( $eventDispatcher->addListener(GenerateMapperEvent::class, new MapToListener($serviceLocator, $expressionLanguage)); $eventDispatcher->addListener(GenerateMapperEvent::class, new MapFromListener($serviceLocator, $expressionLanguage)); $eventDispatcher->addListener(GenerateMapperEvent::class, new MapperListener()); + + $jsonStreamDocumentListener = new \AutoMapper\JsonStreamer\JsonStreamDocumentListener(); + $eventDispatcher->addListener(GenerateMapperEvent::class, [$jsonStreamDocumentListener, 'onGenerateMapper']); + $eventDispatcher->addListener(PropertyMetadataEvent::class, [$jsonStreamDocumentListener, 'onPropertyMetadata']); $eventDispatcher->addListener(GenerateMapperEvent::class, new MapProviderListener()); if (interface_exists(ObjectMapperInterface::class)) { diff --git a/src/Transformer/AbstractArrayTransformer.php b/src/Transformer/AbstractArrayTransformer.php index cf353f38..b8f92a42 100644 --- a/src/Transformer/AbstractArrayTransformer.php +++ b/src/Transformer/AbstractArrayTransformer.php @@ -55,8 +55,26 @@ public function transform(Expr $input, Expr $target, PropertyMetadata $propertyM if ($propertyMapping->target->readAccessor !== null && $this->itemTransformer instanceof IdentifierHashInterface) { $existingValue = new Expr\Variable($uniqueVariableScope->getUniqueName('existingValue')); $hashValueTargetVariable = new Expr\Variable($uniqueVariableScope->getUniqueName('hashValueTarget')); - $itemStatements[] = new Stmt\Expression(new Expr\Assign($hashValueTargetVariable, $this->itemTransformer->getSourceHashExpression($loopValueVar))); - $itemStatements[] = new Stmt\Expression(new Expr\Assign($existingValue, new Expr\BinaryOp\Coalesce(new Expr\ArrayDimFetch($exisingValuesIndexed, $hashValueTargetVariable), new Expr\ConstFetch(new Name('null'))))); + + /* + * Only hash a source item to look up an existing target to merge into when there + * actually is an indexed target collection. Otherwise each source item would be read + * twice (once to hash, once to map), which breaks single-pass / transient sources. + * + * ```php + * $existingValue = null; + * if ($existingValuesIndexed) { + * $existingValue = $existingValuesIndexed[] ?? null; + * } + * ``` + */ + $itemStatements[] = new Stmt\Expression(new Expr\Assign($existingValue, new Expr\ConstFetch(new Name('null')))); + $itemStatements[] = new Stmt\If_($exisingValuesIndexed, [ + 'stmts' => [ + new Stmt\Expression(new Expr\Assign($hashValueTargetVariable, $this->itemTransformer->getSourceHashExpression($loopValueVar))), + new Stmt\Expression(new Expr\Assign($existingValue, new Expr\BinaryOp\Coalesce(new Expr\ArrayDimFetch($exisingValuesIndexed, $hashValueTargetVariable), new Expr\ConstFetch(new Name('null'))))), + ], + ]); } /* Get the transform statements for the source property */ diff --git a/src/Transformer/ObjectTransformerFactory.php b/src/Transformer/ObjectTransformerFactory.php index 600f5bce..a3fa02f4 100644 --- a/src/Transformer/ObjectTransformerFactory.php +++ b/src/Transformer/ObjectTransformerFactory.php @@ -56,7 +56,10 @@ private function isObjectType(Type $type): bool if ($className !== \stdClass::class) { $reflectionClass = new \ReflectionClass($className); - if ($reflectionClass->isInternal()) { + // Internal classes are not mappable as generic objects (DateTime, Closure, ...) — + // except array-like ones (ArrayAccess), which are read by key like an array source, + // e.g. the native `JsonStream\Document`. + if ($reflectionClass->isInternal() && !$reflectionClass->implementsInterface(\ArrayAccess::class)) { return false; } } From 774c592e9a56e07398c80f45751c4b076ee15d15 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Fri, 24 Jul 2026 15:04:10 +0200 Subject: [PATCH 10/13] feat(json-stream): remove custom parser / decoder for json stream, use json_stream_decode with extension or polyfill instead --- bench/composer.lock | 58 ++++- bench/src/DeserializeBench.php | 6 +- bench/src/Factory/MapperFactory.php | 2 +- composer.json | 9 +- src/JsonStreamer/JsonStreamReader.php | 38 +--- src/JsonStreamer/Read/JsonBuffer.php | 134 ----------- src/JsonStreamer/Read/JsonDecoder.php | 35 --- src/JsonStreamer/Read/JsonParser.php | 140 ------------ src/JsonStreamer/Read/LazyJsonList.php | 71 ------ src/JsonStreamer/Read/LazyJsonObject.php | 167 -------------- tests/JsonStreamer/Read/JsonDecoderTest.php | 235 -------------------- 11 files changed, 68 insertions(+), 827 deletions(-) delete mode 100644 src/JsonStreamer/Read/JsonBuffer.php delete mode 100644 src/JsonStreamer/Read/JsonDecoder.php delete mode 100644 src/JsonStreamer/Read/JsonParser.php delete mode 100644 src/JsonStreamer/Read/LazyJsonList.php delete mode 100644 src/JsonStreamer/Read/LazyJsonObject.php delete mode 100644 tests/JsonStreamer/Read/JsonDecoderTest.php diff --git a/bench/composer.lock b/bench/composer.lock index 97cd51f4..32da351f 100644 --- a/bench/composer.lock +++ b/bench/composer.lock @@ -208,16 +208,70 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "joelwurtz/json-stream-polyfill", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/joelwurtz/php-json-stream-polyfill.git", + "reference": "0136c414d2100f779dffd8910e5d591f97845004" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/joelwurtz/php-json-stream-polyfill/zipball/0136c414d2100f779dffd8910e5d591f97845004", + "reference": "0136c414d2100f779dffd8910e5d591f97845004", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=8.1" + }, + "provide": { + "ext-json_stream": "*" + }, + "default-branch": true, + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joel Wurtz", + "email": "joel.wurtz@gmail.com" + } + ], + "description": "Pure-PHP polyfill for the json_stream extension", + "keywords": [ + "json", + "lazy", + "parser", + "polyfill", + "stream", + "streaming" + ], + "support": { + "issues": "https://github.com/joelwurtz/php-json-stream-polyfill/issues", + "source": "https://github.com/joelwurtz/php-json-stream-polyfill" + }, + "time": "2026-07-24T12:54:26+00:00" + }, { "name": "jolicode/automapper", - "version": "dev-feat/json-streamer", + "version": "dev-feat/lazy-map-interface", "dist": { "type": "path", "url": "..", - "reference": "de70615488cd16975e80d4b6e0862473af1abee7" + "reference": "71f5499a2d1776fef25db651626860c5163f5f6f" }, "require": { "composer-runtime-api": "^2.1 || ^3.0", + "joelwurtz/json-stream-polyfill": "@dev", "nikic/php-parser": "^5.6", "php": "^8.4", "phpdocumentor/type-resolver": "^1.12 || ^2.0", diff --git a/bench/src/DeserializeBench.php b/bench/src/DeserializeBench.php index b3465f37..1dc73cdd 100644 --- a/bench/src/DeserializeBench.php +++ b/bench/src/DeserializeBench.php @@ -103,16 +103,16 @@ public function benchSymfonySerializer(): void public function benchSymfonyJsonStreamer(): void { - $this->realize(MapperFactory::jsonStreamReader()->read($this->stream, $this->type)); + $this->realize(MapperFactory::jsonStreamReader()->read(PayloadFactory::stream($this->json), $this->type)); } public function benchAutoMapperJsonStreamer(): void { - $this->realize(MapperFactory::autoMapperJsonStreamReader()->read($this->stream, $this->type)); + $this->realize(MapperFactory::autoMapperJsonStreamReader()->read(PayloadFactory::stream($this->json), $this->type)); } public function benchAutoMapperJsonStreamerNoAttributeChecking(): void { - $this->realize(MapperFactory::autoMapperNoAttributeJsonStreamReader()->read($this->stream, $this->type)); + $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 index e7189d8a..7945994e 100644 --- a/bench/src/Factory/MapperFactory.php +++ b/bench/src/Factory/MapperFactory.php @@ -209,7 +209,7 @@ public static function autoMapperNoAttributeJsonStreamReader(): AutoMapperJsonSt public static function autoMapperNoAttributeJsonStreamWriter(): AutoMapperJsonStreamWriter { return self::$autoMapperNoAttributeJsonStreamWriter ??= new AutoMapperJsonStreamWriter( - self::autoMapperNoChecking(), + self::autoMapperNoAttributeChecking(), self::jsonStreamWriter(), ); } diff --git a/composer.json b/composer.json index 848de4c8..18eaa7ec 100644 --- a/composer.json +++ b/composer.json @@ -18,17 +18,18 @@ "require": { "php": "^8.4", "composer-runtime-api": "^2.1 || ^3.0", + "joelwurtz/json-stream-polyfill": "@dev", "nikic/php-parser": "^5.6", + "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-info": "^7.4 || ^8.0", "symfony/property-access": "^7.4 || ^8.0", - "symfony/type-info": "^7.4 || ^8.0", - "phpdocumentor/type-resolver": "^1.12 || ^2.0", - "phpstan/phpdoc-parser": "^2.3" + "symfony/property-info": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "require-dev": { "api-platform/core": "^4.2", diff --git a/src/JsonStreamer/JsonStreamReader.php b/src/JsonStreamer/JsonStreamReader.php index 05471439..b7768d90 100644 --- a/src/JsonStreamer/JsonStreamReader.php +++ b/src/JsonStreamer/JsonStreamReader.php @@ -5,9 +5,6 @@ namespace AutoMapper\JsonStreamer; use AutoMapper\AutoMapperInterface; -use AutoMapper\JsonStreamer\Read\JsonDecoder; -use AutoMapper\JsonStreamer\Read\LazyJsonList; -use AutoMapper\JsonStreamer\Read\LazyJsonObject; use AutoMapper\Lazy\LazyCollection; use AutoMapper\MapperContext; use Symfony\Component\JsonStreamer\StreamReaderInterface; @@ -51,24 +48,7 @@ public function read($input, Type $type, array $options = []): mixed if ($className !== null) { $buffered = !($options[MapperContext::STREAM] ?? false); - - // Decode lazily: each element is exposed as an array-like source (LazyJsonObject) so - // the AutoMapper reads only the fields it maps. In streaming mode the decoder frees - // each element once iterated past, so a large top-level array stays memory-bounded; - // LazyCollection then maps each element as it is pulled. - if (function_exists('json_stream_decode')) { - // Only release consumed content (transient) in streaming mode: a buffered - // collection must stay re-readable, and releasing would also break the mapper's - // out-of-order field reads on a large document. - $decoded = json_stream_decode($input, $buffered ? 0 : JSON_STREAM_TRANSIENT); - } else { - $decoded = JsonDecoder::decode($input, streaming: !$buffered); - - if (!$decoded instanceof LazyJsonList && !$decoded instanceof LazyJsonObject) { - // The JSON is not a list or an object (null, scalar): nothing to iterate. - return $decoded; - } - } + $decoded = json_stream_decode($input, $buffered ? 0 : JSON_STREAM_TRANSIENT); return new LazyCollection( fn (mixed $rawItem): mixed => \is_array($rawItem) || \is_object($rawItem) @@ -83,20 +63,8 @@ public function read($input, Type $type, array $options = []): mixed $className = $this->ownedClassName($unwrapped); if ($className !== null) { - // Decode lazily: the object is exposed as an array-like source (LazyJsonObject) so the - // AutoMapper reads only the fields it maps, straight from the stream, and nested - // objects/lists stay lazy — no intermediate array is materialized. - if (function_exists('json_stream_decode')) { - $decoded = json_stream_decode($input); - } else { - $decoded = JsonDecoder::decode($input, streaming: !$buffered); - - if (!$decoded instanceof LazyJsonObject) { - // The JSON is not an object (null, scalar, list): nothing for the AutoMapper to - // hydrate, hand the decoded value back as-is. - return $decoded; - } - } + $buffered = !($options[MapperContext::STREAM] ?? false); + $decoded = json_stream_decode($input, $buffered ? 0 : JSON_STREAM_TRANSIENT); return $this->mapper->map($decoded, $className, $options); } diff --git a/src/JsonStreamer/Read/JsonBuffer.php b/src/JsonStreamer/Read/JsonBuffer.php deleted file mode 100644 index 92818e41..00000000 --- a/src/JsonStreamer/Read/JsonBuffer.php +++ /dev/null @@ -1,134 +0,0 @@ - $chunkSize - */ - public function __construct( - mixed $input, - private readonly int $chunkSize = 8192, - ) { - if (\is_string($input)) { - $this->data = $input; - $this->eof = true; - $this->stream = null; - - return; - } - - if (!\is_resource($input)) { - throw new \InvalidArgumentException(\sprintf('The JSON buffer expects a string or a stream resource, "%s" given.', get_debug_type($input))); - } - - $this->stream = $input; - } - - /** - * The byte at $offset, or null once the input is exhausted at that offset. - */ - public function byteAt(int $offset): ?string - { - $this->fillTo($offset); - $local = $this->localize($offset); - - return $local < \strlen($this->data) ? $this->data[$local] : null; - } - - /** - * The $length bytes starting at $offset (possibly shorter at the end of the input). - */ - public function slice(int $offset, int $length): string - { - $this->fillTo($offset + $length - 1); - - return substr($this->data, $this->localize($offset), $length); - } - - /** - * Free every byte before $offset. Later access below $offset is no longer possible; callers - * must only discard past data they are certain no live node will read again (the streaming - * top-level list discards each element once it has advanced past it). - */ - public function discardBefore(int $offset): void - { - if ($offset <= $this->base) { - return; - } - - $this->fillTo($offset - 1); - $drop = min($offset - $this->base, \strlen($this->data)); - $this->data = substr($this->data, $drop); - $this->base += $drop; - } - - /** - * Translate an absolute offset into an index inside $data, failing loudly on discarded bytes. - */ - private function localize(int $offset): int - { - if ($offset < $this->base) { - throw new \LogicException(\sprintf('Byte at offset %d was already discarded from the JSON buffer.', $offset)); - } - - return $offset - $this->base; - } - - /** - * Pull bytes from the stream until $offset is available or the input is exhausted. - */ - private function fillTo(int $offset): void - { - while (!$this->eof && $offset - $this->base >= \strlen($this->data)) { - if (!\is_resource($this->stream)) { - $this->eof = true; - - break; - } - - $chunk = fread($this->stream, $this->chunkSize); - - if ($chunk === false || $chunk === '') { - $this->eof = true; - - break; - } - - $this->data .= $chunk; - } - } -} diff --git a/src/JsonStreamer/Read/JsonDecoder.php b/src/JsonStreamer/Read/JsonDecoder.php deleted file mode 100644 index 5768d5c7..00000000 --- a/src/JsonStreamer/Read/JsonDecoder.php +++ /dev/null @@ -1,35 +0,0 @@ -byteAt($pos) === '[') { - return new LazyJsonList($buffer, $pos, streaming: true); - } - - return JsonParser::parseValue($buffer, $pos); - } -} diff --git a/src/JsonStreamer/Read/JsonParser.php b/src/JsonStreamer/Read/JsonParser.php deleted file mode 100644 index f30f85c8..00000000 --- a/src/JsonStreamer/Read/JsonParser.php +++ /dev/null @@ -1,140 +0,0 @@ -byteAt($pos)) !== null && ($c === ' ' || $c === "\t" || $c === "\n" || $c === "\r")) { - ++$pos; - } - - return $pos; - } - - /** - * The offset right after the value that starts at $pos (which must sit on the first, - * whitespace-stripped, byte of the value). - */ - public static function skipValue(JsonBuffer $buffer, int $pos): int - { - $c = $buffer->byteAt($pos); - - if ($c === '{' || $c === '[') { - return self::skipStructure($buffer, $pos); - } - - if ($c === '"') { - return self::skipString($buffer, $pos); - } - - return self::skipScalar($buffer, $pos); - } - - /** - * Decode the value starting at $pos: a scalar becomes its PHP value, while an object or an - * array becomes a lazy node that is not parsed until it is itself accessed. - */ - public static function parseValue(JsonBuffer $buffer, int $pos): mixed - { - $c = $buffer->byteAt($pos); - - if ($c === '{') { - return new LazyJsonObject($buffer, $pos); - } - - if ($c === '[') { - return new LazyJsonList($buffer, $pos); - } - - $end = $c === '"' ? self::skipString($buffer, $pos) : self::skipScalar($buffer, $pos); - - return json_decode($buffer->slice($pos, $end - $pos), true, 512, \JSON_THROW_ON_ERROR); - } - - /** - * Match a `{...}` or `[...]` structure and return the offset right after its closing bracket, - * without decoding its content. Strings are skipped as a whole so brackets inside them do not - * affect the depth count. - */ - private static function skipStructure(JsonBuffer $buffer, int $pos): int - { - $depth = 0; - - while (($c = $buffer->byteAt($pos)) !== null) { - if ($c === '"') { - $pos = self::skipString($buffer, $pos); - - continue; - } - - if ($c === '{' || $c === '[') { - ++$depth; - } elseif ($c === '}' || $c === ']') { - --$depth; - - if ($depth === 0) { - return $pos + 1; - } - } - - ++$pos; - } - - return $pos; - } - - /** - * The offset right after the string that starts at the opening quote at $pos. - */ - private static function skipString(JsonBuffer $buffer, int $pos): int - { - ++$pos; // opening quote - - while (($c = $buffer->byteAt($pos)) !== null) { - if ($c === '\\') { - $pos += 2; // an escaped char never ends the string (\uXXXX hex digits are plain bytes) - - continue; - } - - if ($c === '"') { - return $pos + 1; - } - - ++$pos; - } - - return $pos; - } - - /** - * The offset right after a scalar literal (number, `true`, `false`, `null`) starting at $pos. - */ - private static function skipScalar(JsonBuffer $buffer, int $pos): int - { - while (($c = $buffer->byteAt($pos)) !== null && $c !== ',' && $c !== '}' && $c !== ']' - && $c !== ' ' && $c !== "\t" && $c !== "\n" && $c !== "\r") { - ++$pos; - } - - return $pos; - } -} diff --git a/src/JsonStreamer/Read/LazyJsonList.php b/src/JsonStreamer/Read/LazyJsonList.php deleted file mode 100644 index cda747c7..00000000 --- a/src/JsonStreamer/Read/LazyJsonList.php +++ /dev/null @@ -1,71 +0,0 @@ - - * - * @internal - */ -final class LazyJsonList implements \IteratorAggregate, \JsonSerializable -{ - public function __construct( - private readonly JsonBuffer $buffer, - private readonly int $start, - private readonly bool $streaming = false, - ) { - } - - /** - * @return \Generator - */ - public function getIterator(): \Generator - { - $pos = JsonParser::skipWhitespace($this->buffer, $this->start + 1); // move past '[' - - if ($this->buffer->byteAt($pos) === ']') { - return; - } - - $index = 0; - - while (true) { - if ($this->streaming) { - // The previous element has been fully consumed by now: free it. - $this->buffer->discardBefore($pos); - } - - yield $index++ => JsonParser::parseValue($this->buffer, $pos); - - $pos = JsonParser::skipWhitespace($this->buffer, JsonParser::skipValue($this->buffer, $pos)); - - if ($this->buffer->byteAt($pos) !== ',') { - return; // closing bracket or exhausted input - } - - $pos = JsonParser::skipWhitespace($this->buffer, $pos + 1); - } - } - - /** - * @return array - */ - public function jsonSerialize(): array - { - return iterator_to_array($this); - } -} diff --git a/src/JsonStreamer/Read/LazyJsonObject.php b/src/JsonStreamer/Read/LazyJsonObject.php deleted file mode 100644 index b8e9ea69..00000000 --- a/src/JsonStreamer/Read/LazyJsonObject.php +++ /dev/null @@ -1,167 +0,0 @@ - - * @implements \IteratorAggregate - * - * @internal - */ -final class LazyJsonObject implements LazyMapInterface, \IteratorAggregate, \JsonSerializable -{ - /** - * Discovered keys, in document order, so iteration and re-lookup are stable. - * - * @var list - */ - private array $keys = []; - - /** @var array key => offset of its value */ - private array $offsets = []; - - /** Offset to resume scanning key/value pairs from. */ - private int $cursor; - - private bool $complete = false; - - public function __construct( - private readonly JsonBuffer $buffer, - int $start, - ) { - $this->cursor = $start + 1; // move past the opening brace - } - - public function offsetExists(mixed $offset): bool - { - return $this->locate((string) $offset) !== null; - } - - public function offsetGet(mixed $offset): mixed - { - $valueOffset = $this->locate((string) $offset); - - if ($valueOffset === null) { - return null; - } - - return JsonParser::parseValue($this->buffer, $valueOffset); - } - - public function offsetSet(mixed $offset, mixed $value): void - { - throw new \LogicException('A lazily decoded JSON object is read-only.'); - } - - public function offsetUnset(mixed $offset): void - { - throw new \LogicException('A lazily decoded JSON object is read-only.'); - } - - /** - * @return \Generator - */ - public function getIterator(): \Generator - { - $index = 0; - - while (true) { - if ($index < \count($this->keys)) { - $key = $this->keys[$index++]; - - yield $key => JsonParser::parseValue($this->buffer, $this->offsets[$key]); - - continue; - } - - if (!$this->scanNext()) { - return; - } - } - } - - /** - * @return array - */ - public function jsonSerialize(): array - { - return iterator_to_array($this); - } - - /** - * Offset of the value for $key, scanning further into the object if needed, or null when the - * key is absent. - */ - private function locate(string $key): ?int - { - if (\array_key_exists($key, $this->offsets)) { - return $this->offsets[$key]; - } - - while (!$this->complete) { - if ($this->scanNext() && $this->keys[\count($this->keys) - 1] === $key) { - return $this->offsets[$key]; - } - } - - return null; - } - - /** - * Read the next key/value pair, recording it, and return whether one was found (false at the - * object's closing brace or at the end of the input). - */ - private function scanNext(): bool - { - if ($this->complete) { - return false; - } - - $pos = JsonParser::skipWhitespace($this->buffer, $this->cursor); - $c = $this->buffer->byteAt($pos); - - if ($c === ',') { - $pos = JsonParser::skipWhitespace($this->buffer, $pos + 1); - $c = $this->buffer->byteAt($pos); - } - - if ($c !== '"') { // closing brace, or malformed/exhausted input - $this->complete = true; - - return false; - } - - [$key, $afterKey] = $this->readKey($pos); - $valueOffset = JsonParser::skipWhitespace($this->buffer, $afterKey + 1); // move past ':' - - $this->keys[] = $key; - $this->offsets[$key] = $valueOffset; - $this->cursor = JsonParser::skipValue($this->buffer, $valueOffset); - - return true; - } - - /** - * @return array{string, int} the decoded key and the offset of the byte after it (the `:`) - */ - private function readKey(int $pos): array - { - $end = JsonParser::skipValue($this->buffer, $pos); - /** @var string $key */ - $key = json_decode($this->buffer->slice($pos, $end - $pos), true, 512, \JSON_THROW_ON_ERROR); - - return [$key, JsonParser::skipWhitespace($this->buffer, $end)]; - } -} diff --git a/tests/JsonStreamer/Read/JsonDecoderTest.php b/tests/JsonStreamer/Read/JsonDecoderTest.php deleted file mode 100644 index c84a11ca..00000000 --- a/tests/JsonStreamer/Read/JsonDecoderTest.php +++ /dev/null @@ -1,235 +0,0 @@ -offsetExists('name')); - self::assertFalse($object->offsetExists('missing')); - self::assertSame('yolo', $object['name']); - self::assertSame(13, $object['age']); - self::assertNull($object['missing']); - } - - public function testScalarTypesArePreserved(): void - { - $object = JsonDecoder::decode('{"i":-42,"f":20.1,"e":1.5e3,"t":true,"f2":false,"n":null,"s":"x"}'); - - self::assertInstanceOf(LazyJsonObject::class, $object); - self::assertSame(-42, $object['i']); - self::assertSame(20.1, $object['f']); - self::assertSame(1500.0, $object['e']); - self::assertTrue($object['t']); - self::assertFalse($object['f2']); - self::assertNull($object['n']); - self::assertSame('x', $object['s']); - } - - public function testFieldsCanBeReadOutOfDocumentOrder(): void - { - $object = JsonDecoder::decode('{"a":1,"b":2,"c":3}'); - - self::assertInstanceOf(LazyJsonObject::class, $object); - // Read the last field first, then an earlier one: the buffer lets us come back. - self::assertSame(3, $object['c']); - self::assertSame(1, $object['a']); - self::assertSame(2, $object['b']); - } - - public function testStringEscapesAndUnicodeAreDecoded(): void - { - $object = JsonDecoder::decode('{"quote":"a\"b","unicode":"é","slash":"a\\/b","brace":"{not:a,field}"}'); - - self::assertInstanceOf(LazyJsonObject::class, $object); - self::assertSame('a"b', $object['quote']); - self::assertSame('é', $object['unicode']); - self::assertSame('a/b', $object['slash']); - // Braces inside a string must not be mistaken for structure. - self::assertSame('{not:a,field}', $object['brace']); - } - - public function testNestedObjectStaysLazy(): void - { - $object = JsonDecoder::decode('{"user":{"city":"Toulon","zip":"83000"}}'); - - self::assertInstanceOf(LazyJsonObject::class, $object); - $nested = $object['user']; - self::assertInstanceOf(LazyJsonObject::class, $nested); - self::assertSame('Toulon', $nested['city']); - self::assertSame('83000', $nested['zip']); - } - - public function testListIsLazyAndReIterable(): void - { - $object = JsonDecoder::decode('{"items":[1,2,3]}'); - - self::assertInstanceOf(LazyJsonObject::class, $object); - $list = $object['items']; - self::assertInstanceOf(LazyJsonList::class, $list); - - self::assertSame([1, 2, 3], iterator_to_array($list)); - // Re-iterable: iterating again yields the same values. - self::assertSame([1, 2, 3], iterator_to_array($list)); - } - - public function testTopLevelListDecodesToLazyList(): void - { - $list = JsonDecoder::decode('[{"id":1},{"id":2}]'); - - self::assertInstanceOf(LazyJsonList::class, $list); - - $ids = []; - foreach ($list as $item) { - self::assertInstanceOf(LazyJsonObject::class, $item); - $ids[] = $item['id']; - } - - self::assertSame([1, 2], $ids); - } - - public function testEmptyObjectAndArray(): void - { - $object = JsonDecoder::decode('{"empty_obj":{},"empty_arr":[]}'); - - self::assertInstanceOf(LazyJsonObject::class, $object); - self::assertSame([], iterator_to_array($object['empty_obj'])); - self::assertSame([], iterator_to_array($object['empty_arr'])); - } - - public function testIterationPreservesKeyOrder(): void - { - $object = JsonDecoder::decode('{"b":1,"a":2,"c":3}'); - - self::assertInstanceOf(LazyJsonObject::class, $object); - self::assertSame(['b' => 1, 'a' => 2, 'c' => 3], iterator_to_array($object)); - } - - public function testIterationAfterPartialAccessStillYieldsEverything(): void - { - $object = JsonDecoder::decode('{"a":1,"b":2,"c":3}'); - - self::assertInstanceOf(LazyJsonObject::class, $object); - self::assertSame(2, $object['b']); // partially scan first - self::assertSame(['a' => 1, 'b' => 2, 'c' => 3], iterator_to_array($object)); - } - - public function testWhitespaceIsTolerated(): void - { - $object = JsonDecoder::decode("{\n \"a\" : 1 ,\n \"b\" : [ 1 , 2 ]\n}"); - - self::assertInstanceOf(LazyJsonObject::class, $object); - self::assertSame(1, $object['a']); - self::assertSame([1, 2], iterator_to_array($object['b'])); - } - - public function testJsonSerializeRoundTrips(): void - { - $json = '{"name":"yolo","address":{"city":"Toulon"},"tags":["a","b"]}'; - $object = JsonDecoder::decode($json); - - self::assertInstanceOf(LazyJsonObject::class, $object); - self::assertJsonStringEqualsJsonString($json, json_encode($object)); - } - - public function testReadOnly(): void - { - $object = JsonDecoder::decode('{"a":1}'); - - self::assertInstanceOf(LazyJsonObject::class, $object); - $this->expectException(\LogicException::class); - $object['a'] = 2; - } - - public function testDiscardBeforeFreesEarlierBytesAndGuardsAccess(): void - { - $buffer = new JsonBuffer('0123456789'); - - self::assertSame('5', $buffer->byteAt(5)); - $buffer->discardBefore(5); - - // Offsets stay absolute: 5 onward is still reachable... - self::assertSame('5', $buffer->byteAt(5)); - self::assertSame('789', $buffer->slice(7, 3)); - - // ...but a discarded offset now fails loudly instead of returning stale data. - $this->expectException(\LogicException::class); - $buffer->byteAt(4); - } - - public function testStreamingListDecodesEachElement(): void - { - $list = JsonDecoder::decode('[{"id":1},{"id":2},{"id":3}]', streaming: true); - - self::assertInstanceOf(LazyJsonList::class, $list); - - $ids = []; - foreach ($list as $item) { - self::assertInstanceOf(LazyJsonObject::class, $item); - $ids[] = $item['id']; - } - - self::assertSame([1, 2, 3], $ids); - } - - public function testStreamingListIsSinglePass(): void - { - $list = JsonDecoder::decode('[{"id":1},{"id":2}]', streaming: true); - - self::assertInstanceOf(LazyJsonList::class, $list); - iterator_to_array($list); // consuming discards the earlier elements - - // A second pass would start on already-freed bytes. - $this->expectException(\LogicException::class); - iterator_to_array($list); - } - - public function testStreamingIsOptInSoTopLevelListStaysReIterableByDefault(): void - { - $list = JsonDecoder::decode('[{"id":1},{"id":2}]'); - - self::assertInstanceOf(LazyJsonList::class, $list); - self::assertSame([['id' => 1], ['id' => 2]], array_map(iterator_to_array(...), iterator_to_array($list))); - // Not streaming: it can be iterated again. - self::assertCount(2, iterator_to_array($list)); - } - - public function testDecodesFromANonRewoundChunkedStream(): void - { - $json = '{"name":"yolo","address":{"city":"Toulon"},"items":[1,2,3]}'; - $stream = fopen('php://temp', 'r+'); - fwrite($stream, $json); - rewind($stream); - - // Tiny chunk size to force many reads and exercise the on-demand fill path. - $buffer = new JsonBuffer($stream, chunkSize: 4); - $object = JsonParser::parseValue($buffer, JsonParser::skipWhitespace($buffer, 0)); - - self::assertInstanceOf(LazyJsonObject::class, $object); - self::assertSame('yolo', $object['name']); - self::assertSame('Toulon', $object['address']['city']); - self::assertSame([1, 2, 3], iterator_to_array($object['items'])); - } -} From 2a8cb760e1cd5b0de6fec734c3568e47706e9550 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Tue, 28 Jul 2026 00:02:02 +0200 Subject: [PATCH 11/13] feat(json-streamer): symfony bundle integration and array-like metadata Add a `json_streamer` option to the bundle, replacing the symfony/json-streamer reader and writer with the AutoMapper ones by decoration, so the Symfony implementations stay as a fallback for the types the AutoMapper does not handle. Both can be made registry aware with `only_registered_mapping`, delegating any unregistered type to the Symfony implementation. The `StreamReaderInterface` and `StreamWriterInterface` are aliased as well, since Symfony only aliases the concrete classes. Resolve the "array-like" decision once during metadata discovery, from a `#[Mapper(arrayLike: ...)]` override or the inferred default, and use it everywhere instead of guessing from the source and target names. This removes the `LazyMapInterface` marker: `LazyMap` now carries the attribute, and the flag is threaded into the mapping extractor, so any `ArrayAccess` class can be read by key. Fixes on the json target: * report a circular reference instead of recursing forever, and increment the context depth so `#[MaxDepth]` keeps working; * stream nullable nested objects and object lists through their json sub-mapper instead of falling back to a buffered `json_encode`; * write a dict as a JSON object keeping its keys, instead of a JSON array; * encode with `JSON_THROW_ON_ERROR`, like the Symfony writer, so an unencodable value fails loudly instead of producing invalid JSON, and stop turning a falsy encoding such as `0` into `null`. `map($object, 'json')` now throws: `json` is a serialization target, only reachable through the JsonStreamer integration. A dedicated API may expose it later. --- CHANGELOG.md | 11 ++ composer.json | 6 +- docs/_nav.md | 1 + docs/bundle/configuration.md | 8 + docs/bundle/index.md | 1 + docs/bundle/json-streamer.md | 148 ++++++++++++++++++ docs/getting-started/source-and-target.md | 1 - phpstan.neon | 6 + src/Attribute/Mapper.php | 2 +- src/AutoMapper.php | 6 + src/EventListener/MapperListener.php | 9 +- src/Extractor/MappingExtractor.php | 25 +-- src/Extractor/MappingExtractorInterface.php | 8 +- .../JsonMapMethodStatementsGenerator.php | 96 ++++++++++++ .../JsonPropertyStatementsGenerator.php | 24 ++- src/Generator/PropertyConditionsGenerator.php | 2 +- .../JsonStreamDocumentListener.php | 5 +- src/JsonStreamer/JsonStreamReader.php | 13 +- src/JsonStreamer/JsonStreamWriter.php | 45 ++++-- src/Lazy/LazyMap.php | 11 +- src/Lazy/LazyMapInterface.php | 26 --- src/Metadata/MapperMetadata.php | 5 +- src/Metadata/MetadataFactory.php | 12 +- .../AutoMapperExtension.php | 20 +++ .../DependencyInjection/Configuration.php | 8 + .../Bundle/Resources/config/json_streamer.php | 40 +++++ .../ArrayShapeTransformerFactory.php | 4 +- src/Transformer/ArrayTransformerFactory.php | 7 +- src/Transformer/NullableTransformer.php | 5 + .../AutoMapperExtensionTest.php | 43 +++++ .../Resources/config/packages/automapper.yaml | 2 + .../Resources/config/packages/framework.yaml | 2 + tests/Bundle/ServiceInstantiationTest.php | 37 +++++ tests/Fixtures/CircularNode.php | 15 ++ .../JsonStreamWriterCollectionTest.php | 118 ++++++++++++++ tests/JsonStreamer/JsonStreamWriterTest.php | 11 +- tests/JsonStreamer/RegistryAwareTest.php | 127 +++++++++++++++ tests/Metadata/ArrayLikeTest.php | 127 +++++++++++++++ 38 files changed, 953 insertions(+), 84 deletions(-) create mode 100644 docs/bundle/json-streamer.md delete mode 100644 src/Lazy/LazyMapInterface.php create mode 100644 src/Symfony/Bundle/Resources/config/json_streamer.php create mode 100644 tests/Fixtures/CircularNode.php create mode 100644 tests/JsonStreamer/JsonStreamWriterCollectionTest.php create mode 100644 tests/JsonStreamer/RegistryAwareTest.php create mode 100644 tests/Metadata/ArrayLikeTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e41a88bd..8c582357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Feature - Allow to disable group checking globally or per mapper, instead of only per property +- Add a `symfony/json-streamer` integration: the AutoMapper can now read and write JSON streams, + keeping all its mapping features available while streaming. Enable it in the bundle with the + `json_streamer.enabled` option, which decorates the Symfony reader and writer and keeps them as a + fallback for the types the AutoMapper does not handle. Use `json_streamer.only_registered_mapping` + to restrict it to the registered mappings. See the [documentation](docs/bundle/json-streamer.md) +- Add a `MapperContext::STREAM` option, to read and write collections without buffering them, so the + memory usage stays flat whatever the collection size +- Add an `arrayLike` option on the `#[Mapper]` attribute, to read or write a class as a keyed array + shape (dynamic keys, `ArrayAccess`, ...) instead of a typed object. The array-like decision is now + resolved once during metadata discovery and reused everywhere, instead of being guessed from the + source and target names ### Fixed - Use the `MapFrom` attribute reference instead of `MapTo` when resolving transformers in `MapFromListener` diff --git a/composer.json b/composer.json index 18eaa7ec..2638d530 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "require": { "php": "^8.4", "composer-runtime-api": "^2.1 || ^3.0", - "joelwurtz/json-stream-polyfill": "@dev", + "joelwurtz/json-stream-polyfill": "^0.1.0", "nikic/php-parser": "^5.6", "phpdocumentor/type-resolver": "^1.12 || ^2.0", "phpstan/phpdoc-parser": "^2.3", @@ -60,7 +60,9 @@ "willdurand/negotiation": "^3.1" }, "suggest": { - "symfony/serializer": "Allow to use symfony serializer attributes in mapping" + "symfony/serializer": "Allow to use symfony serializer attributes in mapping", + "symfony/json-streamer": "Allow to read and write JSON streams through the AutoMapper", + "ext-json_stream": "Faster JSON stream decoding, replaces the joelwurtz/json-stream-polyfill package (pie install joelwurtz/json-stream)" }, "conflict": { "api-platform/core": "<3", diff --git a/docs/_nav.md b/docs/_nav.md index 8f0a92ee..4c90dada 100644 --- a/docs/_nav.md +++ b/docs/_nav.md @@ -26,6 +26,7 @@ - [Expression Language](bundle/expression-language.md) - [Api Platform](bundle/api-platform.md) - [Object Mapper](bundle/object-mapper.md) + - [JSON Streamer](bundle/json-streamer.md) - [Migrate existing application](bundle/migrate.md) - [Debugging](bundle/debugging.md) - [Upgrading](upgrading/upgrading-10.0.md) diff --git a/docs/bundle/configuration.md b/docs/bundle/configuration.md index 12bbfad7..2331e746 100644 --- a/docs/bundle/configuration.md +++ b/docs/bundle/configuration.md @@ -25,6 +25,9 @@ automapper: eval: false cache_dir: "%kernel.cache_dir%/automapper" reload_strategy: "always" + json_streamer: + enabled: false + only_registered_mapping: false serializer_attributes: true api_platform: false object_mapper: false @@ -78,6 +81,11 @@ automapper: * `always` will generate the mappers at each request; * `never` will generate them only if they don't exist; * `on_change` will generate the mappers only if the source or target class has changed since the last generation; +* `json_streamer`: Configure the [JSON Streamer integration](json-streamer.md); + * `enabled` (default: `false`): If the symfony/json-streamer reader and writer should be replaced by the AutoMapper + ones, the Symfony implementations being kept as a fallback for the types the AutoMapper does not handle; + * `only_registered_mapping` (default: `false`): If the JSON streamer should only use the registered mapping, any + other type falling back to the Symfony implementation; * `serializer_attributes` (default: `true` if the symfony/serializer is available, false otherwise): A boolean which indicate if we use the attribute of the symfony/serializer during the mapping, this only apply to the `#[Groups]`, `#[MaxDepth]`, `#[Ignore]` and `#[DiscriminatorMap]` attributes; diff --git a/docs/bundle/index.md b/docs/bundle/index.md index 443270dc..ea6fd97a 100644 --- a/docs/bundle/index.md +++ b/docs/bundle/index.md @@ -9,5 +9,6 @@ features linked to Symfony way of doing things. - [Expression Language](expression-language.md) - [Api Platform](api-platform.md) - [Object Mapper](object-mapper.md) +- [JSON Streamer](json-streamer.md) - [Migrate existing application](migrate.md) - [Debugging](debugging.md) diff --git a/docs/bundle/json-streamer.md b/docs/bundle/json-streamer.md new file mode 100644 index 00000000..cb4e63c8 --- /dev/null +++ b/docs/bundle/json-streamer.md @@ -0,0 +1,148 @@ +# JSON Streamer integration + +> [!WARNING] +> The JSON streamer integration is in an experimental state, and may change in the future. +> Some behavior may not be handled correctly, and some features may not be implemented. +> +> If you find a bug or missing feature, please report it on the [issue tracker](https://github.com/jolicode/automapper/issues). + +This bundle can replace the reader and the writer of the +[JsonStreamer component of Symfony](https://symfony.com/doc/current/serializer.html#json-streamer) +with implementations backed by the AutoMapper. + +Instead of hydrating and encoding objects on its own, the JSON streamer delegates to a generated +AutoMapper mapper. All the AutoMapper features stay available while streaming JSON: property +renaming, `#[MapTo]` / `#[MapFrom]`, custom transformers, providers, discriminators, groups, date +time formats, ... + +## Enabling it + +Enable both the Symfony component and the AutoMapper integration: + +```yaml +# config/packages/framework.yaml +framework: + json_streamer: + enabled: true + +# config/packages/automapper.yaml +automapper: + json_streamer: + enabled: true +``` + +The AutoMapper services **decorate** the Symfony ones, they do not remove them: any type that the +AutoMapper does not handle (scalars, enums, `DateTimeInterface`, `DateInterval`, `DateTimeZone`, ...) +is still streamed by the Symfony implementation. You keep injecting the standard interfaces: + +```php +use Symfony\Component\JsonStreamer\StreamReaderInterface; +use Symfony\Component\JsonStreamer\StreamWriterInterface; +use Symfony\Component\TypeInfo\Type; + +final readonly class UserController +{ + public function __construct( + private StreamReaderInterface $streamReader, + private StreamWriterInterface $streamWriter, + ) { + } + + public function __invoke(mixed $stream): iterable + { + // JSON -> objects + $user = $this->streamReader->read($stream, Type::object(User::class)); + + // objects -> JSON, chunk by chunk + return $this->streamWriter->write($user, Type::object(User::class)); + } +} +``` + +## Only using registered mappings + +By default every object type goes through the AutoMapper. If you would rather opt in explicitly, set +`only_registered_mapping` so that only the types you registered are handled by the AutoMapper, the +others falling back to the Symfony implementation: + +```yaml +automapper: + json_streamer: + enabled: true + only_registered_mapping: true + mapping: + mappers: + # reading: array -> User, writing: User -> array + - { source: 'App\Entity\User', target: 'array', reverse: true } +``` + +A type is considered registered when: + +* for **reading** (JSON to object), a mapper from `array` to the class is registered; +* for **writing** (object to JSON), a mapper from the class to `array` or to `json` is registered. + +Registering the `array` mapping with `reverse: true`, as above, therefore enables both directions. + +## Streaming a collection + +Reading a JSON array of objects returns a lazy collection: elements are decoded and mapped one at a +time, so a large payload never has to be held in memory at once. + +```php +$users = $streamReader->read($stream, Type::list(Type::object(User::class))); + +foreach ($users as $user) { + // $user is a fully mapped User +} +``` + +By default the collection is buffered, so it can be iterated several times and counted. When you +know you will read it only once, pass the `MapperContext::STREAM` option to keep the memory usage +flat whatever the collection size: + +```php +use AutoMapper\MapperContext; + +$users = $streamReader->read($stream, Type::list(Type::object(User::class)), [ + MapperContext::STREAM => true, +]); +``` + +The same option applies to the writer: iterating the returned value yields the JSON chunk by chunk +without ever building the whole string, while casting it to `string` gives you the full payload. + +```php +$result = $streamWriter->write($users, Type::list(Type::object(User::class)), [ + MapperContext::STREAM => true, +]); + +foreach ($result as $chunk) { + echo $chunk; +} +``` + +## Speeding up the decoding with the JSON stream extension + +The read path decodes JSON with the `json_stream_decode()` function. It is provided by the +[`joelwurtz/json-stream-polyfill`](https://github.com/joelwurtz/php-json-stream-polyfill) package, +which is installed by default with the AutoMapper, so everything works out of the box. + +For better performance you can install the [native PHP extension](https://github.com/joelwurtz/php-json-stream) +instead, which implements the same function in C. Use [pie](https://github.com/php/pie) to install it: + +```shell +pie install joelwurtz/json-stream +``` + +Then enable it in your `php.ini`: + +```ini +extension=json_stream +``` + +Nothing else has to change: the AutoMapper uses the extension automatically as soon as it is loaded, +and falls back to the polyfill when it is not. You can check which one is used with: + +```shell +php -r "var_dump(extension_loaded('json_stream'));" +``` diff --git a/docs/getting-started/source-and-target.md b/docs/getting-started/source-and-target.md index e48012b9..092aeee0 100644 --- a/docs/getting-started/source-and-target.md +++ b/docs/getting-started/source-and-target.md @@ -51,7 +51,6 @@ $target = $autoMapper->map($source, $target); > [!NOTE] > In this case you have to assign the result of the `map` method to the `$target` variable since it is not passed by reference. - ### Denormalization : Array to Object Denormalization is the process of converting an `array` or `stdClass` to an object. diff --git a/phpstan.neon b/phpstan.neon index 1a1ca415..63d94870 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,6 +6,12 @@ parameters: tmpDir: cache treatPhpDocTypesAsCertain: false + # json_stream_decode() and JsonStream\Document are provided by the json_stream extension, or by + # the polyfill which declares them behind a runtime guard, so they are not autoloadable. + scanFiles: + - vendor/joelwurtz/json-stream-polyfill/src/functions.php + - vendor/joelwurtz/json-stream-polyfill/src/Document.php + ignoreErrors: - message: "#^Method AutoMapper\\\\ObjectMapper\\\\ObjectMapper\\:\\:map\\(\\) should return T of object but returns object\\|null\\.$#" diff --git a/src/Attribute/Mapper.php b/src/Attribute/Mapper.php index d0013b8d..aa2b9bec 100644 --- a/src/Attribute/Mapper.php +++ b/src/Attribute/Mapper.php @@ -19,7 +19,7 @@ * @param string|null $dateTimeFormat The date-time format to use when transforming this property * @param bool|null $arrayLike Whether this class should be read/written as a keyed array shape (dynamic * keys, `ArrayAccess`, ...) rather than as a typed object. Left null it is - * inferred (`array`, `stdClass`, `json`, {@see \AutoMapper\Lazy\LazyMapInterface}). + * inferred (`array`, `stdClass` and `json`). */ public function __construct( public string|array|null $source = null, diff --git a/src/AutoMapper.php b/src/AutoMapper.php index a8694cd8..d38f423b 100644 --- a/src/AutoMapper.php +++ b/src/AutoMapper.php @@ -107,6 +107,12 @@ public function map(array|object $source, string|array|object $target, array $co throw new InvalidMappingException('Cannot map this value, both source and target are array.'); } + if ('json' === $targetType) { + // `json` is a serialization target, not a mapping one: it is only reachable through the + // JsonStreamer integration, which asks for the mapper directly. + throw new InvalidMappingException('Cannot map to the "json" target, use the JsonStreamer integration to serialize an object to JSON.'); + } + return $this->getMapper($sourceType, $targetType)->map($source, $context); } diff --git a/src/EventListener/MapperListener.php b/src/EventListener/MapperListener.php index 95ce0567..e36d0048 100644 --- a/src/EventListener/MapperListener.php +++ b/src/EventListener/MapperListener.php @@ -33,7 +33,14 @@ public function __invoke(GenerateMapperEvent $event): void $event->strictTypes ??= $directMapperAttribute->strictTypes; $event->allowExtraProperties ??= $directMapperAttribute->allowExtraProperties; $event->mapperMetadata->dateTimeFormat = $directMapperAttribute->dateTimeFormat; - $event->sourceArrayLike ??= $directMapperAttribute->arrayLike; + + // the attribute describes the class it is written on: it configures the source side when + // read from the source class, the target side otherwise + if ($fromSource) { + $event->sourceArrayLike ??= $directMapperAttribute->arrayLike; + } else { + $event->targetArrayLike ??= $directMapperAttribute->arrayLike; + } if ($directMapperAttribute->discriminator) { if ($fromSource) { diff --git a/src/Extractor/MappingExtractor.php b/src/Extractor/MappingExtractor.php index 91f3914e..126246e8 100644 --- a/src/Extractor/MappingExtractor.php +++ b/src/Extractor/MappingExtractor.php @@ -6,7 +6,6 @@ use AutoMapper\Configuration; use AutoMapper\Event\PropertyMetadataEvent; -use AutoMapper\Lazy\LazyMapInterface; use Symfony\Component\PropertyInfo\PropertyListExtractorInterface; use Symfony\Component\PropertyInfo\PropertyReadInfo; use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface; @@ -36,9 +35,10 @@ public function __construct( /** * @return array */ - public function getProperties(string $class, bool $withConstructorParameters = false): iterable + public function getProperties(string $class, bool $withConstructorParameters = false, bool $arrayLike = false): iterable { - if ($class === 'array' || $class === 'json' || $class === \stdClass::class || is_a($class, LazyMapInterface::class, true)) { + // An array-like class has no fixed property list: the properties come from the other side. + if ($arrayLike || $class === 'array' || $class === 'json' || $class === \stdClass::class) { return []; } @@ -83,7 +83,7 @@ private function getConstructorParameters(string $class): iterable } } - public function getReadAccessor(string $class, string $property, bool $allowExtraProperties = false): ?ReadAccessorInterface + public function getReadAccessor(string $class, string $property, bool $allowExtraProperties = false, bool $arrayLike = false): ?ReadAccessorInterface { // split to first dot for nested properties $exploded = explode('.', $property, 2); @@ -91,7 +91,7 @@ public function getReadAccessor(string $class, string $property, bool $allowExtr if (2 === \count($exploded)) { [$parent, $child] = $exploded; } else { - return $this->doGetReadAccessor($class, $property, $allowExtraProperties); + return $this->doGetReadAccessor($class, $property, $allowExtraProperties, $arrayLike); } if ('array' === $class) { @@ -128,13 +128,13 @@ public function getReadAccessor(string $class, string $property, bool $allowExtr return new NestedReadAccessor($parentAccessor, $childAccessor, $childClass, $property); } - public function getWriteMutator(string $source, string $target, string $property, array $context = [], bool $allowExtraProperties = false): ?WriteMutatorInterface + public function getWriteMutator(string $source, string $target, string $property, array $context = [], bool $allowExtraProperties = false, bool $arrayLike = false): ?WriteMutatorInterface { // split to last dot for nested properties $lastDotPosition = strrpos($property, '.'); if (false === $lastDotPosition) { - return $this->doGetWriteMutator($target, $property, $context, $allowExtraProperties); + return $this->doGetWriteMutator($target, $property, $context, $allowExtraProperties, $arrayLike); } $parent = substr($property, 0, $lastDotPosition); @@ -176,9 +176,9 @@ public function getWriteMutator(string $source, string $target, string $property return new NestedWriteMutator($accessor, $mutator); } - public function getCheckExists(string $class, string $property): bool + public function getCheckExists(string $class, string $property, bool $arrayLike = false): bool { - if ('array' === $class || \stdClass::class === $class || is_a($class, LazyMapInterface::class, true)) { + if ($arrayLike || 'array' === $class || \stdClass::class === $class) { return true; } @@ -231,7 +231,7 @@ private function findLastTypeForAccessor(string $source, string $property): ?Typ /** * @param class-string|'array' $class */ - private function doGetReadAccessor(string $class, string $property, bool $allowExtraProperties = false): ?ReadAccessorInterface + private function doGetReadAccessor(string $class, string $property, bool $allowExtraProperties = false, bool $arrayLike = false): ?ReadAccessorInterface { if ('json' === $class) { return null; @@ -245,7 +245,8 @@ private function doGetReadAccessor(string $class, string $property, bool $allowE return new PropertyReadAccessor($property); } - if (is_a($class, LazyMapInterface::class, true)) { + // An array-like class exposes its values by key: read them through offsetGet. + if ($arrayLike) { return new ArrayReadAccessor($property, true); } @@ -277,7 +278,7 @@ private function doGetReadAccessor(string $class, string $property, bool $allowE * @param class-string|'array' $target * @param array $context */ - private function doGetWriteMutator(string $target, string $property, array $context = [], bool $allowExtraProperties = false): ?WriteMutatorInterface + private function doGetWriteMutator(string $target, string $property, array $context = [], bool $allowExtraProperties = false, bool $arrayLike = false): ?WriteMutatorInterface { // `json` targets are never actually written to: their mapper yields the value. A dedicated // mutator marks the property as writable so it is not dropped as unwritable. diff --git a/src/Extractor/MappingExtractorInterface.php b/src/Extractor/MappingExtractorInterface.php index 68f60a81..1bdbddaf 100644 --- a/src/Extractor/MappingExtractorInterface.php +++ b/src/Extractor/MappingExtractorInterface.php @@ -25,7 +25,7 @@ interface MappingExtractorInterface * * @return list */ - public function getProperties(string $class): iterable; + public function getProperties(string $class, bool $withConstructorParameters = false, bool $arrayLike = false): iterable; /** * @param class-string|'array' $source @@ -42,14 +42,14 @@ public function getDateTimeFormat(PropertyMetadataEvent $propertyMetadataEvent): */ public function getGroups(string $class, string $property): ?array; - public function getCheckExists(string $class, string $property): bool; + public function getCheckExists(string $class, string $property, bool $arrayLike = false): bool; /** * Extracts read accessor for a given source, target and property. * * @param class-string|'array' $class */ - public function getReadAccessor(string $class, string $property): ?ReadAccessorInterface; + public function getReadAccessor(string $class, string $property, bool $allowExtraProperties = false, bool $arrayLike = false): ?ReadAccessorInterface; /** * Extracts write mutator for a given source, target and property. @@ -58,5 +58,5 @@ public function getReadAccessor(string $class, string $property): ?ReadAccessorI * @param class-string|'array' $target * @param array $context */ - public function getWriteMutator(string $source, string $target, string $property, array $context = []): ?WriteMutatorInterface; + public function getWriteMutator(string $source, string $target, string $property, array $context = [], bool $allowExtraProperties = false, bool $arrayLike = false): ?WriteMutatorInterface; } diff --git a/src/Generator/JsonMapMethodStatementsGenerator.php b/src/Generator/JsonMapMethodStatementsGenerator.php index 56fb53e7..f6eaa6e7 100644 --- a/src/Generator/JsonMapMethodStatementsGenerator.php +++ b/src/Generator/JsonMapMethodStatementsGenerator.php @@ -4,11 +4,14 @@ namespace AutoMapper\Generator; +use AutoMapper\Exception\CircularReferenceException; +use AutoMapper\MapperContext; use AutoMapper\Metadata\GeneratorMetadata; use AutoMapper\Transformer\MapperDependency; use PhpParser\Node\Arg; use PhpParser\Node\ClosureUse; use PhpParser\Node\Expr; +use PhpParser\Node\Name; use PhpParser\Node\Scalar; use PhpParser\Node\Stmt; @@ -63,11 +66,104 @@ public function getStatements(GeneratorMetadata $metadata): array ]); return [ + // Guard before building the stream, so an invalid graph fails on map() rather than on + // the first chunk pulled out of the generator. + ...$this->handleCircularReference($metadata), + $this->incrementDepth($metadata), new Stmt\Expression(new Expr\Assign($streamVar, new Expr\FuncCall($streamClosure))), new Stmt\Return_($streamVar), ]; } + /** + * A JSON stream cannot reference a value it already wrote, so a circular reference can only be + * reported. The detection itself reuses the regular mapping concepts, honouring the configured + * `circular_reference_limit`. + * + * ```php + * $sourceHash = spl_object_hash($value) . 'json'; + * + * if (\AutoMapper\MapperContext::shouldHandleCircularReference($context, $sourceHash)) { + * throw new CircularReferenceException(...); + * } + * + * $context = \AutoMapper\MapperContext::withReference($context, $sourceHash, $value); + * ``` + * + * @return Stmt[] + */ + private function handleCircularReference(GeneratorMetadata $metadata): array + { + // The json dependencies are the nested object → json mappers this one delegates to, so an + // empty list means the stream cannot recurse at all. `canHaveCircularReference()` cannot be + // used here: it inspects the `array` dependencies, not the json ones. + if ([] === $this->jsonDependencies($metadata)) { + return []; + } + + $variableRegistry = $metadata->variableRegistry; + $hashVar = $variableRegistry->getHash(); + $contextVar = $variableRegistry->getContext(); + $sourceVar = $variableRegistry->getSourceInput(); + + return [ + new Stmt\Expression(new Expr\Assign( + $hashVar, + new Expr\BinaryOp\Concat( + new Expr\FuncCall(new Name('spl_object_hash'), [new Arg($sourceVar)]), + new Scalar\String_($metadata->mapperMetadata->target), + ) + )), + new Stmt\If_( + new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'shouldHandleCircularReference', [ + new Arg($contextVar), + new Arg($hashVar), + ]), + [ + 'stmts' => [ + new Stmt\Expression(new Expr\Throw_(new Expr\New_( + new Name\FullyQualified(CircularReferenceException::class), + [new Arg(new Expr\BinaryOp\Concat( + new Scalar\String_('A circular reference has been detected while streaming the object of type "'), + new Expr\BinaryOp\Concat( + new Expr\ClassConstFetch($sourceVar, 'class'), + new Scalar\String_('" to JSON.'), + ), + ))] + ))), + ], + ] + ), + new Stmt\Expression(new Expr\Assign( + $contextVar, + new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'withReference', [ + new Arg($contextVar), + new Arg($hashVar), + new Arg($sourceVar), + ]) + )), + ]; + } + + /** + * ```php + * $context = \AutoMapper\MapperContext::withIncrementedDepth($context); + * ```. + * + * Keeps `MAX_DEPTH` and the `#[MaxDepth]` attribute working on the nested json mappers. + */ + private function incrementDepth(GeneratorMetadata $metadata): Stmt + { + $contextVar = $metadata->variableRegistry->getContext(); + + return new Stmt\Expression(new Expr\Assign( + $contextVar, + new Expr\StaticCall(new Name\FullyQualified(MapperContext::class), 'withIncrementedDepth', [ + new Arg($contextVar), + ]) + )); + } + /** * The nested object → json dependencies referenced by this mapper, so they can be injected. * diff --git a/src/Generator/JsonPropertyStatementsGenerator.php b/src/Generator/JsonPropertyStatementsGenerator.php index 094e43e5..85a3549f 100644 --- a/src/Generator/JsonPropertyStatementsGenerator.php +++ b/src/Generator/JsonPropertyStatementsGenerator.php @@ -10,6 +10,7 @@ use AutoMapper\Transformer\ArrayTransformer; use AutoMapper\Transformer\LazyCollectionTransformer; use AutoMapper\Transformer\MapperDependency; +use AutoMapper\Transformer\NullableTransformer; use AutoMapper\Transformer\ObjectTransformer; use AutoMapper\Transformer\TransformerInterface; use PhpParser\Node\Arg; @@ -97,7 +98,7 @@ public function jsonDependencies(GeneratorMetadata $metadata): array continue; } - $transformer = $propertyMetadata->transformer; + $transformer = $this->unwrapNullable($propertyMetadata->transformer); $objectTransformer = $transformer instanceof ObjectTransformer ? $transformer : $this->objectListItemTransformer($transformer); if ($objectTransformer === null || ($source = $this->streamableSource($objectTransformer)) === null) { @@ -119,7 +120,9 @@ private function propertyStatements( Expr $fieldValueExpr, Expr $keyPrefix, ): array { - $transformer = $propertyMetadata->transformer; + // A nullable nested object/list is still streamed through its json sub-mapper: the branches + // below emit their own `null` guard, so the NullableTransformer wrapper can be unwrapped. + $transformer = $this->unwrapNullable($propertyMetadata->transformer); $variableRegistry = $metadata->variableRegistry; $advanceSep = new Stmt\Expression(new Expr\Assign(self::separatorVariable(), new Scalar\String_(','))); @@ -175,9 +178,15 @@ private function propertyStatements( $variableRegistry->getSourceInput(), ); + // JSON_THROW_ON_ERROR, like the Symfony writer does: an unencodable value (INF, NAN, + // malformed UTF-8, ...) must fail loudly instead of silently emitting `false`, which would + // concatenate to an empty string and produce structurally invalid JSON. $propStatements[] = new Stmt\Expression(new Expr\Yield_(new Expr\BinaryOp\Concat( $keyPrefix, - new Expr\FuncCall(new Name('json_encode'), [new Arg($output)]), + new Expr\FuncCall(new Name('json_encode'), [ + new Arg($output), + new Arg(new Expr\ConstFetch(new Name('JSON_THROW_ON_ERROR'))), + ]), ))); $propStatements[] = new Stmt\Expression(new Expr\Assign(self::separatorVariable(), new Scalar\String_(','))); @@ -199,6 +208,15 @@ private function yieldFromMapper(string $source, Expr $value, VariableRegistry $ ))); } + /** + * Unwrap a {@see NullableTransformer} so a nullable nested object or object list is recognized + * as such. Only meaningful for the branches emitting an explicit null check. + */ + private function unwrapNullable(TransformerInterface $transformer): TransformerInterface + { + return $transformer instanceof NullableTransformer ? $transformer->getItemTransformer() : $transformer; + } + /** * The per-item {@see ObjectTransformer} when the property is a *list* of objects * (bare or lazy-collection wrapped), or null otherwise. diff --git a/src/Generator/PropertyConditionsGenerator.php b/src/Generator/PropertyConditionsGenerator.php index c5bac024..3557f1d7 100644 --- a/src/Generator/PropertyConditionsGenerator.php +++ b/src/Generator/PropertyConditionsGenerator.php @@ -136,7 +136,7 @@ private function propertyExistsForStdClass(GeneratorMetadata $metadata, Property * ``` * * The exact check is provided by the read accessor itself, so it stays correct whatever the - * array-like source is (plain array, `LazyMapInterface`, native `ArrayAccess` document, ...) + * array-like source is (plain array, `stdClass`, a native `ArrayAccess` document, ...) * instead of being guessed from the source name. */ private function propertyExistsForArray(GeneratorMetadata $metadata, PropertyMetadata $propertyMetadata): ?Expr diff --git a/src/JsonStreamer/JsonStreamDocumentListener.php b/src/JsonStreamer/JsonStreamDocumentListener.php index 17827b60..537b66b4 100644 --- a/src/JsonStreamer/JsonStreamDocumentListener.php +++ b/src/JsonStreamer/JsonStreamDocumentListener.php @@ -13,9 +13,8 @@ * produced by the `json_stream_decode()` extension function) as an array-like source. * * The document is a read-only `ArrayAccess` object that exposes the decoded JSON by key, exactly - * like the pure-PHP {@see Read\LazyJsonObject} — but being a native class it cannot implement - * {@see \AutoMapper\Lazy\LazyMapInterface}, so the default inference does not recognize it. This - * listener supplies, for that one class, what the interface would otherwise provide: + * like a plain `array` source — but being a native class it carries no `#[Mapper]` attribute, so the + * default inference does not recognize it. This listener supplies the two things it needs: * * - {@see onGenerateMapper} marks the mapper as array-like, so the "from target" extractor is used; * - {@see onPropertyMetadata} reads each property through `offsetGet` (an {@see ArrayReadAccessor}). diff --git a/src/JsonStreamer/JsonStreamReader.php b/src/JsonStreamer/JsonStreamReader.php index b7768d90..8ed38883 100644 --- a/src/JsonStreamer/JsonStreamReader.php +++ b/src/JsonStreamer/JsonStreamReader.php @@ -7,6 +7,7 @@ use AutoMapper\AutoMapperInterface; use AutoMapper\Lazy\LazyCollection; use AutoMapper\MapperContext; +use AutoMapper\Metadata\MetadataRegistry; use Symfony\Component\JsonStreamer\StreamReaderInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; @@ -36,6 +37,11 @@ public function __construct( private readonly AutoMapperInterface $mapper, /** @var StreamReaderInterface> */ private readonly StreamReaderInterface $fallbackStreamReader, + /** + * When set, only types having a mapper registered in this registry are read through the + * AutoMapper; everything else is delegated to the underlying Symfony reader. + */ + private readonly ?MetadataRegistry $onlyMetadataRegistry = null, ) { } @@ -88,7 +94,7 @@ private function unwrap(Type $type): 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). + * Symfony value transformers, or types without a registered mapper). * * @return class-string|null */ @@ -109,6 +115,11 @@ private function ownedClassName(Type $type): ?string return null; } + // Registry aware: without a registered mapper for this class, let the Symfony reader do it. + if (null !== $this->onlyMetadataRegistry && !$this->onlyMetadataRegistry->has('array', $className, true)) { + return null; + } + return $className; } } diff --git a/src/JsonStreamer/JsonStreamWriter.php b/src/JsonStreamer/JsonStreamWriter.php index bd22e595..b7bc3b71 100644 --- a/src/JsonStreamer/JsonStreamWriter.php +++ b/src/JsonStreamer/JsonStreamWriter.php @@ -7,6 +7,7 @@ use AutoMapper\AutoMapperInterface; use AutoMapper\AutoMapperRegistryInterface; use AutoMapper\MapperInterface; +use AutoMapper\Metadata\MetadataRegistry; use Symfony\Component\JsonStreamer\StreamWriterInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; @@ -29,6 +30,11 @@ public function __construct( private readonly AutoMapperInterface $mapper, /** @var StreamWriterInterface> */ private readonly StreamWriterInterface $fallbackStreamWriter, + /** + * When set, only types having a mapper registered in this registry are written through the + * AutoMapper; everything else is delegated to the underlying Symfony writer. + */ + private readonly ?MetadataRegistry $onlyMetadataRegistry = null, ) { } @@ -44,7 +50,9 @@ public function write( if ($className !== null && is_iterable($data) && $this->jsonMapper($className) !== null) { /** @var iterable $data */ - return $this->wrap(fn (): \Generator => $this->collectionChunks($data, $className, $options)); + $isList = $unwrapped->isList(); + + return $this->wrap(fn (): \Generator => $this->collectionChunks($data, $className, $options, $isList)); } } @@ -61,8 +69,8 @@ public function write( } /** - * 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. + * Yield the JSON of a collection, one element at a time so a large collection is never held in + * memory at once: a list is framed with `[` … `]`, a dict with `{` … `}` and its keys kept. * * @param iterable $data * @param class-string $className @@ -70,22 +78,32 @@ public function write( * * @return \Generator */ - private function collectionChunks(iterable $data, string $className, array $options): \Generator + private function collectionChunks(iterable $data, string $className, array $options, bool $isList): \Generator { $mapper = $this->jsonMapper($className); - yield '['; + yield $isList ? '[' : '{'; $sep = ''; - foreach ($data as $item) { - yield $sep; + foreach ($data as $key => $item) { + yield $isList ? $sep : $sep . $this->encodeKey($key) . ':'; if (\is_object($item) && $item::class === $className && $mapper !== null) { yield from $mapper->map($item, $options) ?? []; } else { - yield json_encode($item) ?: 'null'; + // JSON_THROW_ON_ERROR, like the Symfony writer: an unencodable value fails loudly + // instead of silently emitting an empty (structurally invalid) chunk. + yield json_encode($item, \JSON_THROW_ON_ERROR); } $sep = ','; } - yield ']'; + yield $isList ? ']' : '}'; + } + + /** + * A JSON object key is always a string, whatever the PHP array key type is. + */ + private function encodeKey(mixed $key): string + { + return json_encode((string) (\is_scalar($key) ? $key : ''), \JSON_THROW_ON_ERROR); } /** @@ -102,6 +120,15 @@ private function jsonMapper(string $className): ?MapperInterface return null; } + // Registry aware: without a registered mapper for this class, let the Symfony writer do it. + if ( + null !== $this->onlyMetadataRegistry + && !$this->onlyMetadataRegistry->has($className, 'json', true) + && !$this->onlyMetadataRegistry->has($className, 'array', true) + ) { + return null; + } + /** @var MapperInterface> $mapper */ $mapper = $this->mapper->getMapper($className, 'json'); diff --git a/src/Lazy/LazyMap.php b/src/Lazy/LazyMap.php index 46870ea9..1f0d06fb 100644 --- a/src/Lazy/LazyMap.php +++ b/src/Lazy/LazyMap.php @@ -4,11 +4,18 @@ namespace AutoMapper\Lazy; +use AutoMapper\Attribute\Mapper; + /** - * @implements LazyMapInterface + * A map whose values are produced on first access, used as the target of a lazy `array` mapping. + * + * It is read by key like a plain `array` source, hence the `arrayLike` marker. + * + * @implements \ArrayAccess * @implements \IteratorAggregate */ -final class LazyMap implements LazyMapInterface, \JsonSerializable, \IteratorAggregate +#[Mapper(arrayLike: true)] +final class LazyMap implements \ArrayAccess, \JsonSerializable, \IteratorAggregate { /** @var array */ private mixed $mappedValue = []; diff --git a/src/Lazy/LazyMapInterface.php b/src/Lazy/LazyMapInterface.php deleted file mode 100644 index 67e999c5..00000000 --- a/src/Lazy/LazyMapInterface.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ -interface LazyMapInterface extends \ArrayAccess -{ -} diff --git a/src/Metadata/MapperMetadata.php b/src/Metadata/MapperMetadata.php index 395388a0..24514c13 100644 --- a/src/Metadata/MapperMetadata.php +++ b/src/Metadata/MapperMetadata.php @@ -4,7 +4,6 @@ namespace AutoMapper\Metadata; -use AutoMapper\Lazy\LazyMapInterface; use Composer\InstalledVersions; class MapperMetadata @@ -92,12 +91,12 @@ public function isSourceArrayLike(): bool */ public function inferTargetArrayLike(): bool { - return \in_array($this->target, ['array', \stdClass::class, 'json'], true) || is_a($this->target, LazyMapInterface::class, true); + return \in_array($this->target, ['array', \stdClass::class, 'json'], true); } public function inferSourceArrayLike(): bool { - return \in_array($this->source, ['array', \stdClass::class], true) || is_a($this->source, LazyMapInterface::class, true); + return \in_array($this->source, ['array', \stdClass::class], true); } public function getHash(): string diff --git a/src/Metadata/MetadataFactory.php b/src/Metadata/MetadataFactory.php index b867dcb6..b8d4ff7a 100644 --- a/src/Metadata/MetadataFactory.php +++ b/src/Metadata/MetadataFactory.php @@ -191,7 +191,7 @@ private function createGeneratorMetadata(MapperMetadata $mapperMetadata): Genera $propertyEvents = []; // First get properties from the source - foreach ($extractor->getProperties($mapperMetadata->source) as $property) { + foreach ($extractor->getProperties($mapperMetadata->source, arrayLike: $mapperMetadata->isSourceArrayLike()) as $property) { $propertyEvent = new PropertyMetadataEvent($mapperMetadata, new SourcePropertyMetadataEvent($property), new TargetPropertyMetadataEvent($property), isFromDefaultExtractor: true); $this->eventDispatcher->dispatch($propertyEvent); @@ -199,7 +199,7 @@ private function createGeneratorMetadata(MapperMetadata $mapperMetadata): Genera $propertyEvents[$propertyEvent->target->property] = $propertyEvent; } - foreach ($extractor->getProperties($mapperMetadata->target, withConstructorParameters: true) as $property) { + foreach ($extractor->getProperties($mapperMetadata->target, withConstructorParameters: true, arrayLike: $mapperMetadata->isTargetArrayLike()) as $property) { if (isset($propertyEvents[$property])) { continue; } @@ -233,11 +233,11 @@ private function createGeneratorMetadata(MapperMetadata $mapperMetadata): Genera foreach ($propertyEvents as $propertyMappedEvent) { // Create the source property metadata if ($propertyMappedEvent->source->accessor === null) { - $propertyMappedEvent->source->accessor = $extractor->getReadAccessor($mapperMetadata->source, $propertyMappedEvent->source->property, $mapperEvent->allowExtraProperties ?? $this->configuration->allowExtraProperties); + $propertyMappedEvent->source->accessor = $extractor->getReadAccessor($mapperMetadata->source, $propertyMappedEvent->source->property, $mapperEvent->allowExtraProperties ?? $this->configuration->allowExtraProperties, $mapperMetadata->isSourceArrayLike()); } if ($propertyMappedEvent->source->checkExists === null) { - $propertyMappedEvent->source->checkExists = $extractor->getCheckExists($mapperMetadata->source, $propertyMappedEvent->source->property); + $propertyMappedEvent->source->checkExists = $extractor->getCheckExists($mapperMetadata->source, $propertyMappedEvent->source->property, $mapperMetadata->isSourceArrayLike()); } if ($propertyMappedEvent->source->extractGroupsIfNull && $propertyMappedEvent->source->groups === null) { @@ -250,13 +250,13 @@ private function createGeneratorMetadata(MapperMetadata $mapperMetadata): Genera // Create the target property metadata if ($propertyMappedEvent->target->readAccessor === null) { - $propertyMappedEvent->target->readAccessor = $extractor->getReadAccessor($mapperMetadata->target, $propertyMappedEvent->target->property, $mapperEvent->allowExtraProperties ?? $this->configuration->allowExtraProperties); + $propertyMappedEvent->target->readAccessor = $extractor->getReadAccessor($mapperMetadata->target, $propertyMappedEvent->target->property, $mapperEvent->allowExtraProperties ?? $this->configuration->allowExtraProperties, $mapperMetadata->isTargetArrayLike()); } if ($propertyMappedEvent->target->writeMutator === null) { $propertyMappedEvent->target->writeMutator = $extractor->getWriteMutator($mapperMetadata->source, $mapperMetadata->target, $propertyMappedEvent->target->property, [ 'enable_constructor_extraction' => false, - ], $mapperEvent->allowExtraProperties ?? $this->configuration->allowExtraProperties); + ], $mapperEvent->allowExtraProperties ?? $this->configuration->allowExtraProperties, $mapperMetadata->isTargetArrayLike()); } if ($propertyMappedEvent->target->parameterInConstructor === null) { diff --git a/src/Symfony/Bundle/DependencyInjection/AutoMapperExtension.php b/src/Symfony/Bundle/DependencyInjection/AutoMapperExtension.php index e4cd6ff9..8c4c00ee 100644 --- a/src/Symfony/Bundle/DependencyInjection/AutoMapperExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/AutoMapperExtension.php @@ -26,6 +26,7 @@ use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\JsonStreamer\StreamWriterInterface; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerInterface; @@ -46,6 +47,10 @@ * only_registered_mapping: bool, * priority: int, * }, + * json_streamer: array{ + * enabled: bool, + * only_registered_mapping: bool, + * }, * serializer_attributes: bool, * api_platform: bool, * object_mapper: bool, @@ -164,6 +169,21 @@ public function load(array $configs, ContainerBuilder $container): void } } + if ($config['json_streamer']['enabled']) { + if (!interface_exists(StreamWriterInterface::class)) { + throw new LogicException('The "symfony/json-streamer" component is required to use the "json_streamer" feature.'); + } + + $loader->load('json_streamer.php'); + + if ($config['json_streamer']['only_registered_mapping']) { + $registry = new Reference('automapper.config_mapping_registry'); + + $container->getDefinition('automapper.json_streamer.stream_reader')->setArgument('$onlyMetadataRegistry', $registry); + $container->getDefinition('automapper.json_streamer.stream_writer')->setArgument('$onlyMetadataRegistry', $registry); + } + } + if ($config['api_platform']) { $loader->load('api_platform.php'); } diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 4ac7cd17..cc2b7657 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -41,6 +41,14 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->addDefaultsIfNotSet() ->end() + ->arrayNode('json_streamer') + ->info('Replace the symfony/json-streamer reader and writer with the AutoMapper ones.') + ->children() + ->booleanNode('enabled')->defaultFalse()->end() + ->booleanNode('only_registered_mapping')->defaultFalse()->end() + ->end() + ->addDefaultsIfNotSet() + ->end() ->booleanNode('serializer_attributes')->defaultValue(interface_exists(SerializerInterface::class))->end() ->booleanNode('api_platform')->defaultFalse()->end() ->booleanNode('object_mapper')->defaultFalse()->end() diff --git a/src/Symfony/Bundle/Resources/config/json_streamer.php b/src/Symfony/Bundle/Resources/config/json_streamer.php new file mode 100644 index 00000000..6a751cc0 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/json_streamer.php @@ -0,0 +1,40 @@ +services() + ->set('automapper.json_streamer.stream_reader', JsonStreamReader::class) + ->decorate('json_streamer.stream_reader') + ->args([ + service(AutoMapperInterface::class), + service('automapper.json_streamer.stream_reader.inner'), + ]) + + ->set('automapper.json_streamer.stream_writer', JsonStreamWriter::class) + ->decorate('json_streamer.stream_writer') + ->args([ + service(AutoMapperInterface::class), + service('automapper.json_streamer.stream_writer.inner'), + ]) + + // Symfony only aliases the concrete JsonStreamReader/JsonStreamWriter classes for + // autowiring; alias the interfaces too so they can be injected (and resolve to the + // decorated services, hence to the AutoMapper implementations). + ->alias(StreamReaderInterface::class, 'json_streamer.stream_reader') + ->alias(StreamWriterInterface::class, 'json_streamer.stream_writer') + ; +}; diff --git a/src/Transformer/ArrayShapeTransformerFactory.php b/src/Transformer/ArrayShapeTransformerFactory.php index 415d869d..54f7da33 100644 --- a/src/Transformer/ArrayShapeTransformerFactory.php +++ b/src/Transformer/ArrayShapeTransformerFactory.php @@ -4,7 +4,6 @@ namespace AutoMapper\Transformer; -use AutoMapper\Lazy\LazyMapInterface; use AutoMapper\Metadata\MapperMetadata; use AutoMapper\Metadata\SourcePropertyMetadata; use AutoMapper\Metadata\TargetPropertyMetadata; @@ -36,8 +35,7 @@ public function getTransformer(SourcePropertyMetadata $source, TargetPropertyMet } $fieldTransformers = []; - $sourceIsUntyped = isset($mapperMetadata->source) - && (\in_array($mapperMetadata->source, ['array', \stdClass::class], true) || is_a($mapperMetadata->source, LazyMapInterface::class, true)); + $sourceIsUntyped = isset($mapperMetadata->source) && $mapperMetadata->isSourceArrayLike(); $sourceShape = $sourceType instanceof Type\ArrayShapeType ? $sourceType->getShape() : []; diff --git a/src/Transformer/ArrayTransformerFactory.php b/src/Transformer/ArrayTransformerFactory.php index 04256dc0..28dd2f13 100644 --- a/src/Transformer/ArrayTransformerFactory.php +++ b/src/Transformer/ArrayTransformerFactory.php @@ -4,7 +4,6 @@ namespace AutoMapper\Transformer; -use AutoMapper\Lazy\LazyMapInterface; use AutoMapper\Metadata\MapperMetadata; use AutoMapper\Metadata\SourcePropertyMetadata; use AutoMapper\Metadata\TargetPropertyMetadata; @@ -41,8 +40,7 @@ public function getTransformer(SourcePropertyMetadata $source, TargetPropertyMet // For untyped sources, the value type is mirrored from target and needs // overriding to mixed so the chain generates proper scalar casts. $wrapWithNullable = false; - if (isset($mapperMetadata->source) - && (\in_array($mapperMetadata->source, ['array', \stdClass::class], true) || is_a($mapperMetadata->source, LazyMapInterface::class, true))) { + if (isset($mapperMetadata->source) && $mapperMetadata->isSourceArrayLike()) { [$sourceCollectionType, $wrapWithNullable] = $this->overrideSourceCollectionType($sourceCollectionType, $targetCollectionType); } @@ -86,8 +84,7 @@ public function getTransformer(SourcePropertyMetadata $source, TargetPropertyMet */ private function targetCanHoldLazyCollection(Type $targetType, MapperMetadata $mapperMetadata): bool { - if (isset($mapperMetadata->target) - && (\in_array($mapperMetadata->target, ['array', \stdClass::class], true) || is_a($mapperMetadata->target, LazyMapInterface::class, true))) { + if (isset($mapperMetadata->target) && $mapperMetadata->isTargetArrayLike()) { return true; } diff --git a/src/Transformer/NullableTransformer.php b/src/Transformer/NullableTransformer.php index c5d78cd2..bbc55a50 100644 --- a/src/Transformer/NullableTransformer.php +++ b/src/Transformer/NullableTransformer.php @@ -25,6 +25,11 @@ public function __construct( ) { } + public function getItemTransformer(): TransformerInterface + { + return $this->itemTransformer; + } + public function transform(Expr $input, Expr $target, PropertyMetadata $propertyMapping, UniqueVariableScope $uniqueVariableScope, Expr $source, ?Expr $existingValue = null): array { [$output, $itemStatements] = $this->itemTransformer->transform($input, $target, $propertyMapping, $uniqueVariableScope, $source, $existingValue); diff --git a/tests/Bundle/DependencyInjection/AutoMapperExtensionTest.php b/tests/Bundle/DependencyInjection/AutoMapperExtensionTest.php index a482b3a5..1a344b63 100644 --- a/tests/Bundle/DependencyInjection/AutoMapperExtensionTest.php +++ b/tests/Bundle/DependencyInjection/AutoMapperExtensionTest.php @@ -87,6 +87,49 @@ public function testObjectMapperListenersRunAfterDoctrineAndDiscriminator(): voi self::assertLessThan($discriminator, $mapTarget); } + public function testJsonStreamerIsNotRegisteredByDefault(): void + { + $this->container->setParameter('kernel.debug', false); + $this->load(); + + $this->assertContainerBuilderNotHasService('automapper.json_streamer.stream_reader'); + $this->assertContainerBuilderNotHasService('automapper.json_streamer.stream_writer'); + } + + public function testJsonStreamerDecoratesTheSymfonyServices(): void + { + $this->container->setParameter('kernel.debug', false); + $this->load(['json_streamer' => ['enabled' => true]]); + + foreach (['reader', 'writer'] as $kind) { + $definition = $this->container->getDefinition("automapper.json_streamer.stream_{$kind}"); + + self::assertSame("json_streamer.stream_{$kind}", $definition->getDecoratedService()[0]); + // the decorated Symfony service is kept as the fallback + self::assertSame( + "automapper.json_streamer.stream_{$kind}.inner", + (string) $definition->getArgument(1), + ); + // registry awareness is opt-in + self::assertArrayNotHasKey('$onlyMetadataRegistry', $definition->getArguments()); + } + } + + public function testJsonStreamerOnlyRegisteredMappingUsesTheConfigRegistry(): void + { + $this->container->setParameter('kernel.debug', false); + $this->load(['json_streamer' => ['enabled' => true, 'only_registered_mapping' => true]]); + + foreach (['reader', 'writer'] as $kind) { + $definition = $this->container->getDefinition("automapper.json_streamer.stream_{$kind}"); + + self::assertSame( + 'automapper.config_mapping_registry', + (string) $definition->getArgument('$onlyMetadataRegistry'), + ); + } + } + private function generateMapperListenerPriority(string $serviceId): int { $definition = $this->container->getDefinition($serviceId); diff --git a/tests/Bundle/Resources/config/packages/automapper.yaml b/tests/Bundle/Resources/config/packages/automapper.yaml index 5752bdc9..f145e7e3 100644 --- a/tests/Bundle/Resources/config/packages/automapper.yaml +++ b/tests/Bundle/Resources/config/packages/automapper.yaml @@ -5,6 +5,8 @@ automapper: loader: eval: false reload_strategy: 'always' + json_streamer: + enabled: true api_platform: true object_mapper: true doctrine: true diff --git a/tests/Bundle/Resources/config/packages/framework.yaml b/tests/Bundle/Resources/config/packages/framework.yaml index f231a537..32695681 100644 --- a/tests/Bundle/Resources/config/packages/framework.yaml +++ b/tests/Bundle/Resources/config/packages/framework.yaml @@ -5,6 +5,8 @@ framework: test: ~ serializer: enabled: true + json_streamer: + enabled: true profiler: enabled: "%kernel.debug%" only_exceptions: false \ No newline at end of file diff --git a/tests/Bundle/ServiceInstantiationTest.php b/tests/Bundle/ServiceInstantiationTest.php index c86b1e75..6bfd6628 100644 --- a/tests/Bundle/ServiceInstantiationTest.php +++ b/tests/Bundle/ServiceInstantiationTest.php @@ -5,6 +5,8 @@ namespace AutoMapper\Tests\Bundle; use AutoMapper\AutoMapperInterface; +use AutoMapper\JsonStreamer\JsonStreamReader as AutoMapperJsonStreamReader; +use AutoMapper\JsonStreamer\JsonStreamWriter as AutoMapperJsonStreamWriter; use AutoMapper\MapperContext; use AutoMapper\Metadata\MetadataFactory; use AutoMapper\Metadata\SourcePropertyMetadata; @@ -31,7 +33,10 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\JsonStreamer\StreamReaderInterface; +use Symfony\Component\JsonStreamer\StreamWriterInterface; use Symfony\Component\ObjectMapper\ObjectMapperInterface; +use Symfony\Component\TypeInfo\Type; class ServiceInstantiationTest extends WebTestCase { @@ -327,6 +332,38 @@ public static function mapProvider(): iterable yield [$b, [$a]]; } + public function testJsonStreamerUsesTheAutoMapperImplementation(): void + { + static::bootKernel(); + $container = self::getContainer(); + + $reader = $container->get(StreamReaderInterface::class); + $writer = $container->get(StreamWriterInterface::class); + + // the Symfony services are decorated by the AutoMapper ones + self::assertInstanceOf(AutoMapperJsonStreamReader::class, $reader); + self::assertInstanceOf(AutoMapperJsonStreamWriter::class, $writer); + + $stream = fopen('php://memory', 'r+'); + fwrite($stream, '{"id":1,"name":"foo"}'); + rewind($stream); + + $addressDto = $reader->read($stream, Type::object(AddressDTO::class)); + self::assertInstanceOf(AddressDTO::class, $addressDto); + + // and the writer streams it back + self::assertJson((string) $writer->write($addressDto, Type::object(AddressDTO::class))); + } + + public function testJsonStreamerFallsBackToSymfonyForUnhandledTypes(): void + { + static::bootKernel(); + $writer = self::getContainer()->get(StreamWriterInterface::class); + + // a scalar is not something the AutoMapper owns: the decorated Symfony writer handles it + self::assertSame('"foo"', (string) $writer->write('foo', Type::string())); + } + public function testMapFromClassWithPrivatePropertyPhpstan(): void { static::bootKernel([ diff --git a/tests/Fixtures/CircularNode.php b/tests/Fixtures/CircularNode.php new file mode 100644 index 00000000..9fc4810b --- /dev/null +++ b/tests/Fixtures/CircularNode.php @@ -0,0 +1,15 @@ +setCity($city); + + return $address; + } + + public function testListIsWrittenAsAJsonArray(): void + { + $json = (string) $this->writer()->write( + [$this->address('Paris'), $this->address('Lyon')], + Type::list(Type::object(Fixtures\Address::class)), + ); + + self::assertSame([['city' => 'Paris'], ['city' => 'Lyon']], json_decode($json, true, flags: \JSON_THROW_ON_ERROR)); + } + + public function testDictIsWrittenAsAJsonObjectKeepingItsKeys(): void + { + $json = (string) $this->writer()->write( + ['first' => $this->address('Paris'), 'second' => $this->address('Lyon')], + Type::dict(Type::object(Fixtures\Address::class)), + ); + + self::assertSame( + ['first' => ['city' => 'Paris'], 'second' => ['city' => 'Lyon']], + json_decode($json, true, flags: \JSON_THROW_ON_ERROR), + ); + } + + public function testDictKeysAreEscaped(): void + { + $json = (string) $this->writer()->write( + ['a"b' => $this->address('Paris')], + Type::dict(Type::object(Fixtures\Address::class)), + ); + + self::assertSame(['a"b' => ['city' => 'Paris']], json_decode($json, true, flags: \JSON_THROW_ON_ERROR)); + } + + public function testFalsyScalarElementsAreNotTurnedIntoNull(): void + { + // json_encode(0) returns the falsy string "0": it must not be mistaken for a failure + $json = (string) $this->writer()->write( + [0, 1, '', false, 'a'], + Type::list(Type::object(Fixtures\Address::class)), + ); + + self::assertSame([0, 1, '', false, 'a'], json_decode($json, true, flags: \JSON_THROW_ON_ERROR)); + } + + public function testCircularReferenceIsReported(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper(classPrefix: 'JsonStreamerCircular_'); + + $first = new Fixtures\CircularNode('first'); + $second = new Fixtures\CircularNode('second'); + $first->next = $second; + $second->next = $first; + + $this->expectException(CircularReferenceException::class); + + $stream = $autoMapper->getMapper(Fixtures\CircularNode::class, 'json')->map($first); + + foreach ($stream as $ignored) { + } + } + + public function testRepeatedInstanceInSiblingsIsNotACircularReference(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper(classPrefix: 'JsonStreamerCircular_'); + + $shared = new Fixtures\CircularNode('shared'); + $root = new Fixtures\CircularNode('root'); + $root->next = $shared; + + $json = ''; + foreach ($autoMapper->getMapper(Fixtures\CircularNode::class, 'json')->map($root) as $chunk) { + $json .= $chunk; + } + + self::assertSame( + ['name' => 'root', 'next' => ['name' => 'shared', 'next' => null]], + json_decode($json, true, flags: \JSON_THROW_ON_ERROR), + ); + } +} diff --git a/tests/JsonStreamer/JsonStreamWriterTest.php b/tests/JsonStreamer/JsonStreamWriterTest.php index dbc249fa..b6f6a24f 100644 --- a/tests/JsonStreamer/JsonStreamWriterTest.php +++ b/tests/JsonStreamer/JsonStreamWriterTest.php @@ -4,6 +4,7 @@ namespace AutoMapper\Tests\JsonStreamer; +use AutoMapper\Exception\InvalidMappingException; use AutoMapper\JsonStreamer\JsonStreamWriter; use AutoMapper\Tests\AutoMapperBuilder; use AutoMapper\Tests\AutoMapperTestCase; @@ -81,7 +82,15 @@ classPrefix: 'JsonStreamerPrivate_' $user->addresses[] = $address; $user->money = 20.1; - // The `json` mapper exposes the stream straight through map(). + // `json` is a serialization target, it is not reachable through AutoMapperInterface::map() + try { + $autoMapper->map($user, 'json'); + self::fail('Mapping to the "json" target should not be allowed.'); + } catch (InvalidMappingException $e) { + self::assertStringContainsString('json', $e->getMessage()); + } + + // Only the mapper itself, as used by the JsonStreamer integration, exposes the stream. $stream = $autoMapper->getMapper(Fixtures\User::class, 'json')->map($user); self::assertInstanceOf(\Traversable::class, $stream, 'A json mapper streams its result.'); diff --git a/tests/JsonStreamer/RegistryAwareTest.php b/tests/JsonStreamer/RegistryAwareTest.php new file mode 100644 index 00000000..0d63aba8 --- /dev/null +++ b/tests/JsonStreamer/RegistryAwareTest.php @@ -0,0 +1,127 @@ +register('array', Fixtures\User::class); + $registry->register(Fixtures\User::class, 'array'); + + return $registry; + } + + public function testRegisteredTypeIsReadByTheAutoMapper(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper( + mapPrivatePropertiesAndMethod: true, + classPrefix: 'JsonStreamerRegistryAware_', + ); + $reader = new JsonStreamReader($autoMapper, FallbackJsonStreamReader::create(), $this->registry()); + + $user = $reader->read( + $this->stream('{"id":1,"name":"yolo","age":"13"}'), + Type::object(Fixtures\User::class), + ); + + self::assertInstanceOf(Fixtures\User::class, $user); + // private property, only the AutoMapper can hydrate it + self::assertSame(1, $user->getId()); + } + + public function testUnregisteredTypeFallsBackToSymfony(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper( + mapPrivatePropertiesAndMethod: true, + classPrefix: 'JsonStreamerRegistryAware_', + ); + $reader = new JsonStreamReader($autoMapper, FallbackJsonStreamReader::create(), $this->registry()); + + $address = $reader->read($this->stream('{"city":"Toulon"}'), Type::object(Fixtures\Address::class)); + + // the Symfony reader still builds the object, but it cannot fill the private property the + // AutoMapper would have mapped: this proves the fallback was used + self::assertInstanceOf(Fixtures\Address::class, $address); + self::assertNull((new \ReflectionProperty(Fixtures\Address::class, 'city'))->getValue($address)); + } + + public function testRegisteredTypeIsWrittenByTheAutoMapper(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper( + mapPrivatePropertiesAndMethod: true, + classPrefix: 'JsonStreamerRegistryAware_', + ); + $writer = new JsonStreamWriter($autoMapper, FallbackJsonStreamWriter::create(), $this->registry()); + + $json = (string) $writer->write(new Fixtures\User(1, 'yolo', '13'), Type::object(Fixtures\User::class)); + + // the private "id" property is only exposed through the AutoMapper mapping + self::assertSame(1, json_decode($json, true)['id']); + } + + public function testUnregisteredTypeIsWrittenBySymfony(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper( + mapPrivatePropertiesAndMethod: true, + classPrefix: 'JsonStreamerRegistryAware_', + ); + $writer = new JsonStreamWriter($autoMapper, FallbackJsonStreamWriter::create(), $this->registry()); + + $address = new Fixtures\Address(); + $address->setCity('Toulon'); + + // Symfony only writes public properties, Address exposes none: an empty object proves the + // fallback was used instead of the AutoMapper mapping + self::assertSame('{}', (string) $writer->write($address, Type::object(Fixtures\Address::class))); + } + + public function testNoRegistryHandlesEveryType(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper( + mapPrivatePropertiesAndMethod: true, + classPrefix: 'JsonStreamerRegistryAware_', + ); + $writer = new JsonStreamWriter($autoMapper, FallbackJsonStreamWriter::create()); + + $address = new Fixtures\Address(); + $address->setCity('Toulon'); + + // without a registry every type goes through the AutoMapper, private properties included + self::assertSame(['city' => 'Toulon'], json_decode((string) $writer->write($address, Type::object(Fixtures\Address::class)), true)); + } +} diff --git a/tests/Metadata/ArrayLikeTest.php b/tests/Metadata/ArrayLikeTest.php new file mode 100644 index 00000000..584414fe --- /dev/null +++ b/tests/Metadata/ArrayLikeTest.php @@ -0,0 +1,127 @@ +map(new ArrayLikeBag(['id' => 7, 'name' => 'seven']), ArrayLikeTarget::class); + + self::assertInstanceOf(ArrayLikeTarget::class, $target); + self::assertSame(7, $target->id); + self::assertSame('seven', $target->name); + } + + public function testMissingKeyIsSkippedInsteadOfMappedAsNull(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper(classPrefix: 'ArrayLike_'); + + $target = $autoMapper->map(new ArrayLikeBag(['id' => 3]), ArrayLikeTarget::class); + + self::assertSame(3, $target->id); + // the key is absent from the bag: the target keeps its default value + self::assertSame('', $target->name); + } + + public function testWithoutTheAttributeTheClassIsMappedAsAnObject(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper(classPrefix: 'ArrayLike_'); + + // the same shape without the attribute exposes no readable property, so nothing is mapped + $target = $autoMapper->map(new PlainBag(['id' => 7, 'name' => 'seven']), ArrayLikeTarget::class); + + self::assertSame(0, $target->id); + self::assertSame('', $target->name); + } +} + +/** + * @implements \ArrayAccess + */ +#[Mapper(arrayLike: true)] +class ArrayLikeBag implements \ArrayAccess +{ + /** + * @param array $data + */ + public function __construct( + private array $data = [], + ) { + } + + public function offsetExists(mixed $offset): bool + { + return isset($this->data[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->data[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + $this->data[$offset] = $value; + } + + public function offsetUnset(mixed $offset): void + { + unset($this->data[$offset]); + } +} + +/** + * @implements \ArrayAccess + */ +class PlainBag implements \ArrayAccess +{ + /** + * @param array $data + */ + public function __construct( + private array $data = [], + ) { + } + + public function offsetExists(mixed $offset): bool + { + return isset($this->data[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->data[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + $this->data[$offset] = $value; + } + + public function offsetUnset(mixed $offset): void + { + unset($this->data[$offset]); + } +} + +class ArrayLikeTarget +{ + public int $id = 0; + public string $name = ''; +} From d7d697ab5c924828d1aea52956fc650b99c1f501 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Tue, 28 Jul 2026 00:16:55 +0200 Subject: [PATCH 12/13] refactor(json-streamer): make `json` a first-class source, symmetric with the target Reading a JSON stream used to map from the decoded document class, with a dedicated listener teaching the AutoMapper to treat `JsonStream\Document` as an array-like source, while writing already used the `json` target. The two directions are now named the same way: json -> Class reads a JSON stream Class -> json writes one `json` is therefore a regular array-like side of a mapping: it is kept as-is by the metadata registry, inferred as array-like, read by key from the decoded document, and propagated to the sub-mappers so a nested value keeps `json` as its own source. The `JsonStreamDocumentListener` becomes useless and is removed, and the registry aware mode of the reader and the writer both check the `json` mapping instead of `array` on one side and `json` on the other. --- CHANGELOG.md | 3 ++ docs/bundle/json-streamer.md | 12 ++--- src/AutoMapper.php | 2 +- src/AutoMapperRegistryInterface.php | 2 +- src/Extractor/FromTargetMappingExtractor.php | 4 +- src/Extractor/MappingExtractor.php | 4 +- .../JsonStreamDocumentListener.php | 48 ------------------- src/JsonStreamer/JsonStreamReader.php | 28 +++++++++-- src/JsonStreamer/JsonStreamWriter.php | 6 +-- src/Metadata/MapperMetadata.php | 7 ++- src/Metadata/MetadataFactory.php | 7 +-- src/Metadata/MetadataRegistry.php | 16 ++++--- src/Transformer/MapperDependency.php | 2 +- src/Transformer/ObjectTransformer.php | 13 ++++- src/Transformer/ObjectTransformerFactory.php | 8 +++- tests/JsonStreamer/RegistryAwareTest.php | 7 +-- 16 files changed, 82 insertions(+), 87 deletions(-) delete mode 100644 src/JsonStreamer/JsonStreamDocumentListener.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c582357..38c225ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `json_streamer.enabled` option, which decorates the Symfony reader and writer and keeps them as a fallback for the types the AutoMapper does not handle. Use `json_streamer.only_registered_mapping` to restrict it to the registered mappings. See the [documentation](docs/bundle/json-streamer.md) +- Add a `json` side to the mappers, naming the JSON document itself symmetrically in both + directions: a `json` -> class mapper reads a JSON stream, a class -> `json` mapper writes one. + Both are only reachable through the JsonStreamer integration, `map()` still refuses them - Add a `MapperContext::STREAM` option, to read and write collections without buffering them, so the memory usage stays flat whatever the collection size - Add an `arrayLike` option on the `#[Mapper]` attribute, to read or write a class as a keyed array diff --git a/docs/bundle/json-streamer.md b/docs/bundle/json-streamer.md index cb4e63c8..b5c6dc70 100644 --- a/docs/bundle/json-streamer.md +++ b/docs/bundle/json-streamer.md @@ -72,16 +72,16 @@ automapper: only_registered_mapping: true mapping: mappers: - # reading: array -> User, writing: User -> array - - { source: 'App\Entity\User', target: 'array', reverse: true } + # reading: json -> User, writing: User -> json + - { source: 'App\Entity\User', target: 'json', reverse: true } ``` -A type is considered registered when: +The `json` side names the JSON document itself, symmetrically in both directions: -* for **reading** (JSON to object), a mapper from `array` to the class is registered; -* for **writing** (object to JSON), a mapper from the class to `array` or to `json` is registered. +* **reading** (JSON to object) uses a `json` → class mapper; +* **writing** (object to JSON) uses a class → `json` mapper. -Registering the `array` mapping with `reverse: true`, as above, therefore enables both directions. +Registering the mapping with `reverse: true`, as above, therefore enables both directions. ## Streaming a collection diff --git a/src/AutoMapper.php b/src/AutoMapper.php index d38f423b..5f39ad35 100644 --- a/src/AutoMapper.php +++ b/src/AutoMapper.php @@ -51,7 +51,7 @@ public function __construct( * @template Source of object * @template Target of object * - * @param class-string|'array' $source + * @param class-string|'array'|'json' $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 8a85b331..58f7c791 100644 --- a/src/AutoMapperRegistryInterface.php +++ b/src/AutoMapperRegistryInterface.php @@ -17,7 +17,7 @@ interface AutoMapperRegistryInterface * @template Source of object * @template Target of object * - * @param class-string|'array' $source + * @param class-string|'array'|'json' $source * @param class-string|'array'|'json' $target * * @return ($source is class-string ? ($target is 'array' ? MapperInterface> : MapperInterface) : MapperInterface, Target>) diff --git a/src/Extractor/FromTargetMappingExtractor.php b/src/Extractor/FromTargetMappingExtractor.php index 3cebfd5e..ae988fe3 100644 --- a/src/Extractor/FromTargetMappingExtractor.php +++ b/src/Extractor/FromTargetMappingExtractor.php @@ -122,7 +122,9 @@ private function transformTargetType(string $source, ?Type $type = null): ?Type // Transform objects to array or \stdClass given the target if ($type instanceof Type\ObjectType && \stdClass::class !== $type->getClassName()) { - if ($source === 'array') { + // `json` behaves like `array` here: the nested value is another decoded document, and + // the nested mapper keeps `json` as its source (see ObjectTransformerFactory). + if ($source === 'array' || $source === 'json') { return Type::arrayShape([]); } diff --git a/src/Extractor/MappingExtractor.php b/src/Extractor/MappingExtractor.php index 126246e8..6a40e5af 100644 --- a/src/Extractor/MappingExtractor.php +++ b/src/Extractor/MappingExtractor.php @@ -234,7 +234,9 @@ private function findLastTypeForAccessor(string $source, string $property): ?Typ private function doGetReadAccessor(string $class, string $property, bool $allowExtraProperties = false, bool $arrayLike = false): ?ReadAccessorInterface { if ('json' === $class) { - return null; + // As a source, `json` is the decoded document, read by key; as a target it is never read + // (its mapper yields the value instead of assigning it). + return $arrayLike ? new ArrayReadAccessor($property, true) : null; } if ('array' === $class) { diff --git a/src/JsonStreamer/JsonStreamDocumentListener.php b/src/JsonStreamer/JsonStreamDocumentListener.php deleted file mode 100644 index 537b66b4..00000000 --- a/src/JsonStreamer/JsonStreamDocumentListener.php +++ /dev/null @@ -1,48 +0,0 @@ -mapperMetadata->source, self::DOCUMENT_CLASS, true)) { - $event->sourceArrayLike ??= true; - } - - if (is_a($event->mapperMetadata->target, self::DOCUMENT_CLASS, true)) { - $event->targetArrayLike ??= true; - } - } - - public function onPropertyMetadata(PropertyMetadataEvent $event): void - { - if (is_a($event->mapperMetadata->source, self::DOCUMENT_CLASS, true)) { - $event->source->accessor ??= new ArrayReadAccessor($event->source->property, isArrayAccess: true); - // Guard reads with offsetExists so a missing key is skipped rather than fetched (which - // would emit an "undefined key" warning on the read-only document). - $event->source->checkExists ??= true; - } - } -} diff --git a/src/JsonStreamer/JsonStreamReader.php b/src/JsonStreamer/JsonStreamReader.php index 8ed38883..4bd0e604 100644 --- a/src/JsonStreamer/JsonStreamReader.php +++ b/src/JsonStreamer/JsonStreamReader.php @@ -5,8 +5,10 @@ namespace AutoMapper\JsonStreamer; use AutoMapper\AutoMapperInterface; +use AutoMapper\AutoMapperRegistryInterface; use AutoMapper\Lazy\LazyCollection; use AutoMapper\MapperContext; +use AutoMapper\MapperInterface; use AutoMapper\Metadata\MetadataRegistry; use Symfony\Component\JsonStreamer\StreamReaderInterface; use Symfony\Component\TypeInfo\Type; @@ -34,7 +36,7 @@ final class JsonStreamReader implements StreamReaderInterface { public function __construct( - private readonly AutoMapperInterface $mapper, + private readonly AutoMapperInterface&AutoMapperRegistryInterface $mapper, /** @var StreamReaderInterface> */ private readonly StreamReaderInterface $fallbackStreamReader, /** @@ -56,9 +58,12 @@ public function read($input, Type $type, array $options = []): mixed $buffered = !($options[MapperContext::STREAM] ?? false); $decoded = json_stream_decode($input, $buffered ? 0 : JSON_STREAM_TRANSIENT); + $mapper = $this->jsonMapper($className); + return new LazyCollection( - fn (mixed $rawItem): mixed => \is_array($rawItem) || \is_object($rawItem) - ? $this->mapper->map($rawItem, $className, $options) + // a decoded container is always a document object, anything else is a scalar + static fn (mixed $rawItem): mixed => \is_object($rawItem) + ? $mapper->map($rawItem, $options) : $rawItem, $decoded, $buffered, @@ -72,12 +77,25 @@ public function read($input, Type $type, array $options = []): mixed $buffered = !($options[MapperContext::STREAM] ?? false); $decoded = json_stream_decode($input, $buffered ? 0 : JSON_STREAM_TRANSIENT); - return $this->mapper->map($decoded, $className, $options); + return $this->jsonMapper($className)->map($decoded, $options); } return $this->fallbackStreamReader->read($input, $type, $options); } + /** + * The `json` → class mapper, reading straight from the decoded document. + * + * @param class-string $className + * + * @return MapperInterface + */ + private function jsonMapper(string $className): MapperInterface + { + /** @var MapperInterface */ + return $this->mapper->getMapper('json', $className); + } + /** * Unwrap nullable and generic wrappers, but never a collection (whose wrapped type * is the underlying `array`/`iterable` builtin, not the value we care about). @@ -116,7 +134,7 @@ private function ownedClassName(Type $type): ?string } // Registry aware: without a registered mapper for this class, let the Symfony reader do it. - if (null !== $this->onlyMetadataRegistry && !$this->onlyMetadataRegistry->has('array', $className, true)) { + if (null !== $this->onlyMetadataRegistry && !$this->onlyMetadataRegistry->has('json', $className, true)) { return null; } diff --git a/src/JsonStreamer/JsonStreamWriter.php b/src/JsonStreamer/JsonStreamWriter.php index b7bc3b71..4d69208e 100644 --- a/src/JsonStreamer/JsonStreamWriter.php +++ b/src/JsonStreamer/JsonStreamWriter.php @@ -121,11 +121,7 @@ private function jsonMapper(string $className): ?MapperInterface } // Registry aware: without a registered mapper for this class, let the Symfony writer do it. - if ( - null !== $this->onlyMetadataRegistry - && !$this->onlyMetadataRegistry->has($className, 'json', true) - && !$this->onlyMetadataRegistry->has($className, 'array', true) - ) { + if (null !== $this->onlyMetadataRegistry && !$this->onlyMetadataRegistry->has($className, 'json', true)) { return null; } diff --git a/src/Metadata/MapperMetadata.php b/src/Metadata/MapperMetadata.php index 24514c13..22f616e2 100644 --- a/src/Metadata/MapperMetadata.php +++ b/src/Metadata/MapperMetadata.php @@ -64,6 +64,11 @@ public function isJsonTarget(): bool return 'json' === $this->target; } + public function isJsonSource(): bool + { + return 'json' === $this->source; + } + /** * 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 @@ -96,7 +101,7 @@ public function inferTargetArrayLike(): bool public function inferSourceArrayLike(): bool { - return \in_array($this->source, ['array', \stdClass::class], true); + return \in_array($this->source, ['array', \stdClass::class, 'json'], true); } public function getHash(): string diff --git a/src/Metadata/MetadataFactory.php b/src/Metadata/MetadataFactory.php index b8d4ff7a..da1d3257 100644 --- a/src/Metadata/MetadataFactory.php +++ b/src/Metadata/MetadataFactory.php @@ -85,7 +85,7 @@ public function __construct( } /** - * @param class-string|'array' $source + * @param class-string|'array'|'json' $source * @param class-string|'array'|'json' $target * * @internal @@ -250,7 +250,7 @@ private function createGeneratorMetadata(MapperMetadata $mapperMetadata): Genera // Create the target property metadata if ($propertyMappedEvent->target->readAccessor === null) { - $propertyMappedEvent->target->readAccessor = $extractor->getReadAccessor($mapperMetadata->target, $propertyMappedEvent->target->property, $mapperEvent->allowExtraProperties ?? $this->configuration->allowExtraProperties, $mapperMetadata->isTargetArrayLike()); + $propertyMappedEvent->target->readAccessor = $extractor->getReadAccessor($mapperMetadata->target, $propertyMappedEvent->target->property, $mapperEvent->allowExtraProperties ?? $this->configuration->allowExtraProperties, $mapperMetadata->isTargetArrayLike() && !$mapperMetadata->isJsonTarget()); } if ($propertyMappedEvent->target->writeMutator === null) { @@ -397,9 +397,6 @@ public static function create( $eventDispatcher->addListener(GenerateMapperEvent::class, new MapFromListener($serviceLocator, $expressionLanguage)); $eventDispatcher->addListener(GenerateMapperEvent::class, new MapperListener()); - $jsonStreamDocumentListener = new \AutoMapper\JsonStreamer\JsonStreamDocumentListener(); - $eventDispatcher->addListener(GenerateMapperEvent::class, [$jsonStreamDocumentListener, 'onGenerateMapper']); - $eventDispatcher->addListener(PropertyMetadataEvent::class, [$jsonStreamDocumentListener, 'onPropertyMetadata']); $eventDispatcher->addListener(GenerateMapperEvent::class, new MapProviderListener()); if (interface_exists(ObjectMapperInterface::class)) { diff --git a/src/Metadata/MetadataRegistry.php b/src/Metadata/MetadataRegistry.php index 5b901a0a..6b20870b 100644 --- a/src/Metadata/MetadataRegistry.php +++ b/src/Metadata/MetadataRegistry.php @@ -26,14 +26,15 @@ public function __construct( } /** - * @param class-string|'array' $source + * @param class-string|'array'|'json' $source * @param class-string|'array'|'json' $target */ public function get(string $source, string $target, bool $registered = false): MapperMetadata { - $source = $this->getRealClassName($source); - // `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. + // `json` is a serialization format that behaves like `array` for every metadata concern, so + // it is kept as a plain array-like side downstream rather than resolved to a class. + /** @var class-string|'array' $source */ + $source = 'json' === $source ? 'json' : $this->getRealClassName($source); /** @var class-string|'array' $target */ $target = 'json' === $target ? 'json' : $this->getRealClassName($target); @@ -41,7 +42,7 @@ public function get(string $source, string $target, bool $registered = false): M } /** - * @param class-string|'array' $source + * @param class-string|'array'|'json' $source * @param class-string|'array'|'json' $target */ public function register(string $source, string $target): void @@ -50,12 +51,13 @@ public function register(string $source, string $target): void } /** - * @param class-string|'array' $source + * @param class-string|'array'|'json' $source * @param class-string|'array'|'json' $target */ public function has(string $source, string $target, bool $onlyRegistered): bool { - $source = $this->getRealClassName($source); + /** @var class-string|'array' $source */ + $source = 'json' === $source ? 'json' : $this->getRealClassName($source); /** @var class-string|'array' $target */ $target = 'json' === $target ? 'json' : $this->getRealClassName($target); diff --git a/src/Transformer/MapperDependency.php b/src/Transformer/MapperDependency.php index 90e51d58..eebf093d 100644 --- a/src/Transformer/MapperDependency.php +++ b/src/Transformer/MapperDependency.php @@ -14,7 +14,7 @@ final readonly class MapperDependency { /** - * @param class-string|'array' $source + * @param class-string|'array'|'json' $source * @param class-string|'array'|'json' $target */ public function __construct( diff --git a/src/Transformer/ObjectTransformer.php b/src/Transformer/ObjectTransformer.php index b8eaa8f2..490e0320 100644 --- a/src/Transformer/ObjectTransformer.php +++ b/src/Transformer/ObjectTransformer.php @@ -27,6 +27,13 @@ public function __construct( private readonly Type $sourceType, private readonly Type $targetType, public bool $deepTargetToPopulate = true, + /** + * Forces the sub-mapper source, for the sides that are not a real class: a nested value of a + * `json` source is another decoded document, so it keeps `json` as its own source. + * + * @var class-string|'array'|'json'|null + */ + private readonly ?string $sourceOverride = null, ) { } @@ -127,10 +134,14 @@ private function getDependencyName(): string } /** - * @return class-string|'array' + * @return class-string|'array'|'json' */ private function getSource(): string { + if (null !== $this->sourceOverride) { + return $this->sourceOverride; + } + $sourceTypeName = 'array'; if ($this->sourceType instanceof Type\ObjectType) { diff --git a/src/Transformer/ObjectTransformerFactory.php b/src/Transformer/ObjectTransformerFactory.php index a3fa02f4..f6fb71cd 100644 --- a/src/Transformer/ObjectTransformerFactory.php +++ b/src/Transformer/ObjectTransformerFactory.php @@ -33,7 +33,13 @@ public function getTransformer(SourcePropertyMetadata $source, TargetPropertyMet return null; } - return new ObjectTransformer($source->type, $target->type); + // A nested value of a `json` source is another decoded document: keep `json` as the source + // of the sub-mapper so the whole chain is read the same way. + return new ObjectTransformer( + $source->type, + $target->type, + sourceOverride: $mapperMetadata->isJsonSource() ? 'json' : null, + ); } private function isObjectType(Type $type): bool diff --git a/tests/JsonStreamer/RegistryAwareTest.php b/tests/JsonStreamer/RegistryAwareTest.php index 0d63aba8..7eb78f9e 100644 --- a/tests/JsonStreamer/RegistryAwareTest.php +++ b/tests/JsonStreamer/RegistryAwareTest.php @@ -38,10 +38,11 @@ private function stream(string $json) private function registry(): MetadataRegistry { - // Fixtures\User is registered, Fixtures\Address is not + // Fixtures\User is registered, Fixtures\Address is not. The registration is symmetric with + // the mappers actually used: `json` -> class when reading, class -> `json` when writing. $registry = new MetadataRegistry(new Configuration()); - $registry->register('array', Fixtures\User::class); - $registry->register(Fixtures\User::class, 'array'); + $registry->register('json', Fixtures\User::class); + $registry->register(Fixtures\User::class, 'json'); return $registry; } From c7316506cb74a09dc2cd11a3ef3c8a36c63891e7 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Tue, 28 Jul 2026 00:32:59 +0200 Subject: [PATCH 13/13] docs(bench): refresh the results with the `json` source and the extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read path changed the most: a JSON stream is now read through a generated `json` -> class mapper instead of being decoded to an intermediate shape, so deserializing a single object goes from ~101 to ~13.5 µs (~11x faster than the Symfony streamer) and reading a 20k collection from ~3 s to ~0.15-0.25 s (~16-27x faster), while streaming keeps a bounded peak. On the write side the generated `json` mappers brought the per element cost from ~4.0 to ~2.2 µs, and streaming a nested collection now peaks exactly like the Symfony writer. Also document that `json_stream_decode()` is ~6.7x faster with the `json_stream` extension than with the polyfill, since every read number assumes the extension. --- bench/README.md | 290 +++++++++++++++++++++----------------------- bench/composer.lock | 9 +- 2 files changed, 141 insertions(+), 158 deletions(-) diff --git a/bench/README.md b/bench/README.md index d1ac3b4c..3289a5b6 100644 --- a/bench/README.md +++ b/bench/README.md @@ -118,29 +118,31 @@ where it is measured (see `src/Factory/MapperFactory.php`): - **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`. +- **no checking** — the above plus `groupChecking: false`. ## Results -Indicative numbers on PHP 8.5 (your absolute numbers will differ; the *ratios* are -the point). Lower is better. +Numbers below were measured on PHP 8.5 with the `json_stream` extension loaded (see +[the note on the extension](#the-json_stream-extension-matters-on-the-read-path)). +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** | +| AutoMapper, no attribute checking | ~2.9 µs | fastest mapper | +| AutoMapper, eval loader | ~7.1 µs | — | +| AutoMapper, no constructor | ~7.1 µs | — | +| AutoMapper (default) | ~7.2 µs | 1× | +| Symfony Serializer (`denormalize`) | ~165 µ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 | +| AutoMapper, no attribute checking | ~2.5 µs | +| AutoMapper (default) | ~8.0 µs | +| Symfony Serializer (`normalize`) | ~63 µs | ### Single object — deserialize (JSON → object) @@ -149,99 +151,81 @@ 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. +| AutoMapper, no checking (`json_decode` + map) | ~5.8 µs | +| AutoMapper JSON streamer, no attribute checking | ~8.2 µs | +| AutoMapper (`json_decode` + map) | ~10.6 µs | +| AutoMapper JSON streamer | ~13.5 µs | +| Symfony JSON streamer | ~156 µs | +| Symfony Serializer | ~169 µs | + +The AutoMapper JSON streamer is now **~11× faster than Symfony's** on this shape, and +within ~1.3× of a plain `json_decode` + `map()`. It reads straight from the decoded +document through a generated `json` → class mapper, so nothing is materialized twice. + +> **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. ### 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. +| **Manual** (hand-written normalization + `json_encode` — *fair floor*) | ~1.3 µs | +| AutoMapper, no attribute checking (map + `json_encode`) | ~3.6 µs | +| AutoMapper, no checking (map + `json_encode`) | ~3.8 µs | +| AutoMapper JSON streamer, no attribute checking | ~7.4 µs | +| Symfony JSON streamer | ~9.2 µs | +| AutoMapper (map + `json_encode`) | ~9.5 µs | +| AutoMapper JSON streamer | ~14.2 µs | +| Symfony Serializer | ~69 µs | + +The **manual** row is the fair baseline: it produces/consumes the same `Person` graph +the libraries do, just by hand (see `src/ManualMapper.php`). ### Object to object | Approach | Time | |----------|-----:| -| Manual (hand-written) | ~0.2 µs | +| Manual (hand-written) | ~0.23 µ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, …). +| AutoMapper | ~5.0 µs | +| AutoMapper ObjectMapper bridge | ~5.5 µs | +| Symfony ObjectMapper | ~49 µs (**~10× slower**) | + +### Collection — read a list of `Person` (`src/CollectionBench.php`) + +Reading a JSON array of `Person`, **every object fully hydrated** (the loop reads a +nested field so each library does the same work). Peak memory (`mem_peak`) and time: + +| Approach | 1 000 | 20 000 | time (20k) | +|----------|------:|-------:|-----------:| +| `json_decode` (array only, no objects) | 14 MB | 93 MB | 0.09 s | +| AutoMapper `mapCollection`, no checking (eager) | 16 MB | 108 MB | 0.21 s | +| AutoMapper `mapCollection` (eager) | 15 MB | 107 MB | 0.29 s | +| **AutoMapper JSON streamer, `STREAM`, no checking** | 11 MB | **21 MB** | **0.15 s** | +| **AutoMapper JSON streamer, `STREAM`** | 11 MB | **21 MB** | 0.25 s | +| AutoMapper JSON streamer, buffered, no attr checking | 18 MB | 183 MB | 0.25 s | +| AutoMapper JSON streamer, buffered | 18 MB | 183 MB | 0.37 s | +| Symfony Serializer | 14 MB | 101 MB | 3.45 s | +| Symfony JSON streamer, **iterable** (lazy) | 10 MB | **10 MB** | 4.14 s | +| Symfony JSON streamer, `list` (materialized) | 43 MB | 682 MB | 4.82 s | + +This is where the `json` source pays off the most: streaming through the AutoMapper is +**~16–27× faster than Symfony's streamer** (0.15–0.25 s vs 4.14 s) while staying within +~2× of its memory, and it is *also faster than eagerly decoding the whole array* with +`mapCollection`. Unlike on the read path before, disabling the checks now matters again +(0.25 s → 0.15 s), because the decoding is no longer the bottleneck. + +Use `STREAM` for large inputs: buffered mode memoizes every mapped object (183 MB at +20k) to stay countable and re-iterable, while `STREAM` keeps a bounded peak at the cost +of being single-pass. #### `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: +The same JSON array read by the same Symfony reader costs **~10 MB or ~682 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. @@ -249,85 +233,83 @@ only on the requested type: `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`.** + ~20 000 ghosts + ~20 000 live generators retained at once. 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. +### Collection — write a list of `Person` (`src/WriteCollectionBench.php`) -> 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: +Serializing a list of `Person` (fed from a generator) to JSON, comparing the two JSON +stream **writer** implementations: | 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** | +| **Symfony JSON streamer — `getIterator()` (streamed)** | **7.2 MB** | **7.2 MB** | **0.10 s** | +| Symfony JSON streamer — `__toString()` | 7.5 MB | 16 MB | 0.12 s | +| AutoMapper `mapCollection` + `json_encode` (eager) | 11 MB | 77 MB | 0.14 s | +| **AutoMapper JSON streamer — `STREAM=true` (streamed)** | 7.8 MB | **7.8 MB** | 0.15 s | +| AutoMapper JSON streamer — `__toString()` | 8.2 MB | 17 MB | 0.17 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. +Both streamed writers keep a **flat peak** whatever the collection size, and the +AutoMapper one is now within ~1.5× of Symfony's while running every element through the +full mapping pipeline. The eager `mapCollection` + `json_encode` path is comparable in +time but allocates the whole array-of-arrays (77 MB at 20k). -### Collection — write, one object with a big nested collection (`src/WriteWideObjectBench.php`) +### 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`): +A single `Person` whose `addresses` is huge, so the nested collection is what streams: | 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 +| **Symfony JSON streamer — `getIterator()`** | **9.5 MB** | **23 MB** | **0.038 s** | +| Symfony JSON streamer — `__toString()` | 9.8 MB | 27 MB | 0.047 s | +| AutoMapper — `getIterator()` `STREAM=true`, no attr | 9.5 MB | 23 MB | 0.052 s | +| AutoMapper — `__toString()`, no attr checking | 9.8 MB | 27 MB | 0.062 s | +| AutoMapper — `getIterator()` `STREAM=true` | 9.5 MB | 23 MB | 0.10 s | +| AutoMapper — `__toString()` (buffered) | 9.8 MB | 27 MB | 0.11 s | + +Memory is now **identical to Symfony** in both consumption modes: the nested collection +is streamed through the sub-mappers instead of being encoded whole. Disabling attribute +checking gives a **~2× write-side speed-up** here, because encoding is pure AutoMapper +with no decoding in the path. + +#### Why Symfony's writer is still faster at collection scale + +For a *single* small object the two writers are close (serialize: ~14 µs vs ~9 µs), so +the collection gap 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. +| | per element | +|---|------:| +| AutoMapper writer (`STREAM`) | **~2.2 µs** | +| Symfony writer (`getIterator`) | **~0.9 µs** | + +AutoMapper pays ~2.4× more per element (it was ~5× before the generated `json` mappers +replaced the lazy-structure walk). Symfony emits from a compiled, type-specialized +writer that reads fields straight off the object; the AutoMapper one additionally runs +each element through the mapping pipeline. Reach for it when you need that pipeline +(renames, transformers, `#[MapTo]`, private properties, discriminators, …); for a plain +object graph with no mapping, Symfony's writer is still the faster tool. + +### The `json_stream` extension matters on the read path + +`json_stream_decode()` comes either from the +[`json_stream` PHP extension](https://github.com/joelwurtz/php-json-stream) or from the +pure-PHP [polyfill](https://github.com/joelwurtz/php-json-stream-polyfill) that ships +with the AutoMapper. Same single-object read, same code: + +| Decoder | Time | +|---------|-----:| +| `json_stream` extension | ~14 µs | +| polyfill (pure PHP) | ~93 µs | + +That is a **~6.7× difference on the read path**, so all the read numbers above assume +the extension. Install it with [pie](https://github.com/php/pie): + +```shell +pie install joelwurtz/json-stream +``` + +The write path does not decode anything and is unaffected. diff --git a/bench/composer.lock b/bench/composer.lock index 32da351f..d0b9a47a 100644 --- a/bench/composer.lock +++ b/bench/composer.lock @@ -210,7 +210,7 @@ }, { "name": "joelwurtz/json-stream-polyfill", - "version": "dev-main", + "version": "0.1.0", "source": { "type": "git", "url": "https://github.com/joelwurtz/php-json-stream-polyfill.git", @@ -229,7 +229,6 @@ "provide": { "ext-json_stream": "*" }, - "default-branch": true, "type": "library", "autoload": { "files": [ @@ -267,11 +266,11 @@ "dist": { "type": "path", "url": "..", - "reference": "71f5499a2d1776fef25db651626860c5163f5f6f" + "reference": "d7d697ab5c924828d1aea52956fc650b99c1f501" }, "require": { "composer-runtime-api": "^2.1 || ^3.0", - "joelwurtz/json-stream-polyfill": "@dev", + "joelwurtz/json-stream-polyfill": "^0.1.0", "nikic/php-parser": "^5.6", "php": "^8.4", "phpdocumentor/type-resolver": "^1.12 || ^2.0", @@ -319,6 +318,8 @@ "willdurand/negotiation": "^3.1" }, "suggest": { + "ext-json_stream": "Faster JSON stream decoding, replaces the joelwurtz/json-stream-polyfill package (pie install joelwurtz/json-stream)", + "symfony/json-streamer": "Allow to read and write JSON streams through the AutoMapper", "symfony/serializer": "Allow to use symfony serializer attributes in mapping" }, "type": "library",