Skip to content

[RFC] object-mapper component #46737

Closed
Closed
@lemorragia

Description

@lemorragia

Description

TL;DR

What about an "object-mapper" component?
The scope of the component should be "mapping objects onto other objects" in a "convention but configurable way".
The component should help instantiating objects/filling properties using the first argument object as basis.

The simplest possible examples:

$mapper = new ObjectMapper($config);

$object|s = $mapper->map($object1|$collection, Object::class);
$mapper->map($object1, $object2);

Background

Often applications contain a number of "often-similar-but-sometimes-different" objects used for many different cases (value objects, dtos, messages, etc..). To handle data mapping between two objects, different methods are used (constructor arguments, public properties, getter/setter, static functions are the main ones), which in turn requires a lot of boilerplate code to set/get all the properties. So this could already be useful simplifying some already existent code, based on the assumption that often properties have the same name, or follow some kind of convention which can be guessed by the mapper.

The mapper itself should be configurable in cases such as:

  • handling properties that does not fully match with each other

  • excluding some properties from mapping

  • having different mapping policies (in cases where the two objects that does not completely overlap with each other, maybe an exception should be thrown in a "strict mode", where a "best effort mode" would ignore the problem

  • maybe some properties should undergo some kind of transformation during the mapping

  • selecting between different property-visibility modes (public-private properties, methods, etc..)

In general a configuration should be needed to manage "policies, differences and non standard mapping" between objects (similar to explicitly binding service arguments), but as long as a convention is followed, an automatic mapping between two different objects could be achieved.

The component itself could leverage on symfony/property-info and symfony/property-access components in different ways to handle different use cases(eg. if the destination object has mandatory typed constructor arguments, it could try to instantiate the object by binding the arguments with specific properties of the source object, and even using constructor types to gather additional informations about the mapping itself).

My take on this is that this component could be useful by itself automating much trivial code, and could be used over time inside symfony as a foundation to handle simple cases of object mapping.

Symfony and Forms

Having "always valid" entities can be done using mandatory constructor arguments in the entity itself for not-nullable properties. This doesn't work well with symfony form, and many issues were opened about this in the past (eg. #36022). As said in the issue: the object binded to the form should be able to store invalid states. With strongly typed properties in the entity(and mandatory typed arguments in the constructor) this has become increasingly difficult over time.

The solution should be having a DTO between the entity itself and the form component, as been described in numerous articles/tutorials, and https://github.com/qossmic/rich-model-forms-bundle is born to address this issue...it does this extending symfony forms and catching type errors instantiating the new entity, which i think is not an ideal approach.

Having a form-dto has its downsides though, often effectively duplicating much of the code of the entity inside the dto (plus adding the methods to handle the conversion between entity and dto), so it quickly can become cumbersome. Nonetheless the form-dto approach is something many people are using/considering: not counting the issues about this topic there's even a PR in maker bundle to help in DTO generation (symfony/maker-bundle#162). This component could help people using this approach reducing the required codebase and at the same time introducing a non-binding convention(aka, "a soft-standardization of dto generation") to automatically pass data between dto and entities (at the absolute minimum, a form-dto could contain only public properties(without types) and the relative validation (obviously the same could be achieved with getter/setters). No additional function to handle data mapping (no functions like fill/extract, or populate/commit, no abstract classes or traits to achieve automatic behaviour in the dto, and no mandatory constructor arguments are needed).

Going Forward

With this component, if wanted, many things could be built in different areas, with a tighter integration with symfony (and possibly a global object-mapper at the framework level):

  • on the topic of "form, dto and rich model", automatic behaviours could be achieved over time, and maybe integrated in the form component also as an additional feature. So in a non-bc breaking way, the form system could be used as is and taking the "rich-model approach" could be easily done massively reducing the code in the DTOs. With some symfony global service/specific-mapping-attributes-in-the-dto even an automatic configuration could be possible.

  • make:crud command could have an additional option to include form-dtos(and using the code generated in the maker-bundle issue avove), and all the relative controller-dto-form code/templates could be automatically generated, if one prefers this "rich-model/form-dto" approach

  • at the framework level, a "global mapping configuration" could be put in place(or specific attributes with auto-configuration), and the proper service could be used globally inside a user project to handle different mapping cases between different objects and maybe inside the framework itself if useful

  • in some cases of messages, maybe the message could be automatically populated (easier still with a framework-level mapping configuration)

  • in cases such as api-platform dto support (https://api-platform.com/docs/core/dto/) it could simplify the data-transformer generation (or even replace it all together in the simplest cases) (one could argue that a dto purpose is to be used to have wildly different data from the entity in this case, and an automatic mapping couldn't be useful...but i think that this is not always true)

Existing Examples

Java and .NET already have libraries with framework integrations (http://modelmapper.org/ , https://github.com/AutoMapper/AutoMapper) and php libraries exists (https://github.com/mark-gerarts/automapper-plus and at the end of the readme of the package itself there's a list of other packages achieving the same goal in comparison).

On the topic of "why a symfony component and not using existing libraries", i think that the scale should tip in favor of a symfony component IF its something that it could be built upon over-time: extracting existing behaviour/code in a separate-specific component, providing additional features in the framework, addressing the form-entity binding issues in a non-bc breaking way (but at the same time simplifying the code needed to implement many dtos / introducing some automatic behaviour), providing the mapper with conventions/configurations for other symfony-ecosystem-packages (easyadmin, api-platform, doctrine), etc...

Example

an example of an "crud actions", purposefully shown "as simple as possible" (a dto with getter/setters will work with the same code), and integrating with the current symfony make-crud 'controller-form-entity' standard flow with little change:

App\Entity\Subject.php

[..]

public function __construct(OtherEntity $relatedEntity, string $name, float $position) 
{
  $this->setRelatedEntity($relatedEntity);
  $this->setName($name);
  $this->setPosition($position);
}

[..]

App\Form\Data\SubjectDTO

use Symfony\Component\Validator\Constraints as Assert;

class SubjectDTO
{
    /** @var OtherEntity */
    #[Assert\NotNull]
    public $relatedEntity;

    /** @var string */
    #[Assert\NotBlank, Assert\Type(type:'string'), Assert\Length(max: 50)]
    public $name;
    
    /** @var float */
    #[Assert\NotNull, Assert\Type(type:'float'), Assert\GreaterThanOrEqual(value: 0)]
    public $position;
}

App\Form\SubjectType

class SubjectType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('relatedEntity', EntityType::class, [..])
            ->add('name', TextType::class, [..])
            ->add('position', NumberType::class, [..]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => SubjectDTO::class,
        ]);
    }
}

