SonataTranslationBundle icon indicating copy to clipboard operation
SonataTranslationBundle copied to clipboard

Translation of nested translatable entity is not saved

Open byhaskell opened this issue 3 years ago • 7 comments
trafficstars

Environment

Sonata packages

show

sonata-project/admin-bundle              4.18.0 4.18.0 The missing Symfony Admin Generator
sonata-project/block-bundle              4.17.0 4.17.0 Symfony SonataBlockBundle
sonata-project/cache                     2.2.0  2.2.0  Cache library
Package sonata-project/cache is abandoned, you should avoid using it. No replacement was suggested.
sonata-project/doctrine-extensions       2.0.1  2.0.1  Doctrine2 behavioral extensions
sonata-project/doctrine-orm-admin-bundle 4.7.0  4.7.0  Integrate Doctrine ORM into the SonataAdminBundle
sonata-project/exporter                  3.0.0  3.0.0  Lightweight Exporter library
sonata-project/form-extensions           1.18.0 1.18.0 Symfony form extensions
sonata-project/intl-bundle               2.14.0 2.14.0 Symfony SonataIntlBundle
sonata-project/media-bundle              4.5.0  4.5.0  Symfony SonataMediaBundle
sonata-project/translation-bundle        3.1.0  3.1.0  SonataTranslationBundle
sonata-project/twig-extensions           2.0.0  2.0.0  Sonata twig extensions.

Symfony packages

show

