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

How to enable Doctrine second_level caching on read only API #6571

Open
worthwhileindustries opened this issue Sep 1, 2024 · 1 comment
Open

Comments

@worthwhileindustries
Copy link

worthwhileindustries commented Sep 1, 2024

PHP 8.1

API PLATFORM 3.1

Description
I'm not sure if this is a missing feature or bug or lack of understanding configuration.

I have a read only api where I want to try and enable second_level cache and was experimenting with defaults and/or with apcu but, can't seem to get it to work. I also need to write my own cache warmup by hitting the GET endpoints and invalidation which I was hoping to assign regions to different doctrine entities and clear them when needed since the database is managed by another application.

Example
I'll post what I'm doing here

// doctrine.yml

    doctrine:
        orm:
            auto_generate_proxy_classes: false
            proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
            query_cache_driver:
                type: pool
                pool: doctrine.system_cache_pool
            result_cache_driver:
                type: pool
                pool: doctrine.result_cache_pool
            second_level_cache:
                enabled: true

    framework:
        cache:
            pools:
                doctrine.result_cache_pool:
                    adapter: cache.app
                doctrine.system_cache_pool:
                    adapter: cache.system


// App\Entity\Employee.php

<?php

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Ignore;
use Symfony\Component\Serializer\Annotation\SerializedName;

#[ApiResource(operations: [new Get(uriTemplate: '/employee/{id}'), new GetCollection(uriTemplate: '/employee')])]
#[ORM\Table('employee')]
#[ORM\Entity]
#[ORM\Cache]
class Employee
{
    /** The ID of this employee. */
    #[ORM\Id, ORM\Column, ORM\GeneratedValue]
    private $id;

    /** The first name of the employee. */
    #[ORM\Column(name: 'first_name')]
    private string $firstName = '';

    #[ORM\JoinTable(name: 'employee_department')]
    #[ORM\JoinColumn(name: 'employee_id', referencedColumnName: 'id')]
    #[ORM\InverseJoinColumn(name: 'department_id', referencedColumnName: 'id')]
    #[ORM\ManyToMany(targetEntity: 'Department')]
    #[ORM\Cache]
    public readonly Collection $departments;

    public function getId(): int
    {
        return $this->id;
    }

    public function getFirstName(): string
    {
        return $this->firstName;
    }
}

// It wasn't doing any second_level caching on read operations so, I tried adding this

// App\Doctrine\QueryCacheExtension.php

<?php

namespace App\Doctrine;

use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;

class QueryCacheExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
    public function applyToCollection(
        QueryBuilder $queryBuilder,
        QueryNameGeneratorInterface $queryNameGenerator,
        string $resourceClass,
        Operation $operation = null,
        array $context = []
    ): void
    {
        $queryBuilder->setCacheable(true);
    }

    public function applyToItem(
        QueryBuilder $queryBuilder,
        QueryNameGeneratorInterface $queryNameGenerator,
        string $resourceClass,
        array $identifiers,
        Operation $operation = null,
        array $context = []
    ): void
    {
        $queryBuilder->setCacheable(true);
    }
}

// doing this made it start showing puts in the cache but, then api platform doesn't pull them as far as I can tell so I'm getting not hits from the second_level cache on it's own and I'm not sure where to tie into that.

image

// I tried doing something like this to test and see if I can get the result but, then the web profiler loses track of everything and I can't tell if it's actually pulling from cache without debugging more but, since web profiler isn't showing stats, I'm wondering if this is the right approach or what else I'm breaking by putting this file here.

// App\Doctrine\QueryResultItemCacheExtension.php;

<?php

namespace App\Doctrine;

use ApiPlatform\Doctrine\Orm\Extension\QueryResultItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;

class QueryResultItemCacheExtension implements QueryResultItemExtensionInterface
{
    /**
     * Applies the caching and eager loading logic to item queries.
     *
     * @param QueryBuilder $queryBuilder The query builder used to construct the query.
     * @param QueryNameGeneratorInterface $queryNameGenerator The query name generator (not used here).
     * @param string $resourceClass The class name of the resource being queried.
     * @param array $identifiers The identifiers for the item being queried.
     * @param Operation|null $operation The operation being performed (not used here).
     * @param array $context Additional context for the query (not used here).
     */
    public function applyToItem(
        QueryBuilder                $queryBuilder,
        QueryNameGeneratorInterface $queryNameGenerator,
        string                      $resourceClass,
        array                       $identifiers,
        Operation                   $operation = null,
        array                       $context = []
    ): void
    {
        // Enable caching for the query
        $queryBuilder->setCacheable(true);

        // Eagerly load all relationships associated with the entity
        $this->eagerLoadRelationships($queryBuilder, $resourceClass);
    }

    /**
     * Determines whether the caching extension should be applied.
     *
     * @return bool Always returns true, indicating that the extension should be applied.
     */
    public function supportsResult(string $resourceClass, Operation $operation = null, array $context = []): bool
    {
        return true;
    }

    /**
     * Handles the execution of the query, including caching the result.
     *
     * @param QueryBuilder $queryBuilder The query builder used to construct the query.
     * @param string|null $resourceClass The class name of the resource being queried (not used here).
     * @param Operation|null $operation The operation being performed (not used here).
     * @param array $context Additional context for the query (not used here).
     *
     * @return object|null The result of the query, either from the cache or the database.
     */
    public function getResult(QueryBuilder $queryBuilder, string $resourceClass = null, Operation $operation = null, array $context = []): ?object
    {
        // Generate the cache key based on the query's DQL and its parameters
        $query = $queryBuilder->getQuery();
        $cacheKey = md5($query->getDQL() . serialize($query->getParameters()));

        // Get the result cache implementation from Doctrine's EntityManager
        $cache = $queryBuilder->getEntityManager()->getConfiguration()->getResultCache();

        // Attempt to fetch the result from the cache
        if ($cache && $cache->hasItem($cacheKey)) {
            return $cache->getItem($cacheKey)->get();
        }

        // If not in the cache, execute the query and store the result in the cache
        $result = $query->getOneOrNullResult();
        if ($cache) {
            $cacheItem = $cache->getItem($cacheKey);
            $cacheItem->set($result);
            $cache->save($cacheItem);
        }

        // Return the result, either from the database or the cache
        return $result;
    }

    /**
     * Ensures that all relationships for the entity are eagerly loaded.
     *
     * @param QueryBuilder $queryBuilder The query builder used to construct the query.
     * @param string $resourceClass The class name of the resource being queried.
     */
    private function eagerLoadRelationships(QueryBuilder $queryBuilder, string $resourceClass): void
    {
        $entityManager = $queryBuilder->getEntityManager();
        $metadata = $entityManager->getClassMetadata($resourceClass);

        foreach ($metadata->associationMappings as $association => $mapping) {
            if (!$mapping['isOwningSide']) {
                continue;
            }

            $alias = $queryBuilder->getRootAliases()[0];
            $queryBuilder->leftJoin(sprintf('%s.%s', $alias, $association), $association)
                ->addSelect($association);
        }
    }
}

// EDIT: I logged out the mysql query log and it doesn't seem to be making db queries so it is definitely getting from cache. Unfortunately, it loses pagination and probably other things and can't see much in web profiler aside from an initial load.

image

I removed all my custom extensions and now that I have the Employee entity mapped to the Departments entity I'm getting caches on second_level automatically but, it's just the relationships and it never hits them.

image

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

No branches or pull requests

2 participants
@worthwhileindustries and others