Skip to content

[WIP] [OptionsResolver] handle nested options #18134

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

Closed

Conversation

HeahDude
Copy link
Contributor

Q A
Branch master
Bug fix? no
New feature? yes
BC breaks? no
Deprecations? no
Tests pass? yes
Fixed tickets #4833, #15524
License MIT
Doc PR todo
  • Gather feedback
  • Address a doc PR
  • Update CHANGELOG
  • Add a lot of tests :)

Summary

  • c2944ac fixes [OptionsResolver] Support nested options #4833. Handle nested options resolving

    • add OptionsResolver::nested a private array of nested OptionsResolver instances by root names,
    • add OptionsResolver::setNested($option, array $defaults) defines an option as a new self and returns the nested OptionsResolver instance,
    • add OptionsResolver::isNested($option) returns whether an option name is a root name for a nested OptionsResolver instance,
    • add OptionsResolver::getNested() an array of root names representing nested OptionsResolver instances.
    $connexionResolver = $resolver->setNested('connexion', array(
        'host' => 'localhost',
        'port' => 80,
    );
    
    $connexionResolver->setRequired('user');
    $connexionResolver->setDefault('password', function (Options $connexion) {
        return isset($nested['user']) ? '' : null;
    });
    $connexionResolver->setDefault('secure', false);
    $connexionResolver->setNormalizer('secure', function (Options $connexion, $secure) {
        return 443 === $nested['port'] ?: $secure;
    });
    $connexionResolver->setAllowedTypes('port', 'int');
    $connexionResolver->setAllowedTypes('user', 'string');
    
    $resolver->setRequired('connexion');
    $resolver->setNormalizer('connexion', function (Options $options, $resolvedConnexion) {
        if ('localhost' === $resolvedConnexion['host']) {
            $resolvedConnexion['host'] = $option['param'];
        }
    
        return $resolvedConnexion;
    });
    
    // In a sub class
    parent::configureOptions($resolver);
    
    $resolver->setDefault('user', $user);
    $connexionResolver = $resolver->setDefault(
        'connexion', function (Options $options, $previousDefaultConnexion) {
            if ($options['user'] instanceof UserInterface and 'localhost' !== $previousDefaultConnexion['host'])
                $connexion['user'] = $options['user']->getUsername();
            }
    );
    
    $options = $resolver->resolve($array);

    *Allowed* methods are not supported with root name of nested options since they always have to be an array, so:

  • f2ad947 trigger warning ? throw BadMethodCallException ? what do you think ?

  • 9e4e02a fixes [OptionsResolver] array of instance allowed type #15524. Handle extra options resolving and addAllowed*ForAll rules with constant flags

    • add Options::NONE constant,
    • add Options::ALL constant, matches defined and extra options,
    • add Options::DEFINED constant,
    • add Options::NESTED constant,
    • add Options::EXTRA constant, matches any undefined options, helps dealing with "anonymous" options (using a numerical index)
    • add OptionsResolver::allowedValuesForAll a private array to validate all options even undefined but not root of nested options as they must always be an array,
    • add OptionsResolver::allowedTypesForAll a private array to validate all options (as previous),
    • add OptionsResolver::resolveUndefined a private bool to allow extra options resolving,
    • add OptionsResolver::addAllowedValuesForAll($options, $allowedValues, $replace = false, $nested = Options::ALL), first argument may be an array of options names or any flag constant except Options::NONE, if a root name of nested options is passed as/among first argument the method will be recursive because the fourth argument default is Options::ALL, prevent it by passing Options::NONE instead,
    • add OptionsResolver::addAllowedTypesForAll($options, $allowedTypes, $replace, $nested), same as previous, note that passing Options::ALL as first argument will not apply to root of nested options as they must always be arrays and on contrary will apply on extra options, pass true as third argument to override previous allowed types,
    • add OptionsResolver::setPrototypes((string|string[]) $types, (mixed) $values, $options = Options::EXTRA), sets resolveUndefined to true and validate any extra options with the any given type with its corresponding allowed values,
    • add OptionsResolver::allowExtraOptions($allow = true)
    $resolver->setNested('attachments', array($attachments))
        ->allowExtraOptions()
        ->addAllowedTypesForAll(Options::ALL, '\Swift_Attachment');
    
    // Another way by implicitly allowing extra options:
    $resolver->setNested('emails')
        ->setPrototypes('string', function ($email) use ($regex) {
            return preg_match($regex, $email);
        });

    The difference is that in the second case implicitly the flag Options::EXTRA is passed so previous defaults will not be validate by prototypes rules, if that's not what you want pass Options::ALL as third argument.
    Note that setNested() returns the nested OptionsResolver instance in this examples, you could call these methods on the $resolver as well but this not the same:

    // Any undefined global options can only be strings
    $resolver
        ->allowExtraOptions()
        ->addAllowTypesForAll(Options::EXTRA, 'string', true, Options::NONE);
  • ccd5ce1 Harden dependency of nested options with an internal class

    • add NestedOptions class which extends OptionsResolver,
    • add NestedOptions::root the parent OptionsResolver instance,
    • add NestedOptions::rootName the root option name,
    • add NestedOptions::resolve() overrides to throw an exception is parent OptionsResolver instance is not locked,
    • add OptionsResolver::isLocked() internal public method to check from NestedOptions if it can be resolved.

Work in Progress (RFC)

  • add OptionsResolver::setExtraNormalizer(function (Options $options, $extraName, $extraValue) { ... }) to normalize any undefined options, basically a shortcut for the currently supported setNormalizer(Options::Extra, callable) but with handling of $extraName
  • add a OptionsResolver::prototypes private array holding allowed values by allowed types to validate extra options.
  • be more strict on NestedOptions array type and indexing when setting defaults. Currently the implementation does not distinguish nested defaults using a numerical index instead of names, which may cause problem when overriding them or to properly map validation/normalization.

@HeahDude HeahDude force-pushed the feature-options_resolver-nested_option branch from ad7cce8 to b486c62 Compare March 12, 2016 17:19
@HeahDude HeahDude force-pushed the feature-options_resolver-nested_option branch from 92e1f61 to 49588c6 Compare March 12, 2016 20:08
@@ -488,6 +600,12 @@ public function addAllowedValues($option, $allowedValues)
throw new AccessException('Allowed values cannot be added from a lazy option or normalizer.');
}