symfony/amqp-messenger             v5.4.11 v5.4.11 Symfony AMQP extension Messenger Bridge
symfony/asset                      v5.4.7  v6.1.0  Manages URL generation and versioning of web assets ...
symfony/browser-kit                v5.4.11 v6.1.3  Simulates the behavior of a web browser, allowing yo...
symfony/cache                      v5.4.11 v6.1.3  Provides an extended PSR-6, PSR-16 (and tags) implem...
symfony/cache-contracts            v2.5.2  v3.1.1  Generic abstractions related to caching
symfony/config                     v5.4.11 v6.1.3  Helps you find, load, combine, autofill and validate...
symfony/console                    v5.4.12 v6.1.4  Eases the creation of beautiful and testable command...
symfony/css-selector               v5.4.11 v6.1.3  Converts CSS selectors to XPath expressions
symfony/debug-bundle               v5.4.11 v6.1.3  Provides a tight integration of the Symfony VarDumpe...
symfony/dependency-injection       v5.4.11 v6.1.3  Allows you to standardize and centralize the way obj...
symfony/deprecation-contracts      v3.1.1  v3.1.1  A generic function and convention to trigger depreca...
symfony/doctrine-bridge            v5.4.11 v6.1.3  Provides integration for Doctrine with various Symfo...
symfony/doctrine-messenger         v5.4.12 v6.1.4  Symfony Doctrine Messenger Bridge
symfony/dom-crawler                v5.4.12 v6.1.4  Eases DOM navigation for HTML and XML documents
symfony/dotenv                     v5.4.5  v6.1.0  Registers environment variables from a .env file
symfony/error-handler              v5.4.11 v6.1.3  Provides tools to manage errors and ease debugging P...
symfony/event-dispatcher           v5.4.9  v6.1.0  Provides tools that allow your application component...
symfony/event-dispatcher-contracts v3.1.1  v3.1.1  Generic abstractions related to dispatching event
symfony/expression-language        v5.4.11 v6.1.3  Provides an engine that can compile and evaluate exp...
symfony/filesystem                 v5.4.12 v6.1.4  Provides basic utilities for the filesystem
symfony/finder                     v5.4.11 v6.1.3  Finds files and directories via an intuitive fluent ...
symfony/flex                       v2.2.3  v2.2.3  Composer plugin for Symfony
symfony/form                       v5.4.12 v6.1.4  Allows to easily create, process and reuse HTML forms
symfony/framework-bundle           v5.4.12 v6.1.4  Provides a tight integration between Symfony compone...
symfony/http-client                v5.4.12 v6.1.4  Provides powerful methods to fetch HTTP resources sy...
symfony/http-client-contracts      v2.5.2  v3.1.1  Generic abstractions related to HTTP clients
symfony/http-foundation            v5.4.12 v6.1.4  Defines an object-oriented layer for the HTTP specif...
symfony/http-kernel                v5.4.12 v6.1.4  Provides a structured process for converting a Reque...
symfony/intl                       v5.4.11 v6.1.0  Provides a PHP replacement layer for the C intl exte...
symfony/lock                       v5.4.10 v6.1.3  Creates and manages locks, a mechanism to provide ex...
symfony/mailer                     v5.4.12 v6.1.4  Helps sending emails
symfony/maker-bundle               v1.45.0 v1.45.0 Symfony Maker helps you create empty commands, contr...
symfony/messenger                  v5.4.11 v6.1.3  Helps applications send and receive messages to/from...
symfony/mime                       v5.4.12 v6.1.4  Allows manipulating MIME messages
symfony/monolog-bridge             v5.4.10 v6.1.2  Provides integration for Monolog with various Symfon...
symfony/monolog-bundle             v3.8.0  v3.8.0  Symfony MonologBundle
symfony/notifier                   v5.4.8  v6.1.0  Sends notifications via one or more channels (email,...
symfony/options-resolver           v5.4.11 v6.1.0  Provides an improved replacement for the array_repla...
symfony/password-hasher            v5.4.11 v6.1.3  Provides password hashing utilities
symfony/phpunit-bridge             v6.1.3  v6.1.3  Provides utilities for PHPUnit, especially user depr...
symfony/polyfill-intl-grapheme     v1.26.0 v1.26.0 Symfony polyfill for intl's grapheme_* functions
symfony/polyfill-intl-icu          v1.26.0 v1.26.0 Symfony polyfill for intl's ICU-related data and cla...
symfony/polyfill-intl-idn          v1.26.0 v1.26.0 Symfony polyfill for intl's idn_to_ascii and idn_to_...
symfony/polyfill-intl-normalizer   v1.26.0 v1.26.0 Symfony polyfill for intl's Normalizer class and rel...
symfony/polyfill-mbstring          v1.26.0 v1.26.0 Symfony polyfill for the Mbstring extension
symfony/polyfill-php73             v1.26.0 v1.26.0 Symfony polyfill backporting some PHP 7.3+ features ...
symfony/polyfill-php80             v1.26.0 v1.26.0 Symfony polyfill backporting some PHP 8.0+ features ...
symfony/polyfill-php81             v1.26.0 v1.26.0 Symfony polyfill backporting some PHP 8.1+ features ...
symfony/process                    v5.4.11 v6.1.3  Executes commands in sub-processes
symfony/property-access            v5.4.11 v6.1.3  Provides functions to read and write from/to an obje...
symfony/property-info              v5.4.11 v6.1.3  Extracts information about PHP class' properties usi...
symfony/proxy-manager-bridge       v5.4.6  v6.1.0  Provides integration for ProxyManager with various S...
symfony/rate-limiter               v5.4.11 v6.1.3  Provides a Token Bucket implementation to rate limit...
symfony/redis-messenger            v5.4.6  v6.1.3  Symfony Redis extension Messenger Bridge
symfony/routing                    v5.4.11 v6.1.3  Maps an HTTP request to a set of configuration varia...
symfony/runtime                    v5.4.11 v6.1.3  Enables decoupling PHP applications from global state
symfony/security-acl               v3.3.1  v3.3.1  Symfony Security Component - ACL (Access Control List)
symfony/security-bundle            v5.4.11 v6.1.3  Provides a tight integration of the Security compone...
symfony/security-core              v5.4.11 v6.1.4  Symfony Security Component - Core Library
symfony/security-csrf              v5.4.11 v6.1.0  Symfony Security Component - CSRF Library
symfony/security-guard             v5.4.9  v5.4.9  Symfony Security Component - Guard
symfony/security-http              v5.4.12 v6.1.4  Symfony Security Component - HTTP Integration
symfony/serializer                 v5.4.12 v6.1.4  Handles serializing and deserializing data structure...
symfony/service-contracts          v2.5.2  v3.1.1  Generic abstractions related to writing services
symfony/stopwatch                  v5.4.5  v6.1.0  Provides a way to profile code
symfony/string                     v5.4.12 v6.1.4  Provides an object-oriented API to strings and deals...
symfony/translation                v5.4.12 v6.1.4  Provides tools to internationalize your application
symfony/translation-contracts      v2.5.2  v3.1.1  Generic abstractions related to translation
symfony/twig-bridge                v5.4.12 v6.1.4  Provides integration for Twig with various Symfony c...
symfony/twig-bundle                v5.4.8  v6.1.1  Provides a tight integration of Twig into the Symfon...
symfony/validator                  v5.4.12 v6.1.4  Provides tools to validate values
symfony/var-dumper                 v5.4.11 v6.1.3  Provides mechanisms for walking through any arbitrar...
symfony/var-exporter               v5.4.10 v6.1.3  Allows exporting any serializable PHP data structure...
symfony/web-link                   v5.4.3  v6.1.0  Manages links between resources
symfony/web-profiler-bundle        v5.4.10 v6.1.2  Provides a development tool that gives detailed info...
symfony/yaml                       v5.4.12 v6.1.4  Loads and dumps YAML files

PHP version

PHP 8.1.5 (cli) (built: Apr 12 2022 17:38:57) (ZTS Visual C++ 2019 x64)
Copyright (c) The PHP Group
Zend Engine v4.1.5, Copyright (c) Zend Technologies

Subject

  1. There is a Page entity and it is translatable
  2. There is a Block entity and it is translatable
  3. Relationship between these two entities * As a result, translations of the second (built-in) entity are not saved.

Page


namespace App\Entity;

use App\Repository\PageRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Knp\DoctrineBehaviors\Contract\Entity\SluggableInterface;
use Knp\DoctrineBehaviors\Contract\Entity\TranslatableInterface;
use Knp\DoctrineBehaviors\Contract\Entity\TranslationInterface;
use Knp\DoctrineBehaviors\Model\Sluggable\SluggableTrait;
use Knp\DoctrineBehaviors\Model\Translatable\TranslatableTrait;

class Page implements TranslatableInterface, SluggableInterface
{
    use SluggableTrait;
    use TranslatableTrait;

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(type: 'integer', length: 2)]
    private ?int $type = null;

    #[ORM\OneToMany(mappedBy: 'page', targetEntity: Block::class, cascade: ["all"], orphanRemoval: true)]
    protected $block;

    #[ORM\OneToMany(mappedBy: 'translatable', targetEntity: PageTranslation::class, cascade: ["persist", "remove"], orphanRemoval: true)]
    protected $translations;

    public function __construct()
    {
        $this->translations = new ArrayCollection();
        $this->block = new ArrayCollection();
    }

    public function __toString()
    {
        return '';
    }

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

    public function getTranslations(): Collection
    {
        return $this->translations;
    }

    public function addTranslation(TranslationInterface $translation): void
    {
        if (!$this->translations->contains($translation)) {
            $this->translations[$translation->getLocale()] = $translation;
            $translation->setTranslatable($this);
        }
    }

    public function removeTranslation(TranslationInterface $translation): void
    {

    }

    public function getType(): ?int
    {
        return $this->type;
    }

    public function setType(int $type): self
    {
        $this->type = $type;

        return $this;
    }

    public function getSluggableFields(): array
    {
        return [];
    }

    /**
     * @return Collection<int, Block>
     */
    public function getBlock(): Collection
    {
        return $this->block;
    }

    public function addBlock(Block $block): self
    {
        if (!$this->block->contains($block)) {
            $this->block->add($block);
            $block->setPage($this);
        }

        return $this;
    }

    public function removeBlock(Block $block): self
    {
        if ($this->block->removeElement($block)) {
            // set the owning side to null (unless already changed)
            if ($block->getPage() === $this) {
                $block->setPage(null);
            }
        }

        return $this;
    }
}