services.yaml

App\Form\Data\:
  resource: '../src/Form/Data/*'
  shared: false

App\Controller\SubjectController

#[Route('/{id}/new', name: 'subject_new', methods: ['GET', 'POST'])]
public function new(Request $request, SubjectDTO $subjectDTO, EntityManagerInterface $entityManager, ObjectMapperInterface $objectMapper): Response
{
    $form = $this->createForm(SubjectType::class, $subjectDTO);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $subject = $objectMapper->($subjectDTO, Subject::class);
        $entityManager->persist(subject);
        $entityManager->flush();

        return $this->redirectToRoute('subject_show', [
            'id' => $subject->getId(),
        ]);
    }

    return $this->render('subject/new.html.twig', [
        'subject' => $subjectDTO,
        'form' => $form->createView(),
    ]);
}

#[Route('/{id}/edit', name: 'subject_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, Subject $subject, SubjectDTO $subjectDTO, EntityManagerInterface $entityManager, ObjectMapperInterface $objectMapper): Response
{
    $objectMapper->map($subject, $subjectDTO);

    $form = $this->createForm(SubjectType::class, $subjectDTO);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $objectMapper->map($subjectDTO, $subject);
        $entityManager->flush();

        return $this->redirectToRoute('subject_show', [
            'id' => $subject->getId(),
        ]);
    }

    return $this->render('subject/edit.html.twig', [
        'subject' => $subjectDTO,
        'form' => $form->createView(),
    ]);
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    RFCRFC = Request For Comments (proposals about features that you want to be discussed)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

      pFad - Phonifier reborn

      Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

      Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


      Alternative Proxies:

      Alternative Proxy

      pFad Proxy

      pFad v3 Proxy

      pFad v4 Proxy