8000 [Config] Allow using an enum FQCN with `EnumNode` by alexandre-daubois · Pull Request #57686 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[Config] Allow using an enum FQCN with EnumNode #57686

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
Feb 26, 2025
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
1 change: 1 addition & 0 deletions src/Symfony/Component/Config/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ CHANGELOG

* Add `ExprBuilder::ifFalse()`
* Add support for info on `ArrayNodeDefinition::canBeEnabled()` and `ArrayNodeDefinition::canBeDisabled()`
* Allow using an enum FQCN with `EnumNode`

7.2
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
class EnumNodeDefinition extends ScalarNodeDefinition
{
private array $values;
private string $enumFqcn;

/**
* @return $this
Expand All @@ -36,17 +37,37 @@ public function values(array $values): static
return $this;
}

/**
* @param class-string<\UnitEnum> $enumFqcn
*
* @return $this
*/
public function enumFqcn(string $enumFqcn): static
{
if (!enum_exists($enumFqcn)) {
throw new \InvalidArgumentException(\sprintf('The enum class "%s" does not exist.', $enumFqcn));
}

$this->enumFqcn = $enumFqcn;

return $this;
}

/**
* Instantiate a Node.
*
* @throws \RuntimeException
*/
protected function instantiateNode(): EnumNode
{
if (!isset($this->values)) {
throw new \RuntimeException('You must call ->values() on enum nodes.');
if (!isset($this->values) && !isset($this->enumFqcn)) {
throw new \RuntimeException('You must call either ->values() or ->enumFqcn() on enum nodes.');
}

if (isset($this->values) && isset($this->enumFqcn)) {
throw new \RuntimeException('You must call either ->values() or ->enumFqcn() on enum nodes but not both.');
}

return new EnumNode($this->name, $this->parent, $this->values, $this->pathSeparator);
return new EnumNode($this->name, $this->parent, $this->values ?? [], $this->pathSeparator, $this->enumFqcn ?? null);
}
}
82 changes: 75 additions & 7 deletions src/Symfony/Component/Config/Definition/EnumNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,30 @@
class EnumNode extends ScalarNode
{
private array $values;
private ?string $enumFqcn = null;

public function __construct(?string $name, ?NodeInterface $parent = null, array $values = [], string $pathSeparator = BaseNode::DEFAULT_PATH_SEPARATOR)
/**
* @param class-string<\UnitEnum>|null $enumFqcn
*/
public function __construct(?string $name, ?NodeInterface $parent = null, array $values = [], string $pathSeparator = BaseNode::DEFAULT_PATH_SEPARATOR, ?string $enumFqcn = null)
{
if (!$values) {
if (!$values && !$enumFqcn) {
throw new \InvalidArgumentException('$values must contain at least one element.');
}

if ($values && $enumFqcn) {
throw new \InvalidArgumentException('$values or $enumFqcn cannot be both set.');
}

if (null !== $enumFqcn) {
if (!enum_exists($enumFqcn)) {
throw new \InvalidArgumentException(\sprintf('The "%s" enum does not exist.', $enumFqcn));
}

$values = $enumFqcn::cases();
$this->enumFqcn = $enumFqcn;
}

foreach ($values as $value) {
if (null === $value || \is_scalar($value)) {
continue;
Expand All @@ -51,11 +68,20 @@ public function getValues(): array
return $this->values;
}

public function getEnumFqcn(): ?string
{
return $this->enumFqcn;
}

/**
* @internal
*/
public function getPermissibleValues(string $separator): string
{
if (is_subclass_of($this->enumFqcn, \BackedEnum::class)) {
return implode($separator, array_column($this->enumFqcn::cases(), 'value'));
}

return implode($separator, array_unique(array_map(static function (mixed $value): string {
if (!$value instanceof \UnitEnum) {
return json_encode($value);
Expand All @@ -78,13 +104,55 @@ protected function finalizeValue(mixed $value): mixed
{
$value = parent::finalizeValue($value);

if (!\in_array($value, $this->values, true)) {
$ex = new InvalidConfigurationException(\sprintf('The value %s is not allowed for path "%s". Permissible values: %s', json_encode($value), $this->getPath(), $this->getPermissibleValues(', ')));
$ex->setPath($this->getPath());
if (!$this->enumFqcn) {
if (!\in_array($value, $this->values, true)) {
throw $this->createInvalidValueException($value);
}

throw $ex;
return $value;
}

return $value;
if ($value instanceof $this->enumFqcn) {
return $value;
}

if (!is_subclass_of($this->enumFqcn, \BackedEnum::class)) {
// value is not an instance of the enum, and the enum is not
// backed, meaning no cast is possible
throw $this->createInvalidValueException($value);
}

if ($value instanceof \UnitEnum && !$value instanceof $this->enumFqcn) {
throw new InvalidConfigurationException(\sprintf('The value should be part of the "%s" enum, got a value from the "%s" enum.', $this->enumFqcn, get_debug_type($value)));
}

if (!\is_string($value) && !\is_int($value)) {
throw new InvalidConfigurationException(\sprintf('Only strings and integers can be cast to a case of the "%s" enum, got value of type "%s".', $this->enumFqcn, get_debug_type($value)));
}

try {
return $this->enumFqcn::from($value);
} catch (\TypeError|\ValueError) {
throw $this->createInvalidValueException($value);
}
}

private function createInvalidValueException(mixed $value): InvalidConfigurationException
{
$displayValue = match (true) {
\is_int($value) => $value,
\is_string($value) => \sprintf('"%s"', $value),
default => \sprintf('of type "%s"', get_debug_type($value)),
};

$message = \sprintf('The value %s is not allowed for path "%s". Permissible values: %s.', $displayValue, $this->getPath(), $this->getPermissibleValues(', '));
if ($this->enumFqcn) {
$message = substr_replace($message, \sprintf(' (cases of the "%s" enum)', $this->enumFqcn), -1, 0);
}

$e = new InvalidConfigurationException($message);
$e->setPath($this->getPath());

return $e;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
return static function (PrimitiveTypesConfig $config) {
$config->booleanNode(true);
$config->enumNode('foo');
$config->fqcnEnumNode('bar');
$config->fqcnUnitEnumNode(\Symfony\Component\Config\Tests\Fixtures\TestEnum::Bar);
$config->floatNode(47.11);
$config->integerNode(1337);
$config->scalarNode('foobar');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
return [
'boolean_node' => true,
'enum_node' => 'foo',
'fqcn_enum_node' => 'bar',
'fqcn_unit_enum_node' => \Symfony\Component\Config\Tests\Fixtures\TestEnum::Bar,
'float_node' => 47.11,
'integer_node' => 1337,
'scalar_node' => 'foobar',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Config\Tests\Fixtures\StringBackedTestEnum;
use Symfony\Component\Config\Tests\Fixtures\TestEnum;

class PrimitiveTypes implements ConfigurationInterface
Expand All @@ -25,6 +26,8 @@ public function getConfigTreeBuilder(): TreeBuilder
->children()
->booleanNode('boolean_node')->end()
->enumNode('enum_node')->values(['foo', 'bar', 'baz', TestEnum::Bar])->end()
->enumNode('fqcn_enum_node')->enumFqcn(StringBackedTestEnum::class)->end()
->enumNode('fqcn_unit_enum_node')->enumFqcn(TestEnum::class)->end()
->floatNode('float_node')->end()
->integerNode('integer_node')->end()
->scalarNode('scalar_node')->end()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class PrimitiveTypesConfig implements \Symfony\Component\Config\Builder\ConfigBu
{
private $booleanNode;
private $enumNode;
private $fqcnEnumNode;
private $fqcnUnitEnumNode;
private $floatNode;
private $integerNode;
private $scalarNode;
Expand Down Expand Up @@ -44,6 +46,32 @@ public function enumNode($value): static
return $this;
}

/**
* @default null
* @param ParamConfigurator|\Symfony\Component\Config\Tests\Fixtures\StringBackedTestEnum::Foo|\Symfony\Component\Config\Tests\Fixtures\StringBackedTestEnum::Bar $value
* @return $this
*/
public function fqcnEnumNode($value): static
{
$this->_usedProperties['fqcnEnumNode'] = true;
$this->fqcnEnumNode = $value;

return $this;
}

/**
* @default null
* @param ParamConfigurator|\Symfony\Component\Config\Tests\Fixtures\TestEnum::Foo|\Symfony\Component\Config\Tests\Fixtures\TestEnum::Bar|\Symfony\Component\Config\Tests\Fixtures\TestEnum::Ccc $value
* @return $this
*/
public function fqcnUnitEnumNode($value): static
{
$this->_usedProperties['fqcnUnitEnumNode'] = true;
$this->fqcnUnitEnumNode = $value;

return $this;
}

/**
* @default null
* @param ParamConfigurator|float $value
Expand Down Expand Up @@ -115,6 +143,18 @@ public function __construct(array $value = [])
unset($value['enum_node']);
}

if (array_key_exists('fqcn_enum_node', $value)) {
$this->_usedProperties['fqcnEnumNode'] = true;
$this->fqcnEnumNode = $value['fqcn_enum_node'];
unset($value['fqcn_enum_node']);
}

if (array_key_exists('fqcn_unit_enum_node', $value)) {
$this->_usedProperties['fqcnUnitEnumNode'] = true;
$this->fqcnUnitEnumNode = $value['fqcn_unit_enum_node'];
unset($value['fqcn_unit_enum_node']);
}

if (array_key_exists('float_node', $value)) {
$this->_usedProperties['floatNode'] = true;
$this->floatNode = $value['float_node'];
Expand Down Expand Up @@ -153,6 +193,12 @@ public function toArray(): array
if (isset($this->_usedProperties['enumNode'])) {
$output['enum_node'] = $this->enumNode;
}
if (isset($this->_usedProperties['fqcnEnumNode'])) {
$output['fqcn_enum_node'] = $this->fqcnEnumNode;
}
if (isset($this->_usedProperties['fqcnUnitEnumNode'])) {
$output['fqcn_unit_enum_node'] = $this->fqcnUnitEnumNode;
}
if (isset($this->_usedProperties['floatNode'])) {
$output['float_node'] = $this->floatNode;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

use PHPUnit\Framework\TestCase;
use Symfony\Component\Config\Definition\Builder\EnumNodeDefinition;
use Symfony\Component\Config\Tests\Fixtures\StringBackedTestEnum;
use Symfony\Component\Config\Tests\Fixtures\TestEnum;

class EnumNodeDefinitionTest extends TestCase
{
Expand All @@ -25,14 +27,34 @@ public function testWithOneValue()
$this->assertEquals(['foo'], $node->getValues());
}

public function testWithUnitEnumFqcn()
{
$def = new EnumNodeDefinition('foo');
$def->enumFqcn(TestEnum::class);

$node = $def->getNode();
$this->assertEquals(TestEnum::class, $node->getEnumFqcn());
}

public function testNoValuesPassed()
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('You must call ->values() on enum nodes.');
$this->expectExceptionMessage('You must call either ->values() or ->enumFqcn() on enum nodes.');
$def = new EnumNodeDefinition('foo');
$def->getNode();
}

public function testBothValuesAndEnumFqcnPassed()
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('You must call either ->values() or ->enumFqcn() on enum nodes but not both.');
$def = new EnumNodeDefinition('foo');
$def->values([123])
->enumFqcn(StringBackedTestEnum::class);

$def->getNode();
}

public function testWithNoValues()
{
$this->expectException(\InvalidArgumentException::class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ private function getConfigurationAsString()
<!-- scalar-deprecated-with-message: Deprecated (Since vendor/package 1.1: Deprecation custom message for "scalar_deprecated_with_message" at "acme_root") -->
<!-- enum-with-default: One of "this"; "that" -->
<!-- enum: One of "this"; "that"; Symfony\Component\Config\Tests\Fixtures\TestEnum::Ccc -->
<!-- enum-with-class: One of foo; bar -->
<!-- unit-enum-with-class: One of Symfony\Component\Config\Tests\Fixtures\TestEnum::Foo; Symfony\Component\Config\Tests\Fixtures\TestEnum::Bar; Symfony\Component\Config\Tests\Fixtures\TestEnum::Ccc -->
<!-- variable: Example: foo, bar -->
<config
boolean="true"
Expand All @@ -58,6 +60,8 @@ private function getConfigurationAsString()
node-with-a-looong-name=""
enum-with-default="this"
enum=""
enum-with-class=""
unit-enum-with-class=""
variable=""
custom-node="true"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ private function getConfigurationAsString(): string
node_with_a_looong_name: ~
enum_with_default: this # One of "this"; "that"
enum: ~ # One of "this"; "that"; Symfony\Component\Config\Tests\Fixtures\TestEnum::Ccc
enum_with_class: ~ # One of foo; bar
unit_enum_with_class: ~ # One of Symfony\Component\Config\Tests\Fixtures\TestEnum::Foo; Symfony\Component\Config\Tests\Fixtures\TestEnum::Bar; Symfony\Component\Config\Tests\Fixtures\TestEnum::Ccc

# some info
array:
Expand Down
Loading
Loading
0