Missing target data in Custom Transformer
It would be great to have access to the target object's data and the specific property being transformed in the transform method of a custom transformer. This would greatly help in creating more generic data transformation rules.
If I have a data field that contains IDs instead of specific objects, and I want to create a generic transformer to translate IDs into objects, I currently have to create a separate transformer for each transformation. The only difference between these transformers is the className, which seems unnecessary to me. If I had access to the target object's data, I could determine this directly within it (similarly to the support method).
Same issue here.
My case:
I map the DTO, containing some references as integer IDs to the Doctrine Entity, containing related entities of the different types - so i want to create a correspondent Entity based on its ID for every DTO/Entity property pair. Now, i have no possibility to determine the target Entity class with this concrete ID.
My proposal:
Give the array|null return type to the supports() method (where we have the full access to the source and the target metadata, null = 'not supported') to have it written in the generated mapper class (and cached) and have it as a fourth parameter of the transform() method (or have a separated method for that purpose with the same input parameters as support() - it will be even better). Using this, the property mappers can be more flexible.
I think you can use TransformerFactory for that, they get all needed metadata. For example something like that
class GenericTransformerFactory
{
public function getTransformer(TypesMatching $types, SourcePropertyMetadata $source, TargetPropertyMetadata $target, MapperMetadata $mapperMetadata): ?TransformerInterface
{
$targetType = $types->getTargetUniqueType($types->getSourceUniqueType());
return new GenericTransformer($targetType->getClassName());
}
}
But transformerFactories argument in AutoMapper::create() is deprecated, I am not sure why. And I am not familiar with Symfony, so don't know how to inject custom factories there
@MrMeshok Thank you for your advice!
As i see, it should not be difficult to override the TransformerFactory in Symfony - the automapper bundle already implements a automapper.transformer_factory tag for autowiring. Then it will be possible to use your own PropertyTransformer and add a parameter to the transform() call.
It's deprecated because all related interface / class for this are internal and we don't want to provide a BC contract on this (too much work and it's rarely used)
Add the target to the custom transformer would be a BC break since we have to change the interface
I'm also relunctant to do this because the target is not ready when calling the transformer, not all properties may have been set, and i think it opens a door with too many bugs / issues / problems in the long term.
We could however add the target class to the context when calling the custom transformer , since the field already exists, there is no BC break and it is safe.
Would that work for your use case ?
In the concrete described case, it will work, yes, because that is what i am doing right now (simplified code):
use AutoMapper\Transformer\PropertyTransformer\PropertyTransformerInterface;
use AutoMapper\Transformer\PropertyTransformer\PropertyTransformerSupportInterface;
interface ParameterizedPropertyTransformerInterface extends PropertyTransformerInterface, PropertyTransformerSupportInterface
{
public function getParameters(TypesMatching $types, SourcePropertyMetadata $source, TargetPropertyMetadata $target, MapperMetadata $mapperMetadata): array;
public function transform(mixed $value, object|array $source, array $context, array $parameters = []): mixed;
}
...
use AutoMapper\Transformer\PrioritizedTransformerFactoryInterface;
use AutoMapper\Transformer\TransformerFactoryInterface;
use App\AutoMapper\Transformer\PropertyTransformer\PropertyTransformer;
#[AsTaggedItem(name: 'automapper.transformer_factory', priority: 1004)]
final class PropertyTransformerFactory implements PrioritizedTransformerFactoryInterface, TransformerFactoryInterface
{
...
public function getTransformer(...) {
{
...
if ($propertyTransformer instanceof ParameterizedPropertyTransformerInterface) {
$parameters = $propertyTransformer->getParameters($types, $source, $target, $mapperMetadata);
}
return new PropertyTransformer($id, [], null, $parameters);
}
}
...
final readonly class PropertyTransformer implements TransformerInterface, AllowNullValueTransformerInterface
{
public function __construct(
private string $propertyTransformerId,
private array $extraContext = [],
Parser $parser = null,
private ?array $parameters = null) {}
...
public function transform(...): array
{
...
// The ORIGINAL one
// $transformExpr = new Expr\MethodCall(
// new Expr\MethodCall(new Expr\PropertyFetch(new Expr\Variable('this'), 'transformerRegistry'), 'getPropertyTransformer', [
// new Arg(new Scalar\String_($this->propertyTransformerId)),
// ]),
// 'transform',
// [
// new Arg($input),
// new Arg($source),
// new Arg($context),
// ]
// );
//
// MINE, simplified
$transformParams = [
new Arg($input),
new Arg($source),
new Arg($context)
];
if ($this->parameters !== null && sizeof($this->parameters) > 0) {
$arrayElements = [];
foreach ($this->parameters as $parameterKey => $parameterValue) {
$arrayElements[] = new ArrayItem(new Scalar\String_((string) $parameterValue), is_string($parameterKey) ? new Scalar\String_($parameterKey) : new Scalar\Int_($parameterKey));
}
$transformParams[] = new Array_($arrayElements);
}
$transformExpr = new Expr\MethodCall(new Expr\MethodCall(new Expr\PropertyFetch(new Expr\Variable('this'), 'transformerRegistry'), 'getPropertyTransformer', [
new Arg(new Scalar\String_($this->propertyTransformerId))
]), 'transform', $transformParams);
...
}
}
And, as a result, i can do this (getParameters() is called only once during the Mapper class generation):
class BlablablaTransformer implements ParameterizedPropertyTransformerInterface
{
...
public function supports(...): bool
{
...
}
public function getParameters(...): array
{
...
return [
'targetClass' => $targetClass
];
}
public function transform(mixed $value, object|array $source, array $context, $parameters = []): mixed
{
$targetClass = $parameters['targetClass'];
...
return $this->entityManager->getReference($targetClass, $value);
}
}
But i already using it more than only for that, my idea was to store the Reflection-based parameters, needed for the transformation, in the generated (and cached) Mapper classes to use the reflection only once and have no performance issues because of that.
(As you see here, i keep the interface of the supports() method and also $context untouched.)