Skip to content

[Form] Add new way of mapping data using callback functions #37968

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 16, 2020

Conversation

yceruto
Copy link
Member

@yceruto yceruto commented Aug 27, 2020

Q A
Branch? master
Bug fix? no
New feature? yes
Deprecations? yes
Tickets Fix #37597 (partially)
License MIT
Doc PR symfony/symfony-docs#14241

Replaces #37614

What this solves

Objects and Forms have different mechanisms for structuring data. When you build an object model with a lot of business logic it's valuable to use these mechanisms to better collect the data and the behavior that goes with it. Doing so leads to variant schemas; that is, the object model schema and the form schema don't match up.

You still need to transfer data between the two schemas, and this data transfer becomes a complexity in its own right. If the objects know about the form structure, changes in one tend to ripple to the other.

Currently, the Data Mapper layer separates the objects from the form, transfering data between the two and also isolating them from each other. That's fine, but at present the default data mapper has a limitation: it's very tied to one property path (see PropertyPathMapper).

That said, you'll have to write your own data mapper in the following situations:

  • When the property path differs for reading and writing
  • When several form fields are mapped to a single method
  • When you need to read data based on the model's state
  • When the mapping of the model depends on the submitted form data
  • ...

Also, when we create a new data mapper, we usually forget about checking the status of the given data and forms. Whether the data is empty or not; throw an exception if the given data is not an object/array and whether the form field is submitted/synchronized/disabled or not. Not doing that could lead to unwanted behavior.

What this proposes

Create a new way to write and read values to/from an object/array using callback functions. This feature would be tied to each form field and would also mean a new way of mapping data, but a very convenient one, in which it won't be necessary to define a new data mapper and take into account all what it would imply when you only need to map one field in a different manner or perhaps in only one direction (writing or reading the value).

This PR adds two new options for each form type: getter and setter, allowed to be null or callable:

$builder->add('name', TextType::class, [
    'getter' => function (Person $person, FormInterface $form): string {
        return $person->firstName().' '.$person->lastName();
    },
    'setter' => function (Person &$person, ?string $name, FormInterface $form): void {
        $person->rename($name);
    },
]);

This would give us the same possibilities as data mappers, but within the form field scope, where:

  • $person is the view data, basically the underlying data to the form.
  • $form is the current child form that is being mapped.
  • $name is the submitted data that belongs to that field.

These two callbacks will be executed following the same rules as for property paths before read and write any value (e.i. early return if empty data, skip mapping if the form field is not mapped or it's disabled, etc).

What this also proposes

I based the implementation on solving this problem first:

#37614 (comment)
[...] the property_path option defines the rules on how it's accessed. From there, the actual way it is accessed (direct property access, accessors, reflection, whatever) are hidden from view. All that matters is the property path (which is deduced from the name if not explicitly set). [...]

Thus, splitting the default data mapper PropertyPathMapper into two artifacts: "DataMapper" and "DataAccessor" would allow us to add multiple data accessors without having to constantly reinvent the wheel (the data mapper code). This way, we can focus on varying the code that handles data access.

You can also think about a new ReflectionAccessor for instance? or use this CallbackAccessor to map your form partially from an external API? yes, you could do it :)

Here is a view of the proposed changes:

data_accessor

DataMapper will take care of common checks, iterating through the given child forms, managing the form data, and all the tasks necessary for mapping a standard form. Meanwhile, DataAccessor will handle how to read and write values to/from the underlying object or array.

BC

The PropertyPathMapper is being deprecated in favor of DataMapper class, which uses the PropertyPathAccessor by default.

Although DataMapper is now the default for each compound form, the behavior must remain the same (tests prove it). Therefore, if the getter or setter option is null (they are by default), CallbackAccessor will fall back to PropertyPathAccessor for reading or writing values.


Sorry for the long description, but I think it is sometimes necessary for complex issues and big changes. Additionally, now you have a better understanding of what these changes are about.

/cc @xabbuh as creator of rich-model-forms-bundle and @alcaeus as you had worked on this issue.

WDYT?

