gediminasm преди 15 години
ревизия
f8129c0a66

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+tests/phpunit.xml
+build/

+ 0 - 0
README.markdown


+ 168 - 0
lib/DoctrineExtensions/Translatable/Entity/Translation.php

@@ -0,0 +1,168 @@
+<?php
+
+namespace DoctrineExtensions\Translatable\Entity;
+
+/**
+ * DoctrineExtensions\Translatable\Entity\Translation
+ *
+ * @Table(name="ext_translations", indexes={
+ *      @index(name="lookup_idx", columns={"locale", "entity", "foreign_key", "field"})
+ * })
+ * @Entity(repositoryClass="DoctrineExtensions\Translatable\Repository\TranslationRepository")
+ */
+class Translation
+{
+    /**
+     * @var integer $id
+     *
+     * @Column(name="id", type="integer")
+     * @Id
+     * @GeneratedValue(strategy="IDENTITY")
+     */
+    private $id;
+
+    /**
+     * @var string $locale
+     *
+     * @Column(name="locale", type="string", length=8)
+     */
+    private $locale;
+
+    /**
+     * @var string $entity
+     *
+     * @Column(name="entity", type="string", length=255)
+     */
+    private $entity;
+
+    /**
+     * @var string $field
+     *
+     * @Column(name="field", type="string", length=32)
+     */
+    private $field;
+
+    /**
+     * @var integer $foreignKey
+     *
+     * @Column(name="foreign_key", type="integer", nullable=true)
+     */
+    private $foreignKey;
+
+    /**
+     * @var text $content
+     *
+     * @Column(name="content", type="text", nullable=true)
+     */
+    private $content;
+    
+    /**
+     * Get id
+     *
+     * @return integer $id
+     */
+    public function getId()
+    {
+        return $this->id;
+    }
+
+    /**
+     * Set locale
+     *
+     * @param string $locale
+     */
+    public function setLocale($locale)
+    {
+        $this->locale = $locale;
+    }
+
+    /**
+     * Get locale
+     *
+     * @return string $locale
+     */
+    public function getLocale()
+    {
+        return $this->locale;
+    }
+
+    /**
+     * Set field
+     *
+     * @param string $field
+     */
+    public function setField($field)
+    {
+        $this->field = $field;
+    }
+
+    /**
+     * Get field
+     *
+     * @return string $field
+     */
+    public function getField()
+    {
+        return $this->field;
+    }
+
+    /**
+     * Set entity
+     *
+     * @param string $entity
+     */
+    public function setEntity($entity)
+    {
+        $this->entity = $entity;
+    }
+
+    /**
+     * Get entity
+     *
+     * @return string $entity
+     */
+    public function getEntity()
+    {
+        return $this->entity;
+    }
+    
+    /**
+     * Set foreignKey
+     *
+     * @param integer $foreignKey
+     */
+    public function setForeignKey($foreignKey)
+    {
+        $this->foreignKey = $foreignKey;
+    }
+
+    /**
+     * Get foreignKey
+     *
+     * @return integer $foreignKey
+     */
+    public function getForeignKey()
+    {
+        return $this->foreignKey;
+    }
+    
+    /**
+     * Set content
+     *
+     * @param text $content
+     */
+    public function setContent($content)
+    {
+        $this->content = $content;
+    }
+
+    /**
+     * Get content
+     *
+     * @return text $content
+     */
+    public function getContent()
+    {
+        return $this->content;
+    }
+}