PageTranslation


namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Knp\DoctrineBehaviors\Contract\Entity\TranslatableInterface;
use Knp\DoctrineBehaviors\Contract\Entity\TranslationInterface;
use Knp\DoctrineBehaviors\Model\Translatable\TranslationTrait;

#[ORM\Entity]
class PageTranslation implements TranslationInterface
{
    use TranslationTrait;

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    protected $locale;

    #[ORM\ManyToOne(targetEntity: Page::class, inversedBy: 'translations')]
    #[ORM\JoinColumn(nullable: false)]
    protected $translatable;

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

    public function setTranslatable(TranslatableInterface $translatable): void
    {
        $this->translatable = $translatable;
    }

    public function getTranslatable(): TranslatableInterface
    {
        return $this->translatable;
    }
}

Block


namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Knp\DoctrineBehaviors\Contract\Entity\TranslatableInterface;
use Knp\DoctrineBehaviors\Contract\Entity\TranslationInterface;
use Knp\DoctrineBehaviors\Model\Translatable\TranslatableTrait;

#[ORM\Entity(repositoryClass: BlockRepository::class)]
class Block implements TranslatableInterface
{
    use TranslatableTrait;

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: Page::class, inversedBy: 'block')]
    #[ORM\JoinColumn(nullable: false)]
    protected $page;

    #[ORM\Column(length: 255)]
    private ?string $fieldTest = null;

    #[ORM\OneToMany(mappedBy: 'translatable', targetEntity: BlockTranslation::class, cascade: ["persist", "remove"], orphanRemoval: true)]
    protected $translations;

    public function __construct()
    {
        $this->translations = new ArrayCollection();
    }

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

    public function getName(): ?string
    {
        return $this->translate( null, false )->getName();
    }

    public function setName(string $name): self
    {
        $this->translate( null, false )->setName( $name );

        return $this;
    }

    public function getTranslations(): Collection
    {
        return $this->translations;
    }

    public function addTranslation(TranslationInterface $translation): void
    {
        if (!$this->translations->contains($translation)) {
            $this->translations[$translation->getLocale()] = $translation;
            $translation->setTranslatable($this);
        }
    }

    public function removeTranslation(TranslationInterface $translation): void
    {

    }

    public function __toString()
    {
        return $this->getName() ?: '';
    }

    public function getPage(): ?Page
    {
        return $this->page;
    }

    public function setPage(?Page $page): self
    {
        $this->page = $page;

        return $this;
    }

    public function getFieldTest(): ?string
    {
        return $this->fieldTest;
    }

    public function setFieldTest(string $fieldTest): self
    {
        $this->fieldTest = $fieldTest;

        return $this;
    }
}

