Skip to content
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

Support reading enums from PHP 8.1 #3681

Open
bertoost opened this issue Apr 9, 2022 · 27 comments · May be fixed by #3872
Open

Support reading enums from PHP 8.1 #3681

bertoost opened this issue Apr 9, 2022 · 27 comments · May be fixed by #3872

Comments

@bertoost
Copy link

bertoost commented Apr 9, 2022

Hi,

Since PHP 8.1 we can use Enums. Which in my opinion are a great asset to PHP.
But unfortunate there is not an elegant way of retrieving values inside Twig templates.

I currently use this in my Symfony project;

{{ constant('App\\...\\Enum::Key').value }}

Which will return the value of the key in de the Enum.

Maybe it's good to add some specific functions for this in the Twig core?

Regards, Bert

@stof
Copy link
Member

stof commented Apr 11, 2022

Well, for the cases where you already have the enum instance in a variable (coming from an object getter for instance), you would have to use my_enum.value anyway in Twig. I'm not sure a special enum_value(...) function replacing constant(...).value is worth it in the core.

@ThomasLandauer
Copy link
Contributor

@bertoost Is a function like enum_value('App\\...\\Enum::Key') what you had in mind?

@bertoost
Copy link
Author

Definitely true @stof ... but when you don't have a getter to assign it to the template, then constant(..).value is kinda weird since enums doesn't feel like constants...

@ThomasLandauer could be, or just enum() since the value of the key is probably the only thing you want to use/compare against. Therefore there should be an option to retrieve the enum itself too. For example when you want to build a dropdown of checklist/radio-list with the enum values... eq. for item in enum(..)

@ThomasLandauer
Copy link
Contributor

OK, that's 2 different things:

  • Single enum value: So what you're asking for is in fact an alias enum() for the existing constant()?
  • Entire enum iterable: An enum has the built-in cases() method: https://www.php.net/manual/language.enumerations.listing.php But AFAIK there's no Twig function to get the entire enum; constant('App\\...\\Enum') is not working. So maybe an enum() function for that?

@stof
Copy link
Member

stof commented Apr 20, 2022

@ThomasLandauer there is nothing like getter the entire enum. Calling MyEnum::cases() gives you a list of MyEnum instances. But constant('App\\...\\MyEnum') means something totally different (in PHP as well).

@janklan
Copy link

janklan commented May 5, 2022

Wouldn't it suffice to add an enum(...) method working comparable to constant(...), but would (a) validate the class in question is in fact an enum, and (b) could accept a second argument instructing it to either return the enum itself (default - not all enums have to be backed, and you don't always need to know what the backed value is), or a value, or its cases?

Even if it ended up being just a decorator of what powers constant(...), with the additional type check, I'd say it's a good start?

@luxemate
Copy link

luxemate commented May 13, 2022

At first I tried to add a twig function for native enums like this:

public function enum(string $className): object
{
    if (!is_subclass_of($className, Enum::class)) {
        throw new \InvalidArgumentException(sprintf('"%s" is not an enum.', $className));
    }

    return new class ($className) {
        public function __construct(private string $className)
        {
        }

        public function __call(string $caseName, array $arguments): mixed
        {
            Assert::count($arguments, 0);

            return ($this->className)::$caseName();
        }
    };
}

Which allows to use it in templates:

{% set PostStatus = enum('Acme\\Post\\PostStatus') %}