+ 30 - 0
lib/DoctrineExtensions/Translatable/Exception.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace DoctrineExtensions\Translatable;
+
+/**
+ * The exception list for Translatable behavior
+ * 
+ * @author Gediminas Morkevicius <gediminas.morkevicius@gmail.com>
+ * @package DoctrineExtensions.Translatable
+ * @link http://www.gediminasm.org
+ * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+class TranslatableException extends \Exception
+{
+    static public function undefinedLocale()
+    {
+        return new self("Locale cannot be empty and must be set in Translatable\Listener or in the entity");
+    }
+
+    static public function singleIdentifierRequired($entityClass)
+    {
+        return new self("Only a single identifier column is required for the Translatable extension, entity: {$entityClass}.");
+    }
+    
+    static public function invalidIdentifierType($id)
+    {
+    	$type = gettype($id);
+        return new self("Currently there is only integer identifiers supported, [{$type}] is given.");
+    }
+}

+ 61 - 0
lib/DoctrineExtensions/Translatable/Repository/TranslationRepository.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace DoctrineExtensions\Translatable\Repository;
+
+use Doctrine\ORM\EntityRepository,
+    Doctrine\ORM\Query;
+
+/**
+ * The TranslationRepository has some useful functions
+ * to interact with translations.
+ * 
+ * @author Gediminas Morkevicius <gediminas.morkevicius@gmail.com>
+ * @package DoctrineExtensions.Translatable.Repository
+ * @link http://www.gediminasm.org
+ * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+class TranslationRepository extends EntityRepository
+{
+    public function findTranslation($foreignKey, $field, $locale, $entity)
+    {
+    	$qb = $this->createQueryBuilder('trans');
+    	$qb->where(
+    	    'trans.foreignKey = :foreignKey',
+            'trans.locale = :locale',
+            'trans.field = :field',
+            'trans.entity = :entity'
+    	);
+    	$q = $qb->getQuery();
+    	$result = $q->execute(
+            compact('field', 'locale', 'foreignKey', 'entity'),
+            Query::HYDRATE_OBJECT
+    	);
+    	if ($result && is_array($result) && count($result)) {
+    		return array_shift($result);
+    	}
+    	return null;
+    }
+    
+    public function findFieldTranslation($foreignKey, $field, $locale, $entity)
+    {
+    	$qb = $this->createQueryBuilder('trans');
+        $qb->select('trans.content')
+            ->where(
+            'trans.foreignKey = :foreignKey',
+            'trans.locale = :locale',
+            'trans.field = :field',
+            'trans.entity = :entity'
+        );
+
+        $q = $qb->getQuery();
+        $result = $q->execute(
+            compact('field', 'locale', 'foreignKey', 'entity'),
+            Query::HYDRATE_ARRAY
+        );
+        if ($result && is_array($result) && count($result)) {
+            $result = array_shift($result);
+            return $result['content'];
+        }
+        return null;
+    }
+}

+ 31 - 0
lib/DoctrineExtensions/Translatable/Translatable.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace DoctrineExtensions\Translatable;
+
+/**
+ * This interface must be implemented for all entities
+ * to active the Translatable behavior
+ * 
+ * @author Gediminas Morkevicius <gediminas.morkevicius@gmail.com>
+ * @package DoctrineExtensions.Translatable
+ * @link http://www.gediminasm.org
+ * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+interface Translatable
+{
+	/**
+	 * Specifies the fields which should be translated.
+	 * example: array('field1', 'field2')
+	 * 
+	 * @return array - list of translatable fields
+	 */
+	public function getTranslatableFields();
+	
+	/**
+	 * Specifies the locale to be used for translation.
+	 * example: en_us or lt_lt
+	 * 
+	 * @return string - locale to use
+	 */
+	public function getTranslatableLocale();
+}

+ 299 - 0
lib/DoctrineExtensions/Translatable/TranslationListener.php