BlockTranslation


namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Knp\DoctrineBehaviors\Contract\Entity\TranslatableInterface;
use Knp\DoctrineBehaviors\Contract\Entity\TranslationInterface;
use Knp\DoctrineBehaviors\Model\Translatable\TranslationTrait;

#[ORM\Entity]
class BlockTranslation implements TranslationInterface
{
    use TranslationTrait;

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $name = null;

    protected $locale;

    #[ORM\ManyToOne(targetEntity: Block::class, inversedBy: 'translations')]
    #[ORM\JoinColumn(nullable: false)]
    protected $translatable;

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

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): self
    {
        $this->name = $name;

        return $this;
    }

    public function setTranslatable(TranslatableInterface $translatable): void
    {
        $this->translatable = $translatable;
    }

    public function getTranslatable(): TranslatableInterface
    {
        return $this->translatable;
    }
}

We fill in a non-translatable field in the Block - fieldTest and a translated name, the first is saved, and the second is not, while even the connection with the translation is not saved in the database. If you dump the object through the preUpdate method, all translations are in newTranslations.

Expected results

Saving data

Actual results

Translation of nested translatable entity is not saved

byhaskell avatar Aug 30 '22 08:08 byhaskell

As I understand it, the mergeNewTranslations method is not executed when processing fields

byhaskell avatar Aug 31 '22 04:08 byhaskell

This is an issue for SonataTranslationBundle

i would say the mergeNewTranslations is supposed to be called by https://github.com/sonata-project/SonataTranslationBundle/blob/9d46dd7ca28ce4a891f19509e4ceec733c1fc97e/src/Admin/Extension/Knplabs/TranslatableAdminExtension.php

VincentLanglet avatar Aug 31 '22 08:08 VincentLanglet