if ($this->isNested($option)) {
@trigger_error(sprintf('The "%s" method should not be used with nested options. A failed attempt occurred with the option "%" for values %s', __METHOD__, $option, $this->formatValues($allowedValues)), E_USER_WARNING);
Copy link

Choose a reason for hiding this comment

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

this doesn't seem to be a deprecation notice, so exceptions should be used, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That what I proposed in my comment in the concerned commit.

I'm not sure how strict we should be here, this is just a proposition.

@HeahDude HeahDude force-pushed the feature-options_resolver-nested_option branch 3 times, most recently from dd55fe5 to 211a4d9 Compare March 13, 2016 17:20
closes symfony#4833.

Allow to resolve nested options.

  * add `OptionsResolver::nested`
  * add `OptionsResolver::setNested()`
  * add `OptionsResolver::isNested()`
  * add `OptionsResolver::getNested()`
@HeahDude HeahDude force-pushed the feature-options_resolver-nested_option branch from 211a4d9 to e1aa1b6 Compare March 14, 2016 06:16
closes symfony#15524.

Set allowed types for many options or for a set of nested
or extra options.

  * add `Options::NONE`
  * add `Options::ALL`
  * add `Options::DEFINED`
  * add `Options::NESTED`
  * add `Options::EXTRA`
  * add `OptionsResolver::allowedValuesForAll`
  * add `OptionsResolver::allowedTypesForAll`
  * add `OptionsResolver::resolveUndefined`
  * add `OptionsResolver::addAllowedValuesForAll()`
  * add `OptionsResolver::addAllowedTypesForAll()`
  * add `OptionsResolver::setPrototype()`
@HeahDude HeahDude force-pushed the feature-options_resolver-nested_option branch from e1aa1b6 to ccd5ce1 Compare March 14, 2016 06:25
@HeahDude
Copy link
Contributor Author

Pushed b51cce4 to pass the parent options to each nested option normalizer.

@backbone87
Copy link
Contributor

Just an alternative approach for this feature, without caring about maintaining a tree:

  • Accept PropertyPaths as option names
  • Provide a wrapper for OptionsResolver that automatically converts option names to PropertyPaths
$resolver->setDefault(new PropertyPath('connexion[secure]'), false);
$wrapped = new PropertyPathOptionsResolverDecorator($resolver);
$wrapped->setDefault('connexion[secure]', false); // same as 2 lines above

@backbone87
Copy link
Contributor

Just some more thoughts: Your approach of maintaining a tree makes the OptionsResolver almost a clone of Configuration

@HeahDude
Copy link
Contributor Author

@backbone87 thanks for the feedback.

Accept PropertyPaths as option names
Provide a wrapper for OptionsResolver that automatically converts option names to PropertyPaths

It's not a bad idea. But for now I'd rather try to handle nested or extra options without adding too much overhead to the current behavior.

Just some more thoughts: Your approach of maintaining a tree makes the OptionsResolver almost a clone of Configuration

I don't agree with you on that, it just makes it more powerful IMHO, ref #3968.

I've worked on that PR but haven't push any more updates for now, I'm waiting for more reviews on the current state.

ping @stof @Tobion @webmozart

@backbone87
Copy link
Contributor

I don't agree with you on that, it just makes it more powerful IMHO, ref #3968.

Well that discussion was 4 years ago and the OptionsResolver grew quite a bit since then and the requested features push it more and more towards the feature set of the Config component

@HeahDude
Copy link
Contributor Author

But they still do not share goal or responsibility: one for loading resources and defining merging rules for domain configuration and the other for resolving class options even through inheritance.

@backbone87
Copy link
Contributor

"option resolution through inheritance" is a specific "defined merging rule"
the "configuration domain" of the Form component are FormTypes

The main benefit of the OptionResolver is probably much better performance, but thats something that can be worked out in the Config component.

The point of view is cleary personal preference, so like you already said: lets hear some other opinions on this topic.

@HeahDude
Copy link
Contributor Author

I would just say I see a nuance between "domain configuration" and "component or classes configuration" which may not require external resource loading but internal definition.

@webmozart
Copy link
Contributor

Thank you for working on this @HeahDude! I'm a bit uncomfortable about adding this functionality. First of all, nested options - so far - were not asked for by many people. Second, adding support for nested options will have a performance impact. However - just like PropertyAccess - OptionsResolver is a very low-level but frequently called component that impacts the performance of forms. We should avoid decreasing its performance for no benefit (for the majority of users).

@HeahDude
Copy link
Contributor Author

@webmozart thanks for the review. It was my self challenge for the hack day.

It looks more like a PoC/RFC as is, and would require a nice lifting and lot of test to be safely merged right now.

I actually thought about the light weight aspect, but the idea is also to help some core form types.

Since it remains a specific new method call, it should not impact performance that much while being flexible for specific use cases, attr is a perfect example.

Also it may be more safe to allow "locked" options as tried in #17161 since some options should never been overridden in some form type extensions or children in some core form types.

I guess we have the time to polish it for later and make a decision when it's properly implemented.

@HeahDude
Copy link
Contributor Author

HeahDude commented Apr 9, 2016

Status: Needs Work

@senkal
Copy link

senkal commented Aug 2, 2016

We have an use case where we need this functionality.
For now, we just created some class later which provides logic we need, instead of using OptionResolver directly.
Maybe an option would be to move it to higher level(the decision if we want to use the potentially slower option nested resolver), than to change main OptionResolver code.
Maybe new thing, something like collection of those resolver, something similar to ValidatorConstraint/All http://symfony.com/doc/current/reference/constraints/All.html?
Looks like, from what I have read, this is common request to handle just one more level.
Anyway, that wouldn't support nested arrays.
In the end, looks like, for majority not needed.
For very deeply nested arrays- writing your own solution is not that bad.

@fabpot
Copy link
Member

fabpot commented Jul 6, 2017

@HeahDude Any news on this one? Still wanting to work on it?

@HeahDude
Copy link
Contributor Author

HeahDude commented Jul 6, 2017

Yes I think it's needed to support this case IMHO even in the core. I will start it again from scratch to get something simpler and be careful to not add too much overhead in the meantime if someone wants to give it a try, please do.

Closing for now.

@HeahDude HeahDude closed this Jul 6, 2017
fabpot added a commit that referenced this pull request Oct 10, 2018
…finition (yceruto)

This PR was merged into the 4.2-dev branch.

Discussion
----------

[OptionsResolver] Added support for nesting options definition

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | #4833
| License       | MIT
| Doc PR        | symfony/symfony-docs#9995

I'd like to propose an alternative to #27251 and #18134 with a different approach.

It would allow you to create a nested options system with required options, validation (type, value),
normalization and more.

<details>
 <summary><strong>Short documentation</strong></summary>

**To define a nested option, you can pass a closure as the default value of the option with an `OptionsResolver` argument:**
```php
$resolver
    ->defaults([
        'connection' => 'default',
        'database' => function (OptionsResolver $resolver) {
            $resolver
                ->setRequired(['dbname', 'host', ...])
                ->setDefaults([
                    'driver' => 'pdo_sqlite',
                    'port' => function (Options $options) {
                        return 'pdo_mysql' === $options['driver'] ? 3306 : null,
                    },
                    'logging' => true,
                ])
                ->setAllowedValues('driver', ['pdo_sqlite', 'pdo_mysql'])
                ->setAllowedTypes('port', 'int')
                ->setAllowedTypes('logging', 'bool')
                // ...
        },
    ]);

$resolver->resolve(array(
    'database' => array(
        'dbname' => 'demo',
        'host' => 'localhost',
        'driver' => 'pdo_mysql',
    ),
));

// returns: array(
//    'connection' => 'default',
//    'database' => array(
//        'dbname' => 'demo',
//        'host' => 'localhost',
//        'driver' => 'pdo_mysql',
//        'port' => 3306,
//        'logging' => true,
//    ),
//)
```
Based on this instance, you can define the options under ``database`` and its desired default
value.

**If the default value of a child option depend on another option defined in parent level,
adds a second ``Options`` argument to the closure:**
```php
$resolver
    ->defaults([
        'profiling' => false,
        'database' => function (OptionsResolver $resolver, Options $parent) {
            $resolver
                ->setDefault('logging', $parent['profiling'])
                ->setAllowedTypes('logging', 'bool');
        },
    ])
;
```
**Access to nested options from lazy or normalize functions in parent level:**
```php
$resolver
    ->defaults([
        'version' => function (Options $options) {
            return $options['database']['server_version'];
        },
        'database' => function (OptionsResolver $resolver) {
            $resolver
                ->setDefault('server_version', 3.15)
                ->setAllowedTypes('server_version', 'numeric')
                // ...
        },
    ])
;
```
As condition, for nested options you must to pass an array of values to resolve it on runtime, otherwise an exception will be thrown:
```php
$resolver->resolve(); // OK
$resolver->resolve(['database' => []]); // OK
$resolver->resolve(['database' => null); // KO (Exception!)
```
</details>

---

Demo app https://github.com/yceruto/nested-optionsresolver-demo

Commits
-------

d04e40b Added support for nested options definition
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.

[OptionsResolver] array of instance allowed type [OptionsResolver] Support nested options
9 participants