@@ -0,0 +1,299 @@
+<?php
+
+namespace DoctrineExtensions\Translatable;
+
+use Doctrine\Common\EventSubscriber,
+    Doctrine\ORM\Events,
+    Doctrine\ORM\Event\LifecycleEventArgs,
+    Doctrine\ORM\Event\OnFlushEventArgs,
+    Doctrine\ORM\EntityManager,
+    Doctrine\ORM\Query,
+    DoctrineExtensions\Translatable\Entity\Translation;
+
+/**
+ * The translation listener handles the generation and
+ * loading of translations for entities which implements
+ * the Translatable interface.
+ * 
+ * This behavior can inpact the performance of your application
+ * since it does an additional query for each field to translate.
+ * 
+ * @author Gediminas Morkevicius <gediminas.morkevicius@gmail.com>
+ * @package DoctrineExtensions.Translatable
+ * @link http://www.gediminasm.org
+ * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+class TranslationListener implements EventSubscriber
+{
+	/**
+	 * The translation entity class used to store the translations
+	 */
+	const TRANSLATION_ENTITY_CLASS = 'DoctrineExtensions\Translatable\Entity\Translation';
+	
+	/**
+	 * Default locale which is set on this listener.
+	 * If Entity being translated has locale defined it
+	 * will override this one
+	 *  
+	 * @var string
+	 */
+	protected $_locale = 'en-us';
+	
+	/**
+	 * List of translations which do not have the foreign
+	 * key generated yet - MySQL case. These translations
+	 * will be updated with new keys on postPersist event
+	 * 
+	 * @var array
+	 */
+	protected $_pendingTranslations = array();
+	
+	/**
+	 * Specifies the events to listen
+	 * 
+	 * @return array - list of events to listen
+	 */
+    public function getSubscribedEvents()
+    {
+        return array(
+            Events::postLoad,
+            Events::postPersist,
+            Events::onFlush
+        );
+    }
+	
+    /**
+     * Set the locale to use for translation listener
+     * 
+     * @param string $locale
+     * @return void
+     */
+	public function setTranslatableLocale($locale)
+	{
+		$this->_locale = $locale;
+	}
+	
+	/**
+	 * Gets the locale to use for translation. Loads entity
+	 * defined locale first..
+	 * 
+	 * @param Translatable $entity - entity being translated
+	 * @return string - locale to use
+	 */
+	public function getTranslatableLocale(Translatable $entity)
+	{
+		return $entity->getTranslatableLocale() ?: $this->_locale;
+	}
+    
+	/**
+	 * Looks for translatable entities being inserted or updated
+	 * for further processing
+	 * 
+	 * @param OnFlushEventArgs $args
+	 * @return void
+	 */
+    public function onFlush(OnFlushEventArgs $args)
+    {
+        $em = $args->getEntityManager();
+        $uow = $em->getUnitOfWork();
+        // check all scheduled inserts for Translatable entities
+        foreach ($uow->getScheduledEntityInsertions() as $entity) {
+            if ($entity instanceof Translatable && count($entity->getTranslatableFields())) {
+                $this->_handleTranslatableEntityUpdate($em, $entity, true);
+            }
+        }
+        // check all scheduled updates for Translatable entities
+        foreach ($uow->getScheduledEntityUpdates() as $entity) {
+            if ($entity instanceof Translatable && count($entity->getTranslatableFields())) {
+                $this->_handleTranslatableEntityUpdate($em, $entity, false);
+            }
+        }
+    }
+    
+    /**
+     * Checks for inserted entities to update their translation
+     * foreign keys
+     * 
+     * @param LifecycleEventArgs $args
+     * @return void
+     */
+    public function postPersist(LifecycleEventArgs $args)
+    {
+        $em = $args->getEntityManager();
+        $entity = $args->getEntity();
+        // check if entity is Translatable and without foreign key
+        if ($entity instanceof Translatable && count($this->_pendingTranslations)) {
+        	$oid = spl_object_hash($entity);
+        	if (array_key_exists($oid, $this->_pendingTranslations)) {
+                // load the pending translation without key
+        		$translation = $this->_pendingTranslations[$oid];
+                // schedule an extra update for the foreign key
+                $uow = $em->getUnitOfWork();
+                $uow->scheduleExtraUpdate($translation, array(
+                    'foreignKey' => array(null, $entity->getId())
+                ));
+            }
+        }
+    }
+    
+    /**
+     * After entity is loaded, listener updates the translations
+     * by currently used locale
+     * 
+     * @param LifecycleEventArgs $args
+     * @throws TranslatableException if locale is not valid
+     * @return void
+     */
+    public function postLoad(LifecycleEventArgs $args)
+    {
+    	$em = $args->getEntityManager();
+    	$entity = $args->getEntity();
+    	
+    	$entityClass = get_class($entity);
+    	if ($entity instanceof Translatable && count($entity->getTranslatableFields())) {
+            $locale = strtolower($this->getTranslatableLocale($entity));
+	    	$this->_validateLocale($locale);
+            
+	    	// load translated content for all translatable fields
+            foreach ($entity->getTranslatableFields() as $field) {
+            	$content = $this->_findTranslation(
+            	    $em,
+            	    $entity->getId(),
+            	    $entityClass,
+                    $locale,
+            	    $field,
+            	    true
+            	);
+            	if ($content !== null) {
+            		$fnc = 'set' . ucfirst($field);
+            		$entity->$fnc($content);
+            	}
+            }	
+    	}
+    }
+    
+    /**
+     * Creates the translation for entity being flushed
+     * 
+     * @param EntityManager $em
+     * @param object $entity
+     * @param boolean $isInsert
+     * @throws TranslatableException if locale is not valid, or
+     *      primary key is composite, missing or invalid
+     * @return void
+     */
+    protected function _handleTranslatableEntityUpdate(EntityManager $em, $entity, $isInsert)
+    {
+    	$entityClass = get_class($entity);
+    	// no need cache, metadata is loaded only once in MetadataFactoryClass
+        $translationMetadata = $em->getClassMetadata(self::TRANSLATION_ENTITY_CLASS);
+        $entityClassMetadata = $em->getClassMetadata($entityClass);
+        
+        // check for the availability of the primary key
+        $entityId = $entityClassMetadata->getIdentifierValues($entity);
+        if (count($entityId) == 1 && current($entityId)) {
+            $entityId = current($entityId);
+        } elseif ($isInsert) {
+            $entityId = null;
+        } else {
+            throw TranslatableException::singleIdentifierRequired($entityClass);
+        }
+        
+        // @todo: add support for string type identifier also 
+        if (!is_int($entityId)) {
+        	throw TranslatableException::invalidIdentifierType($entityId);
+        }
+        
+        // load the currently used locale
+        $locale = strtolower($this->getTranslatableLocale($entity));
+        $this->_validateLocale($locale);
+
+        foreach ($entity->getTranslatableFields() as $field) {
+        	$translation = null;
+        	// check if translation allready is created
+        	if (!$isInsert) {
+                $translation = $this->_findTranslation(
+                    $em,
+                    $entityId,
+                    $entityClass,
+                    $locale,
+                    $field
+                );
+        	}
+            // create new translation
+            if (!$translation) {
+                $translation = new Translation;
+                $translation->setLocale($locale);
+	            $translation->setField($field);
+	            $translation->setEntity($entityClass);
+	            $translation->setForeignKey($entityId);
+            }
+            
+            // set the translated field, take value using getter
+            $fnc = 'get' . ucfirst($field);
+            $translation->setContent($entity->$fnc());
+            
+            // persist and compute change set for translation
+            $em->persist($translation);
+            $uow = $em->getUnitOfWork();
+            $uow->computeChangeSet($translationMetadata, $translation);
+            // if we do not have the primary key yet available
+            // keep this translation in memory for later update
+            if ($isInsert && is_null($entityId)) {
+            	$this->_pendingTranslations[spl_object_hash($entity)] = $translation;
+            }
+        }
+    }
+    
+    /**
+     * Search for existing translation record or
+     * it`s field translation only
+     * 
+     * @param EntityManager $em
+     * @param mixed $entityId
+     * @param string $entityClass
+     * @param string $locale
+     * @param string $field
+     * @param boolean $contentOnly - true if field translation only
+     * @return mixed - null if nothing is found
+     */
+    protected function _findTranslation(EntityManager $em, $entityId, $entityClass, $locale, $field, $contentOnly = false)
+    {
+        $qb = $em->createQueryBuilder();
+        $qb->select('trans')
+            ->from(self::TRANSLATION_ENTITY_CLASS, 'trans')
+            ->where(
+                'trans.foreignKey = :entityId',
+                'trans.locale = :locale',
+                'trans.field = :field',
+                'trans.entity = :entityClass'
+            );
+        $q = $qb->getQuery();
+        $result = $q->execute(
+            compact('field', 'locale', 'entityId', 'entityClass'),
+            $contentOnly ? Query::HYDRATE_ARRAY : Query::HYDRATE_OBJECT
+        );
+        if ($result && is_array($result) && count($result)) {
+            $result = array_shift($result);
+            if ($contentOnly) {
+            	$result = $result['content'];
+            }
+            return $result;
+        }
+        return null;
+    }
+    
+    /**
+     * Validates the given locale
+     * 
+     * @param string $locale - locale to validate
+     * @throws TranslatableException if locale is not valid
+     * @return void
+     */
+    protected function _validateLocale($locale)
+    {
+    	if (!strlen($locale)) {
+    		throw TranslatableException::undefinedLocale();
+    	}
+    }
+}