Сериализатор JMS — проблема с перекрестными ссылками

Моя сущность имеет две самоссылающиеся OneToMany отношения children и revisions.

<?php

namespace App\Entity\CMS;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as JMS;

/**
 * @ORM\Entity
 * @JMS\ExclusionPolicy("ALL")
 */
class Page
{
    /**
     * @var int
     *
     * @ORM\Id()
     * @ORM\GeneratedValue(strategy="IDENTITY")
     * @ORM\Column(type="integer")
     */
    protected $id;

    /**
     * @var Page[]|ArrayCollection
     *
     * @ORM\OneToMany(targetEntity="CoreBundle\Entity\CMS\Page", mappedBy="parent")
     *
     * @JMS\Groups({"page_children"})
     * @JMS\Expose()
     */
    protected $children;

    /**
     * @var Page[]|ArrayCollection
     *
     * @ORM\OneToMany(targetEntity="CoreBundle\Entity\CMS\Page", mappedBy="page")
     *
     * @JMS\Groups({"revisions"})
     * @JMS\Expose()
     *
     */
    protected $revisions;

    /**
     * @var string
     *
     * @ORM\Column(type="string", value={"main", "revision"})
     *
     * @JMS\Expose()
     *
     */
    protected $type;

    #...
}

Выставляю две коллекции - children и revisions. Дополнительно выставляется type - это показатель того, принадлежит ли Page revisions или нет.

Запрос {{host}}/api/pages?expand=page_children возвращает результат, который включает Pages обоих типов.

{
    "id": "1",
    "type": "main",
    "children": [
        {
            "id": "3",
            "type": "main",
            "children": [
                {
                    "id": "5",
                    "type": "main",
                    "children": []

                },
                {
                    "id": "6",
                    "type": "revision",
                    "children": []

                }
            ],
        },
        {
            "id": "4,
            "type": "revision",
            "children": []
        }
    ],
    "id": "2',
    "type": "revision",
    "children": []
}

Я хотел бы исключить из ответа Pages тип revision. Итак, мой окончательный результат будет выглядеть так:

{
    "id": "1",
    "type": "main",
    "children": [
        {
            "id": "3",
            "type": "main",
            "children": [
                {
                    "id": "5",
                    "type": "main",
                    "children": []
                }
            ]
        }
    ]
}

Обычно для фильтрации результатов я использую LexikFormFilterBundle. Однако в этом случае комбинированный запрос, например:

{{host}}/api/expand=page_children&page_filter[type]=main

работает только для результатов первого уровня.

Я думал о стратегии динамического исключения или Обработчик подписки. К сожалению, я не могу найти решение.


person Łukasz D. Tulikowski    schedule 12.02.2018    source источник
comment
Это кажется интересной проблемой. Пожалуйста, отредактируйте и добавьте немного больше деталей. До сих пор я понимаю, что вы можете запрашивать объект Page, получать его дочерние элементы и ревизии, но показывать только дочерние элементы ревизий. Правильный?   -  person Don Omondi    schedule 13.02.2018
comment
@DonOmondi Я добавил детали, дайте мне знать, если теперь станет понятнее. Спасибо.   -  person Łukasz D. Tulikowski    schedule 13.02.2018
comment
Извините, что не заходил несколько часов. Есть одно решение, но оно немного длинное, но дает большую гибкость, я использовал его при работе со сложными API. Если вам это нравится, я могу опубликовать как ответ с более подробной информацией. По сути, создайте отдельный класс, который вы будете заполнять (на __construct) из сериализованного массива, во время которого вы опускаете тип, который вам не нужен.   -  person Don Omondi    schedule 14.02.2018
comment
@DonOmondi Вы имеете в виду использование DTO?   -  person Łukasz D. Tulikowski    schedule 15.02.2018


Ответы (2)


Я решил эту проблему, внедрив обработчик подписки.

<?php

namespace CoreBundle\Serializer\Subscriber\CMS;

use App\Entity\CMS\Page;
use JMS\Serializer\EventDispatcher\Events;
use JMS\Serializer\EventDispatcher\PreSerializeEvent;
use JMS\Serializer\Handler\SubscribingHandlerInterface;