@yceruto yceruto added this to the next milestone Aug 27, 2020
@yceruto yceruto requested a review from xabbuh as a code owner August 27, 2020 16:39
@yceruto yceruto changed the title [Form] Add new way to write and/or read values to/from an object or array using callback functions [Form] Add new way to write/read values to/from an object or array using callback functions Aug 27, 2020
@yceruto yceruto force-pushed the data_accessor branch 2 times, most recently from 7102a5a to 7b6c4dd Compare August 27, 2020 17:54
@yceruto yceruto changed the title [Form] Add new way to write/read values to/from an object or array using callback functions [Form] Add new way of mapping data using callback functions Aug 27, 2020
@yceruto yceruto force-pushed the data_accessor branch 5 times, most recently from 43e2ff7 to 8b14495 Compare August 27, 2020 23:18
@maxhelias
Copy link
Contributor

I am not very familiar with the Form component but your explanations are very clear and the concept looks very interesting 👏

@yceruto yceruto force-pushed the data_accessor branch 4 times, most recently from cb9cc30 to 14bc40b Compare August 30, 2020 16:18
Copy link
Contributor

@alcaeus alcaeus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like how you solved the problem. Splitting data mapping and data accessors makes this more maintainable.

I've left a couple of comments regarding fallback accessors. I'm not sure if we should be nesting data accessors as you've suggested in this PR; I think using some form of chain accessors that iterates over a list of accessors may be better, but I defer to the judgment of the core devs.

@yceruto yceruto force-pushed the data_accessor branch 2 times, most recently from 97cd327 to 97edd18 Compare September 2, 2020 17:14
@yceruto yceruto force-pushed the data_accessor branch 2 times, most recently from 7d2c504 to 8272c27 Compare September 3, 2020 12:34
Copy link
Contributor

@alcaeus alcaeus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Copy link
Member

@fabpot fabpot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great to me! I'd love to get some feedback from @xabbuh and @weaverryan if possible.

*
* @throws Exception\AccessException If unable to write the given value
*/
public function setValue(&$objectOrArray, $value, FormInterface $form): void;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't the method return the modified value instead of using a ref?

Copy link
Member Author

@yceruto yceruto Sep 15, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it can. But it is not mandatory to return a new value here and I would like to keep this signature quite similar to the current DataMapperInterface::mapFormsToData(iterable $forms, &$viewData) method for consistency.

Copy link
Member

@xabbuh xabbuh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work! 🎉 Apart from a minor comment this looks ready to me.

@fabpot
Copy link
Member

fabpot commented Sep 16, 2020

Thank you @yceruto.

@fabpot fabpot merged commit c34865f into symfony:master Sep 16, 2020
@yceruto yceruto deleted the data_accessor branch September 16, 2020 12:34
wouterj added a commit to symfony/symfony-docs that referenced this pull request Oct 3, 2020
… functions (yceruto)

This PR was merged into the master branch.

Discussion
----------

[Form] Add new way of mapping form data using callback functions

Documenting new feature symfony/symfony#37968

I don't think we need to cover all possible situations; the current example covers 3 of them, except the second one, but it seems enough for me, wdyt?

> [...] you'll have to write your own data mapper in the following situations:
>* When the property path differs for reading and writing
>* When several form fields are mapped to a single method
>* When you need to read data based on the model's state
>* When the mapping of the model depends on the submitted form data
...

By the way, the second situation "when several form fields are mapped to a single method" can be achieved this way:
```php
$builder
    ->add('foo', TextType::class, [
        'setter' => function (Foobar $foobar, string $value) use (&$foo) {
            $foo = $value;
        },
    ])
    ->add('bar', TextType::class, [
        'setter' => function (Foobar $foobar, string $bar) use (&$foo) {
            $foobar->doSomething($foo, $bar);
        },
    ])
;
```
Since the data mapper will follow the same order in which the fields were defined, we can trust that the foo "setter" will be executed first and so the second "setter" will have the right value at the moment this function `$foobar->doSomething($foo, $bar)` is being executed.

---

ping @alcaeus :)

Commits
-------

53fb3c7 Add new way of mapping form data
@nicolas-grekas nicolas-grekas modified the milestones: next, 5.2 Oct 5, 2020
@fabpot fabpot mentioned this pull request Oct 5, 2020
@zmitic
Copy link

zmitic commented Oct 7, 2020

Question about setter:

($setter)($data, $form->getData(), $form);

If the settter is

'setter' => fn(Person &$person, ?string $name) => $person->rename($name),

but rename(string $newName) doesn't accept null, this would throw TypeError. Is this correct or I simply can't find how it is solved?

@curry684
Copy link
Contributor

curry684 commented Nov 9, 2022

@yceruto thanks for the very useful feature, but you might want to take a look at #48167

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[RFC] [Form] Improve handling of domain objects in forms
9 participants