Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
/Tests/ export-ignore
/tests/ export-ignore
/bench/ export-ignore
/docs/ export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/.php-cs-fixer.php export-ignore
/castor.php export-ignore
/castor.composer.json export-ignore
/castor.composer.lock export-ignore
/mkdocs.yaml export-ignore
/phpstan.neon export-ignore
/phpunit.xml export-ignore
/pyproject.toml export-ignore
/poetry.lock export-ignore
/bootstrap.php export-ignore
/.github/ export-ignore
35 changes: 24 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Feature
## [10.3.0] - 2026-09-16
Comment thread
joelwurtz marked this conversation as resolved.

### Added
- 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
Expand All @@ -24,34 +26,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
resolved once during metadata discovery and reused everywhere, instead of being guessed from the
source and target names

### Changed
These are bug fixes, but they change the output of mappings that already worked:

- Convert between different backed enums through their backing value instead of copying the source instance
- Keep the concrete Symfony Uid subclass (e.g. `UuidV4`) when copying a uid instead of building a base `Uuid`/`Ulid`
- Keep the concrete date time subclass in date time to date time mapping instead of always building `DateTime`/`DateTimeImmutable`
- The `AutoMapperNormalizer` no longer claims value objects handled by dedicated symfony normalizers (`DateTimeInterface`, `BackedEnum`, `AbstractUid`, etc.), which could produce a structure dump instead of the expected representation
- The highest priority `Mapper` attribute wins as documented, instead of the lowest

### Fixed
- Resolve API Platform IRIs in every JSON format, not only JSON-LD, so a relation sent as an IRI on `application/json` or `application/merge-patch+json` no longer reaches the mapper as a raw string
- Use the `MapFrom` attribute reference instead of `MapTo` when resolving transformers in `MapFromListener`
- Do not run a transformation on a null source value when the target is not nullable, a `TypeError` is thrown for typed properties instead of the transformation crashing on the null value
- Create backed enum from scalar source value instead of assigning the raw scalar
- Convert between different backed enums through their backing value instead of copying the source instance
- Normalize leading backslash in class names resolved from docblocks, fixing a parse error in generated mappers for global namespace classes and the `__PM__` proxy name off-by-one
- Implement `CheckTypeInterface` on enum, date-time and uid transformers so union source types generate a runtime check for every branch
- Unwrap nullable doctrine collection target type, items are mapped to the collection value type instead of plain arrays
- Capture the context in the `isAllowedAttribute` closure, fixing `MapToContext` combined with `skip_null_values`
- Guard nested property accessors (`parent.child`) against null or uninitialized parent values on read, write and constructor paths, support private nested leaf properties and nullable parent types
- Support variadic constructor parameters, values are spread as individual arguments and an absent variadic no longer throws `MissingConstructorArgumentsException`
- The highest priority `Mapper` attribute wins as documented, instead of the lowest
- Correct identifier hashing: scalar identifiers are cast to string, object identifiers are hashed through their own mapper and `hash_final` no longer receives a string as its boolean argument, fixing mappers generated with `strictTypes: true`
- Atomic mapper file writes and locked registry updates in `FileLoader`, fixing partially written files and lost registry entries under concurrency, the cache warmer no longer discards previously registered mapper hashes
- Initialize `LazyMap` only once instead of re-running the mapping on every access
- Align `Map` attribute `if` condition semantics with symfony/object-mapper: `if: false` never maps the property, callables receive the value first and class level `Map` targets also match child classes
- Do not register the cache warmer when the `eval` loader is enabled, fixing container compilation with `automapper.loader.eval: true`
- Detect doctrine entities even when their metadata is not loaded yet, fixing missing provider and identifier on cold metadata factories
- Do not map static properties
- Keep the concrete Symfony Uid subclass (e.g. `UuidV4`) when copying a uid instead of building a base `Uuid`/`Ulid`
- Keep the concrete date time subclass in date time to date time mapping instead of always building `DateTime`/`DateTimeImmutable`
- Guard a single matching union branch at runtime so a value of an unhandled union member is not blindly transformed
- The `AutoMapperNormalizer` no longer claims value objects handled by dedicated symfony normalizers (`DateTimeInterface`, `BackedEnum`, `AbstractUid`, etc.), which could produce a structure dump instead of the expected representation
- Use the computed resource class in the API Platform JSON-LD context transformer instead of resolving it from a non-resource source (operator precedence fix)
- Only shorten an API Platform resource to its id for `array` or `mixed` targets, avoiding a `TypeError` on scalar targets
- Allow `final` and `static` public getters to be used with `#[MapToContext]`
- Run the object mapper listeners after the Doctrine and discriminator listeners in the bundle, so a `Map`-attributed source class keeps its Doctrine provider and discriminator handling
- The JsonStreamer integration no longer claims enums and uids, which were read as `null` and written as a structure dump (`{"name":"FLAT","value":"flat"}`) instead of being delegated to the Symfony reader and writer