class PageSubscriber extends SubscribingHandlerInterface
{
    /**
     * {@inheritdoc}
     */
    public static function getSubscribedEvents()
    {
        return [
            [
                'event' => Events::PRE_SERIALIZE,
                'method' => 'onPreSerialize',
                'class' => Page::class,
                'format' => 'json',
            ],
        ];
    }

    /**
     * @param PreSerializeEvent $event
     */
    public function onPreSerialize(PreSerializeEvent $event)
    {
        $entity = $event->getObject();

        if (!$entity instanceof Page) {
            return;
        }
        if ($this->isSerialisingForGroup($event, 'exclude_revisions')) {
            $this->excludeRevisions($entity);
        }
    }

    /**
     * @param Page $page
     */
    private function excludeRevisions(Page $page): void
    {
        foreach ($page->getChildren() as $child) {
            if ($child->getStatus() === 'revision') {
                $page->removeChild($child);
            }
        }
    }
}

Недостаток: в этот момент извлекаются все данные. Элементы "type": "revision" будут включены в не первый уровень и он расширится, что может привести к Allowed memory size exhausted.

Другой возможный подход — использовать Doctrine postLoad Event.

<?php

namespace App\Service\Doctrine;

use App\Entity\CMS\Page;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use RuntimeException;

class PageListener implements EventSubscriber
{
    /** @var bool $canFlush */
    private $canFlush = true;

    /**
     * {@inheritdoc}
     */
    public function getSubscribedEvents()
    {
        return [
            Events::postLoad,
            Events::onFlush,
        ];
    }

    /**
     * @param Page $page
     */
    public function onPostLoad(Page $page): void
    {
        $children = $page->getChildren();

        foreach ($children as $child) {
            if ($child->getStatus === 'revision') {
                $page->removeChild($child);
            }
        }
    }

    /**
     * @param OnFlushEventArgs $eventArgs
     */
    public function onFlush(OnFlushEventArgs $eventArgs)
    {
        $em = $eventArgs->getEntityManager();
        $uow = $em->getUnitOfWork();

        foreach ($uow->getScheduledEntityDeletions() as $entityDeletion) {
            if ($entityDeletion instanceof Page){
                throw new RuntimeException('Flushing Page at this point will remove all Revisions.');
            }
        }
    }
}

Недостаток: может быть опасным и ограничивать возможные будущие изменения.

person Łukasz D. Tulikowski    schedule 15.02.2018

Вы можете сделать это с помощью пользовательского метода внутри вашего класса и указать JMS использовать его.

/** * @VirtualProperty * @SerializedName("my_collection") */ public function getMyCollection() { return $this->collection->filterByType..... }

Если вы хотите сделать его динамическим, вы можете хранить некоторые данные внутри виртуального свойства и использовать их в этом методе. Для управления глубиной используйте @JMS\MaxDepth(depth=1).

Но концептуально это кажется очень плохой идеей. Я бы предпочел пересмотреть масштаб и, возможно, разделить вещи должным образом.

person Doru Mardari    schedule 14.02.2018
comment
Я мог бы попробовать это решение. Не могли бы вы немного улучшить свой ответ? Вы имеете в виду использование Criteria(Criteria::expr()->...? - person Łukasz D. Tulikowski; 15.02.2018
comment
да. Но дело не в том, как вы делаете фильтрацию, главное, чтобы она у вас правильно фильтровалась. Суть заключалась в том, чтобы раскрыть его через пользовательские геттеры, которые подготовят данные, а не через существующие свойства. - person Doru Mardari; 15.02.2018
comment
@ ŁukaszD.Tulikowski, но все же я бы посоветовал вам пересмотреть свою модель данных вместо того, чтобы делать грязные хаки. - person Doru Mardari; 15.02.2018
comment
Я согласен, что ни одно из решений не является хорошим. Но, к сожалению, модель не может быть изменена в данный момент. Вот почему мне нужна была помощь. - person Łukasz D. Tulikowski; 15.02.2018