Search code examples
symfonytwigsymfony6

How to pass class to twig custom tag using twig extension Node compiler


Factory creation with injected entity repository


namespace App\DependencyInjection\Compiler;

use App\Factory\EntityFactoryRegistry;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

class EntityFactoryPass implements CompilerPassInterface
{

    public function process(ContainerBuilder $container): void
    {
        if (!$container->has(EntityFactoryRegistry::class)) {
            return;
        }

        $definition = $container->findDefinition(EntityFactoryRegistry::class);
        $taggedServices = $container->findTaggedServiceIds('app.entity_factory');
        $entityManager = $container->findDefinition('doctrine.orm.entity_manager');

        foreach ($taggedServices as $id => $tags) {
            $definition->addMethodCall('addFactory', [new Reference($id), $entityManager]);
        }
    }
} 

//using code like this to get factory for entity, now this part works, I have the factory accessible everywhere I want.
foreach ((new \ReflectionClass($this::class))->getAttributes(Access::class) as $attribute) {
    if (key_exists('entity', $attribute->getArguments())) {
        $this->factory = $this->entityFactoryRegistry->get($attribute->getArguments()['entity']);
    }
}

Post factory:

<?php

namespace App\Factory;

use App\Command\EntityCommandInterface;
use App\Command\PostCommand;
use App\Entity\Base\BaseEntity;
use App\Entity\Post;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;

#[Autoconfigure(tags: ['app.entity_factory'])]
class PostFactory implements EntityFactoryInterface
{
    private ?EntityManagerInterface $entityManager = null;

    public function create(EntityCommandInterface|PostCommand $command): BaseEntity
    {

    }

    public function edit(BaseEntity|Post $entity, EntityCommandInterface|PostCommand $command): BaseEntity
    {

    }

    public function getMainPosts (): array
    {
        return $this->entityManager->getRepository($this->getEntity())
            ->findAll();
    }

    public function setEntityManager (EntityManagerInterface $entityManager): void
    {
        $this->entityManager = $entityManager;
    }

    public function getEntity(): string
    {
        return Post::class;
    }
}

now when it comes to twig extension, I would like to get factory the same way and pass it to custom twig node.

Extension creation:

namespace App\Extension;

use App\Extension\Parser\FactoryTokenParser;
use App\Factory\EntityFactoryRegistry;
use Twig\Extension\AbstractExtension;

class FactoryExtension extends AbstractExtension
{
    public function __construct(
        protected readonly EntityFactoryRegistry $entityFactoryRegistry,
    )
    {
    }

    public function getTokenParsers(): array
    {
        return [
            new FactoryTokenParser($this->entityFactoryRegistry),
        ];
    }
}

Token parser:

<?php

namespace App\Extension\Parser;

use App\Extension\Node\FactoryTagNode;
use App\Factory\EntityFactoryRegistry;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;

class FactoryTokenParser extends AbstractTokenParser
{
    public function __construct(
        protected readonly EntityFactoryRegistry $entityFactoryRegistry,
    )
    {}

    public function parse(Token $token): FactoryTagNode
    {
        $stream = $this->parser->getStream();

        $entityClass = $this->parser->getExpressionParser()->parseExpression();

        if (!class_exists($entityClass->getAttribute("value"))) {
            throw new \Exception("Class provided to factory does not exist");
        }

        // Parse the content between {% factory %} and {% endfactory %}
        $stream->expect(Token::BLOCK_END_TYPE);
        $body = $this->parser->subparse([$this, 'decideFactoryEndTag'], true);
        $stream->expect(Token::BLOCK_END_TYPE);

        $entityFactory = $this->entityFactoryRegistry->get($entityClass->getAttribute("value"));

        return new FactoryTagNode(
            $entityFactory,
            $body,
            $token->getLine(),
            $this->getTag()
        );
    }

    public function decideFactoryEndTag(Token $token): bool
    {
        return $token->test('endfactory');
    }

