Twig icon indicating copy to clipboard operation
Twig copied to clipboard

Support reading enums from PHP 8.1

Open bertoost opened this issue 2 years ago • 27 comments

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

bertoost avatar Apr 09 '22 18:04 bertoost

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.

stof avatar Apr 11 '22 13:04 stof

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

ThomasLandauer avatar Apr 19 '22 21:04 ThomasLandauer

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(..)

bertoost avatar Apr 20 '22 07:04 bertoost

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?

ThomasLandauer avatar Apr 20 '22 13:04 ThomasLandauer

@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).

stof avatar Apr 20 '22 13:04 stof

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?

janklan avatar May 05 '22 22:05 janklan

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.

luxemate avatar May 13 '22 11:05 luxemate

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()) %}

codeg-pl avatar Jun 17 '22 16:06 codeg-pl

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 https://github.com/php/php-src/pull/8825 is accepted.

nicolas-grekas avatar Jun 21 '22 12:06 nicolas-grekas

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

allejo avatar Jun 22 '22 07:06 allejo

Excellent. Thanks

MateoWartelle avatar Jul 02 '22 22:07 MateoWartelle

The @allejo solution should be in the core IMHO.

RSickenberg avatar Oct 26 '22 08:10 RSickenberg

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).

stof avatar Oct 26 '22 10:10 stof

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.

allejo avatar Oct 27 '22 03:10 allejo

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

bertoost avatar Dec 19 '22 12:12 bertoost

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) }}

EarthDweller avatar Mar 26 '23 19:03 EarthDweller

@stof Do your objections still hold with the updates made to https://github.com/twigphp/Twig/issues/3681#issuecomment-1162728959?

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?

mpdude avatar Apr 21 '23 18:04 mpdude

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

dland avatar May 04 '23 08:05 dland

@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.

michelbrons avatar May 11 '23 09:05 michelbrons

@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 😎

EarthDweller avatar May 11 '23 11:05 EarthDweller

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}

michelbrons avatar May 12 '23 14:05 michelbrons

@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

EarthDweller avatar May 14 '23 18:05 EarthDweller

Why pass ENUM through controller? Use global solution: https://github.com/twigphp/Twig/issues/3681#issuecomment-1159029881

I use it in production :)

codeg-pl avatar May 14 '23 19:05 codeg-pl

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. 💪😎

EarthDweller avatar May 14 '23 19:05 EarthDweller

I made a small modification to the sollution of @codeg-pl https://github.com/twigphp/Twig/issues/3681#issuecomment-1159029881

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>

timo002 avatar Oct 11 '23 22:10 timo002

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

Definitely the best advice, thanks

Trismegiste avatar Jan 17 '24 16:01 Trismegiste

@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;
}

GregOriol avatar Apr 25 '24 13:04 GregOriol