## [10.2.0] - 2026-04-27
### Added
Expand All @@ -74,18 +81,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [GH#331](https://github.com/jolicode/automapper/pull/331) Fix syntax error in supports method return statement example
- [GH#329](https://github.com/jolicode/automapper/pull/329) Fix some typos

## [10.0.3] - 2025-02-25
## [10.0.3] - 2026-02-25
- [GH#327](https://github.com/jolicode/automapper/pull/327) Fix cache extractor in symfony bundle being mixed between source and target.

## [10.0.2] - 2025-02-24
## [10.0.2] - 2026-02-24
### Fixed
- [GH#326](https://github.com/jolicode/automapper/pull/326) Fix array, be consistent with old behavior, undefined array should be mapped with their keys.

## [10.0.1] - 2025-02-24
## [10.0.1] - 2026-02-24
### Fixed
- [GH#325](https://github.com/jolicode/automapper/pull/325) Fix creating union or intersection type when not enough types.

## [10.0.0] - 2025-02-10
## [10.0.0] - 2026-02-10
### Added
- [GH#297](https://github.com/jolicode/automapper/pull/297) Support PHP 8.5 and Symfony 8, this library now use the `TypeInfo` Component for types instead of PropertyInfo directly.
- [GH#297](https://github.com/jolicode/automapper/pull/297) Debug command now show the type of each property mapped, transformers will also display more information.
Expand All @@ -110,6 +117,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Miscellaneous
- [GH#297](https://github.com/jolicode/automapper/pull/297) Add a castor task to serve the symfony app in tests for debugging purpose.

## [9.5.1] - 2026-04-27
### Fixed
- Allow type resolver deps in 2.0

## [9.5.0] - 2025-09-18
### Added
- [GH#260](https://github.com/jolicode/automapper/pull/260) Add support for identifiers detection and comparison of objects, this allow mappers to detect if objects are equals based on some properties, which allow better deep merge / update of collections.
Expand Down Expand Up @@ -525,12 +536,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
* [AutoMapper] [GH#179](https://github.com/janephp/janephp/pull/179) Fixing incompatible changes in Symfony 5.0

[Unreleased]: https://github.com/jolicode/automapper/compare/10.1.0...HEAD
[10.3.0]: https://github.com/jolicode/automapper/compare/10.2.0...10.3.0
[10.2.0]: https://github.com/jolicode/automapper/compare/10.1.0...10.2.0
[10.1.0]: https://github.com/jolicode/automapper/compare/10.0.3...10.1.0
[10.0.3]: https://github.com/jolicode/automapper/compare/10.0.2...10.0.3
[10.0.2]: https://github.com/jolicode/automapper/compare/10.0.1...10.0.2
[10.0.1]: https://github.com/jolicode/automapper/compare/10.0.0...10.0.1
[10.0.0]: https://github.com/jolicode/automapper/compare/9.5.0...10.0.0
[9.5.1]: https://github.com/jolicode/automapper/compare/9.5.0...9.5.1
[9.5.0]: https://github.com/janephp/janephp/compare/9.4.1...9.5.0
[9.4.1]: https://github.com/janephp/janephp/compare/9.4.0...9.4.1
[9.4.0]: https://github.com/janephp/janephp/compare/9.3.1...9.4.0
Expand Down
20 changes: 20 additions & 0 deletions docs/mapping/mapper-attribute.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@ with a single attribute.
The `#[Mapper]` attribute supports most of the configuration parameters specified in the [global configuration](../getting-started/configuration.md).
It will override the global configuration for the specified mapping.

## Array-like classes

By default a class is mapped as a typed object: its properties are discovered through reflection.
Set `arrayLike: true` to read or write it as a keyed array shape instead, where the keys are
dynamic and come from the other side of the mapping. This is what `array`, `stdClass` and `json`
already do implicitly, and it is the option to use for a class exposing arbitrary keys through
`ArrayAccess`.

```php
#[Mapper(arrayLike: true)]
class Bag implements \ArrayAccess
{
private array $values = [];

// ...
}
```

Left to `null` the behaviour is inferred.

## Register

This attribute may also be used when registering mappers manually when using the Symfony bundle.
Expand Down
2 changes: 1 addition & 1 deletion src/Attribute/Mapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ public function __construct(
public string|array|null $source = null,
public string|array|null $target = null,
public ?bool $checkAttributes = null,
public ?bool $checkGroups = null,
public ?ConstructorStrategy $constructorStrategy = null,
public ?bool $allowReadOnlyTargetToPopulate = null,
public ?bool $strictTypes = null,
Expand All @@ -34,6 +33,7 @@ public function __construct(
public ?bool $allowExtraProperties = null,
public ?Discriminator $discriminator = null,
public ?bool $arrayLike = null,
public ?bool $checkGroups = null,
) {
}
}
8 changes: 4 additions & 4 deletions src/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,6 @@ public function __construct(
* If the attributes should be checked to map the properties.
*/
public bool $attributeChecking = true,
/**
* Make mapper group aware, configure this to false when there is no groups behavior in your mapping, saving a lot of times.
*/
public bool $groupChecking = true,
/**
* If the mappers should be automatically generated if it does not exist
* Otherwise the mapper will throw a MapperNotFoundException.
Expand Down Expand Up @@ -62,6 +58,10 @@ public function __construct(
* Enable this option to extract the type from the getter instead of the setter.
*/
public bool $extractTypesFromGetter = false,
/**
* Make mapper group aware, configure this to false when there is no groups behavior in your mapping, saving a lot of times.
*/
public bool $groupChecking = true,
) {
}
}
2 changes: 1 addition & 1 deletion src/Event/GenerateMapperEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ public function __construct(
public array $properties = [],
public ?Provider $provider = null,
public ?bool $checkAttributes = null,
public ?bool $checkGroups = null,
public ?ConstructorStrategy $constructorStrategy = null,
public ?bool $allowReadOnlyTargetToPopulate = null,
public ?bool $strictTypes = null,
Expand All @@ -34,6 +33,7 @@ public function __construct(
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,
public ?bool $checkGroups = null,
) {
}
}
7 changes: 2 additions & 5 deletions src/JsonStreamer/JsonStreamReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use AutoMapper\MapperContext;
use AutoMapper\MapperInterface;
use AutoMapper\Metadata\MetadataRegistry;
use AutoMapper\ValueObjectTypes;
use Symfony\Component\JsonStreamer\StreamReaderInterface;
use Symfony\Component\TypeInfo\Type;
use Symfony\Component\TypeInfo\Type\CollectionType;
Expand Down Expand Up @@ -125,11 +126,7 @@ private function ownedClassName(Type $type): ?string
/** @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)
) {
if (ValueObjectTypes::isUnsupported($className)) {
return null;
}

Expand Down
12 changes: 4 additions & 8 deletions src/JsonStreamer/JsonStreamWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use AutoMapper\AutoMapperRegistryInterface;
use AutoMapper\MapperInterface;
use AutoMapper\Metadata\MetadataRegistry;
use AutoMapper\ValueObjectTypes;
use Symfony\Component\JsonStreamer\StreamWriterInterface;
use Symfony\Component\TypeInfo\Type;
use Symfony\Component\TypeInfo\Type\CollectionType;
Expand Down Expand Up @@ -57,9 +58,8 @@ public function write(
}

if (
$unwrapped instanceof ObjectType
&& \is_object($data)
&& $unwrapped->getClassName() === $data::class
\is_object($data)
&& $this->ownedClassName($unwrapped) === $data::class
&& ($mapper = $this->jsonMapper($data::class)) !== null
) {
return $this->wrap(static fn (): iterable => $mapper->map($data, $options) ?? []);
Expand Down Expand Up @@ -191,11 +191,7 @@ private function ownedClassName(Type $type): ?string
/** @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)
) {
if (ValueObjectTypes::isUnsupported($className)) {
return null;
}

Expand Down
27 changes: 5 additions & 22 deletions src/Normalizer/AutoMapperNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use AutoMapper\Exception\MissingConstructorArgumentsException;
use AutoMapper\MapperContext;
use AutoMapper\Metadata\MetadataRegistry;
use AutoMapper\ValueObjectTypes;
use Symfony\Component\Serializer\Exception\CircularReferenceException as SymfonyCircularReferenceException;
use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException as SymfonyMissingConstructorArgumentsException;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
Expand All @@ -26,20 +27,6 @@
*/
readonly class AutoMapperNormalizer implements NormalizerInterface, DenormalizerInterface
{
/**
* Value object types handled by their own dedicated symfony normalizer, AutoMapper must not claim them
* otherwise it would produce a structure dump instead of e.g. an RFC3339 date string.
*
* @var list<class-string>
*/
private const array UNSUPPORTED_TYPES = [
\DateTimeInterface::class,
\DateTimeZone::class,
\DateInterval::class,
\UnitEnum::class,
\Symfony\Component\Uid\AbstractUid::class,
];

private const array SERIALIZER_CONTEXT_MAPPING = [
AbstractNormalizer::GROUPS => MapperContext::GROUPS,
AbstractNormalizer::ATTRIBUTES => MapperContext::ALLOWED_ATTRIBUTES,
Expand Down Expand Up @@ -106,10 +93,8 @@ public function supportsNormalization(mixed $data, ?string $format = null, array
return false;
}

foreach (self::UNSUPPORTED_TYPES as $unsupportedType) {
if ($data instanceof $unsupportedType) {
return false;
}
if (ValueObjectTypes::isUnsupported($data::class)) {
return false;
}

if ($this->onlyMetadataRegistry === null) {
Expand All @@ -128,10 +113,8 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form
return false;
}

foreach (self::UNSUPPORTED_TYPES as $unsupportedType) {
if (is_a($type, $unsupportedType, true)) {
return false;
}
if (ValueObjectTypes::isUnsupported($type)) {
return false;
}

if ($this->onlyMetadataRegistry === null) {
Expand Down
39 changes: 39 additions & 0 deletions src/ValueObjectTypes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace AutoMapper;

/**
* Value object types handled by their own dedicated Symfony normalizer or value transformer.
*
* The AutoMapper must not claim them, otherwise it would produce a structure dump
* (`{"name":"FLAT","value":"flat"}`) instead of the expected representation (`"flat"`).
*
* @internal
*/
final class ValueObjectTypes
{
/** @var list<class-string> */
public const array UNSUPPORTED = [
\DateTimeInterface::class,
\DateTimeZone::class,
\DateInterval::class,
\UnitEnum::class,
\Symfony\Component\Uid\AbstractUid::class,
];

/**
* @param class-string $className
*/
public static function isUnsupported(string $className): bool
{
foreach (self::UNSUPPORTED as $unsupported) {
if (is_a($className, $unsupported, true)) {
return true;
}
}

return false;
}
}
21 changes: 21 additions & 0 deletions tests/JsonStreamer/JsonStreamReaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,25 @@ classPrefix: 'JsonStreamReaderPrivate_',

self::assertEquals($user, $user2);
}

public function testEnumIsDelegatedToTheFallbackReader(): void
{
$autoMapper = AutoMapperBuilder::buildAutoMapper(classPrefix: 'JsonStreamReaderEnum_');
$reader = new JsonStreamReader($autoMapper, FallbackJsonStreamReader::create());

self::assertSame(
Fixtures\AddressType::FLAT,
$reader->read($this->stream('"flat"'), Type::enum(Fixtures\AddressType::class)),
);

$list = $reader->read(
$this->stream('["flat","apartment"]'),
Type::list(Type::enum(Fixtures\AddressType::class)),
);

self::assertSame(
[Fixtures\AddressType::FLAT, Fixtures\AddressType::APARTMENT],
iterator_to_array($list),
);
}
}
Loading
Loading