    public function getTag(): string
    {
        return 'factory';
    }
}

Node:

<?php

namespace App\Extension\Node;

use App\Factory\EntityFactoryInterface;
use Twig\Compiler;
use Twig\Node\Node;

class FactoryTagNode extends Node
{
    private EntityFactoryInterface $factory;

    public function __construct(EntityFactoryInterface $entityFactory, Node $body, int $line, string $tag)
    {
        parent::__construct(['body' => $body], [], $line, $tag);
        $this->factory = $entityFactory;
    }

    public function compile(Compiler $compiler): void
    {
        $body = $this->getNode('body');

        $compiler
            ->addDebugInfo($this)
            ->write(sprintf('$context["factory"] = %s;', $this->getVarExport($this->factory)))
            ->write('ob_start();' . PHP_EOL)
            ->subcompile($body)
            ->write('echo strtoupper(ob_get_clean());' . PHP_EOL);
    }

    private function getVarExport($var): ?string
    {
        return var_export($var, true);
    }
}

Now this is the part, where it wont work. ->write(sprintf('$context["factory"] = %s;', $this->getVarExport($this->factory))) Error: An exception has been thrown during the compilation of a template ("Warning: var_export does not handle circular references").

When I initialize class like this ->write(sprintf('$context["factory"] = new %s();', get_class(this->factory)))

Twig has the class available, but I want to inject the factory class, not to re-init new instance of a class for twig.

Why, reason is that, entity manager is injected into factories using compiler pass and when I re-init class, it is no longer available.

Are there more solutions to this I am unable to figure out?

Twig rendering part:

{% factory 'App\\Entity\\Post' %}
    {{ dump(factory.mainPosts) }}
{% endfactory %}

Solution

  • Okay, got it figured out, basically what I did

    FactoryExtension.php:

    • added getter, to get factory from collection, wont pass it any more to the parser.
    <?php
    
    namespace App\Extension;
    
    use App\Extension\Parser\FactoryTokenParser;
    use App\Factory\EntityFactoryRegistry;
    use Twig\Extension\AbstractExtension;
    use Twig\TwigFunction;
    
    class FactoryExtension extends AbstractExtension
    {
        public function __construct(
            protected readonly EntityFactoryRegistry $entityFactoryRegistry
        )
        {
        }
    
        public function getTokenParsers(): array
        {
            return [
                new FactoryTokenParser(),
            ];
        }
    
        public function getEntityFactory(string $className): ?object
        {
            return $this->entityFactoryRegistry->get($className);
        }
    }
    
    

    Main thing: FactoryTagNode.php:

    • since Environment is accessible from Twig Compiler, we can actually ask for registered extensions from it and access the methods inside of it.
    • in my case $entityClass->getAttribute("value") === App\Entity\Post
    <?php
    
    namespace App\Extension\Node;
    
    use Twig\Compiler;
    use Twig\Node\Node;
    
    class FactoryTagNode extends Node
    {
    
        public function __construct(Node $entityClass, Node $body, int $line, string $tag)
        {
            parent::__construct(['entityClass' => $entityClass, 'body' => $body], [], $line, $tag);
        }
    
        public function compile(Compiler $compiler): void
        {
            $body = $this->getNode('body');
            $entityClass = $this->getNode('entityClass');
    
            $compiler
                ->addDebugInfo($this)
                ->write(sprintf('$context["factory"] = $this->env->getExtension(\'App\\Extension\\FactoryExtension\')->getEntityFactory(\'%s\');' . PHP_EOL, $entityClass->getAttribute("value")))
                ->write('ob_start();' . PHP_EOL)
                ->subcompile($body)
                ->write('echo strtoupper(ob_get_clean());' . PHP_EOL);
        }
    }
    

    And twig:

    {% factory 'App\\Entity\\Post' %}
        {{ dump(factory.mainPosts|length) }} {# prints out 2 in my case #}
    {% endfactory %}