{% if post.status == PostStatus.Posted %}
    {# ... #}
{% endif %}

{% for status in PostStatus.cases() %}
    {# ... #}
{% endfor %}

In the end I decided to use isser methods on my entities and not exposing enums to templates.

Sharing this in case someone will find it useful. :) This code will need some changes to work with built-in enums.

@codeg-pl
Copy link

Hi, my solution:

<?php

declare(strict_types=1);

namespace App\Twig;

use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

class EnumExtension extends AbstractExtension
{
    public function getFunctions(): array
    {
        return [
            new TwigFunction('enum', [$this, 'enum']),
        ];
    }

    public function enum(string $fullClassName): object
    {
        $parts = explode('::', $fullClassName);
        $className = $parts[0];
        $constant = $parts[1] ?? null;

        if (!enum_exists($className)) {
            throw new \InvalidArgumentException(sprintf('"%s" is not an enum.', $className));
        }

        if ($constant) {
            return constant($fullClassName);
        }

        return new class($fullClassName) {
            public function __construct(private string $fullClassName)
            {
            }

            public function __call(string $caseName, array $arguments): mixed
            {
                return call_user_func_array([$this->fullClassName, $caseName], $arguments);
            }
        };
    }
}

Templates:

{% dump(enum('App\\Entity\\Status').cases()) %}
{% dump(enum('App\\Entity\\Status').customStaticMethod()) %}

{% dump(enum('App\\Entity\\Status::NEW')) %}
{% dump(enum('App\\Entity\\Status::NEW').name()) %}
{% dump(enum('App\\Entity\\Status::NEW').customMethod()) %}

@nicolas-grekas
Copy link
Contributor

I was a bit skeptical at first but both ideas from @luxemate and @codeg-pl look interesting to me.

{{ constant('App\...\Enum::Key').value }}

About this use case, the .value suffix is boilerplate that could be removed if php/php-src#8825 is accepted.

@allejo
Copy link

allejo commented Jun 22, 2022

Slightly different implementation from @codeg-pl's version that allows for something closer to PHP's syntax.

<?php declare(strict_types=1);

namespace App\Twig;

use BadMethodCallException;
use InvalidArgumentException;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

class EnumExtension extends AbstractExtension
{
    /**
     * @return TwigFunction[]
     */
    public function getFunctions(): array
    {
        return [
            new TwigFunction('enum', [$this, 'createProxy']),
        ];
    }

    public function createProxy(string $enumFQN): object
    {
        return new class($enumFQN) {
            public function __construct(private readonly string $enum)
            {
                if (!enum_exists($this->enum)) {
                    throw new InvalidArgumentException("$this->enum is not an Enum type and cannot be used in this function");
                }
            }

            public function __call(string $name, array $arguments)
            {
                $enumFQN = sprintf('%s::%s', $this->enum, $name);

                if (defined($enumFQN)) {
                    return constant($enumFQN);
                }

                if (method_exists($this->enum, $name)) {
                    return $this->enum::$name(...$arguments);
                }

                throw new BadMethodCallException("Neither \"{$enumFQN}\" nor \"{$enumFQN}::{$name}()\" exist in this runtime.");
            }
        };
    }
}
{% set OrderStatus = enum('\\App\\Helpers\\OrderStatus') %}
{% set waitingStatus = [ OrderStatus.Placed, OrderStatus.BeingPrepared ] %}

{% if order.status in waitingStatus %}
    Be patient
{% elseif order.status == OrderStatus.Completed %}
    Order complete!
{% endif %}

...

<select>
    {% for type in OrderStatus.cases() %}
        <option value="{{ type.value }}">
            {{ type.stringLiteral() }} {# getStringLiteral is a custom method in my enum #}
        </option>
    {% endfor %}
</select>

Updates

  • 2023-05-04 - Changed "or" to "nor" in the exception message (no functional changes)
  • 2022-10-26 - Added check to ensure that the given string given to the constructor of this function is in fact an enum

@MateoWartelle
Copy link

Excellent. Thanks

@RSickenberg
Copy link

The @allejo solution should be in the core IMHO.

@stof
Copy link
Member

stof commented Oct 26, 2022

Such function would give access to any static method available in PHP (at least the suggested implementation). This cannot go in core as is (integrating that with the sandbox system would be a nightmare).

@allejo
Copy link

allejo commented Oct 27, 2022

Ohai, never thought other people would find my snippet helpful. @stof is 100% right, my snippet does give access to any static method (I've added an enum_exists check to mitigate this), which is definitely dangerous; I never noticed that 😓 Would adding a check to the constructor to ensure that $enum is an Enum (i.e. enum_exists) be a decent safety check? Anything else I'm not thinking of?

I'm not too familiar with Twig's sandboxing other than it being a whitelist-only system. The enum_exists check does not resolve the sandbox issue though. Are functions like constant allowed inside of a sandbox? If so, how do those work? If not, then could enum just be excluded from sandboxed Twig environments?

Edit: It just hit me, constant() in Twig/PHP doesn't execute any code, it just retrieves values so the safety concern of this enum() function calling arbitrary methods in Enum classes makes sandboxing difficult.

@bertoost
Copy link
Author

Thanks @allejo ! This works like charm. Should be added to the core.

@EarthDweller
Copy link
Contributor

EarthDweller commented Mar 26, 2023

A way to work with ENUMS:

enum MyEnum : string
{
	case READY = 'в очереди';
	case PROCESSING = 'обрабатывается';
	case REJECTED = 'забраковано';

	public static function getAsAssociatedArray () : array
	{
		$to_return = [];
		foreach (self::cases() as $status) {
			$to_return[$status->name] = $status;
			$to_return[strtolower($status->name)] = $status;
		}

		return $to_return;
	}

Controller

(new \App\Twig)->render('template.twig', ["my_enums" => MyEnum::getAsAssociatedArray()]);

TWIG

{# @var my_enums MyEnum #}
{{ dump(my_enums.ready) }}
{{ dump(my_enums.ready.name) }}
{{ dump(my_enums.READY.value) }}

@mpdude
Copy link
Contributor

mpdude commented Apr 21, 2023

@stof Do your objections still hold with the updates made to #3681 (comment)?

If I am not missing anything, it takes a solution like this to be able to pass beim cases e. g. into methods from Twig?

@dland
Copy link

dland commented May 4, 2023

Neither \"{$enumFQN}\" or \"{$enumFQN}::{$name}()\"

Being pendantic here, but that should be Neither \"{$enumFQN}\" nor \"{$enumFQN}::{$name}()\" but otherwise I hope the patch makes it in, in some shape or other

@michelbrons
Copy link

@EarthDweller A simpler way is:

(new \App\Twig)->render('template.twig', ["my_enums" => array_column(Module::cases(), null, 'name')]);

Then you don't need the getAsAssociatedArray function.

@EarthDweller
Copy link
Contributor

@michelbrons
And both ways will work?
{{ dump(my_enums.ready.name) }}
{{ dump(my_enums.READY.value) }}

TWIG code more accurate and readable when all in lower_snake_case, BUT sometimes more visually usefull UPPERCASE
😎

@michelbrons
Copy link

Only uppercase works..

It would be nice if a developer can pass enums to templates and the designer can use autocompletion using my_enums.R...{Modal}

@EarthDweller
Copy link
Contributor

EarthDweller commented May 14, 2023

@michelbrons
It is possible, you can fork TWIG and add that check, then pull request to main repo.
TWIG already cheking methods:
some.method
getMethod, isMethod, hasMethod, method

@codeg-pl
Copy link

Why pass ENUM through controller? Use global solution: #3681 (comment)

I use it in production :)

@EarthDweller
Copy link
Contributor

EarthDweller commented May 14, 2023

Why pass ENUM through controller? Use global solution: #3681 (comment)

I use it in production :)

Depends from how many templates use enum, if only one, controller good way, if uses in more than one template, Twig\Extension – good way.
💪😎

@timo002
Copy link

timo002 commented Oct 11, 2023

I made a small modification to the sollution of @codeg-pl #3681 (comment)

I added the code below to the enum function, also removed the string type from the function parameter

public function enum($fullClassName): object
{
        if (is_object($fullClassName)) {
            $fullClassName = get_class($fullClassName).'::'.$fullClassName->name;
        }

       // Original code continues

In Twig I can now use Enum values from the database like:

<h1>{{ enum(entity.enumPropertie).name() }}</h1>

@Trismegiste
Copy link

In the end I decided to use isser methods on my entities and not exposing enums to templates.

Definitely the best advice, thanks

@GregOriol
Copy link

@allejo It could be possible to limit the issue with a check if it is one of the cases.
Instead of return constant($enumFQN);, something like:

$constant = constant(...);
if (in_array($constant, $enum::cases())) {
    return $constant;
}

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

Successfully merging a pull request may close this issue.