@VincentLanglet Just an hour ago, I realized this too, thank you.

My solution was to add the following code to the main entity:

public function translate($locale = null, $fallbackToDefault = false) : TranslationInterface
    {
        $translation = $this->doTranslate($locale, $fallbackToDefault);
        if ($this->id == null) {
            $this->mergeNewTranslations();
        }
        return $translation;
    }

If you do not make an exception when creating an object ($this->id == null), then there is a duplication of translation keys.

I understand that the code that you presented is not being executed now and needs to be additionally connected? I checked in the library I have it, but apparently it does not work.

byhaskell avatar Aug 31 '22 08:08 byhaskell

I don't use this repository, so some help for the investigation...

The compiler https://github.com/sonata-project/SonataTranslationBundle/blob/3.x/src/DependencyInjection/Compiler/AdminExtensionCompilerPass.php is supposed to add the AdminExtension https://github.com/sonata-project/SonataTranslationBundle/blob/3.x/src/Resources/config/service_gedmo.php

I would say it require a config like https://github.com/sonata-project/SonataTranslationBundle/blob/3.x/docs/reference/advanced_configuration.rst

But I wonder why the default interface https://github.com/doctrine-extensions/DoctrineExtensions/blob/main/doc/translatable.md#translatable-entity-example is not taken by default. This might require a PR.

VincentLanglet avatar Aug 31 '22 08:08 VincentLanglet

Thanks for your help, it's very much appreciated!!! I'll try to be of some help. The configuration is set in the project:

sonata_translation:
    locales: '%locales%'
    default_locale: '%locale%'
    locale_switcher: true

    # change default behavior for translated field filtering.
    default_filter_mode: knplabs # must be either 'gedmo' or 'knplabs', default: gedmo

    # here enable the types you need
    gedmo:
        enabled: false
        # when using gedmo/doctrine-extensions, you have to register a translatable listener
        # service or if you are using a bundle that integrates the library, it will be registered
        # by the bundle (e.g. "stof_doctrine_extensions.listener.translatable" for "stof/doctrine-extensions-bundle").
        # here you can provide a custom translatable listener service name.
        translatable_listener_service: Gedmo\Translatable\TranslatableListener

    knplabs:
        enabled: true

I checked if the service is enabled, this is the output:

$ php bin/console debug:container sonata_translation.admin.extension.knplabs_translatable

Information for Service "sonata_translation.admin.extension.knplabs_translatable"
=================================================================================

 ---------------- -----------------------------------------------------------------------------
  Option           Value
 ---------------- -----------------------------------------------------------------------------
  Service ID       sonata_translation.admin.extension.knplabs_translatable
  Class            Sonata\TranslationBundle\Admin\Extension\Knplabs\TranslatableAdminExtension
  Tags             sonata.admin.extension
  Public           no
  Synthetic        no
  Lazy             no
  Shared           yes
  Abstract         no
  Autowired        no
  Autoconfigured   no
 ---------------- -----------------------------------------------------------------------------


 ! [NOTE] The "sonata_translation.admin.extension.knplabs_translatable" service or alias has been removed
 !        or inlined when the container was compiled.

But how to make everything work - there are no ideas yet, unfortunately, my solution seems to work, but not always.

byhaskell avatar Aug 31 '22 09:08 byhaskell

Looking at your config, you enabled knplabs but the compiler only add extensions if the entity implements an interface provided or a extends a class provided. https://github.com/sonata-project/SonataTranslationBundle/blob/3.x/src/DependencyInjection/Compiler/AdminExtensionCompilerPass.php#L51-L56

You could dump inside the compiler to see what you get but I would say you get empty array so the extensions is never added.

it's kinda weird that

implements:
     - Knp\DoctrineBehaviors\Contract\Entity\TranslatableInterface

is not used by default.

VincentLanglet avatar Aug 31 '22 09:08 VincentLanglet

Good test, thanks. Unfortunately, I put a dump immediately after entering the method * and also near the array - nothing was displayed, and even using the write to a file to accurately check whether the method is being entered - also failed. Strange behavior..

byhaskell avatar Aug 31 '22 09:08 byhaskell