twigphp / Twig

Twig, the flexible, fast, and secure template language for PHP
https://twig.symfony.com/
BSD 3-Clause "New" or "Revised" License
8.13k stars 1.24k forks source link

Support reading enums from PHP 8.1 #3681

Open bertoost opened 2 years ago

bertoost commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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

bertoost commented 2 years ago

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 commented 2 years ago

OK, that's 2 different things:

stof commented 2 years ago

@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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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.

allejo commented 2 years ago

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

MateoWartelle commented 2 years ago

Excellent. Thanks

RSickenberg commented 1 year ago

The @allejo solution should be in the core IMHO.

stof commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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

EarthDweller commented 1 year ago

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 commented 1 year ago

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

dland commented 1 year ago
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 commented 1 year ago

@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 commented 1 year ago

@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 commented 1 year ago

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 commented 1 year ago

@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 commented 1 year ago

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

I use it in production :)

EarthDweller commented 1 year ago

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 commented 11 months ago

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>
Trismegiste commented 8 months ago

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

Definitely the best advice, thanks

GregOriol commented 4 months ago

@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;
}
ReSpawN commented 2 months ago

@allejo excellent stuff. Adopted yours right away. <3 Open Source