Przeglądaj źródła

[sluggable] relative slug handles for orm tree and simple relation

gediminasm 14 lat temu
rodzic
commit
80b051f945

+ 130 - 0
lib/Gedmo/Sluggable/Handler/InversedRelativeSlugHandler.php

@@ -0,0 +1,130 @@
+<?php
+
+namespace Gedmo\Sluggable\Handler;
+
+use Doctrine\Common\Persistence\ObjectManager;
+use Doctrine\Common\Persistence\Mapping\ClassMetadata;
+use Gedmo\Sluggable\SluggableListener;
+use Gedmo\Sluggable\Mapping\Event\SluggableAdapter;
+use Gedmo\Tool\Wrapper\AbstractWrapper;
+use Gedmo\Exception\InvalidMappingException;
+
+/**
+* Sluggable handler which should be used for inversed relation mapping
+* used together with RelativeSlugHandler. Updates back related slug on
+* relation changes
+*
+* @author Gediminas Morkevicius <gediminas.morkevicius@gmail.com>
+* @package Gedmo.Sluggable.Handler
+* @subpackage InversedRelativeSlugHandler
+* @link http://www.gediminasm.org
+* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+*/
+class InversedRelativeSlugHandler implements SlugHandlerInterface
+{
+    /**
+     * @var Doctrine\Common\Persistence\ObjectManager
+     */
+    protected $om;
+
+    /**
+     * @var Gedmo\Sluggable\SluggableListener
+     */
+    protected $sluggable;
+
+    /**
+     * Options for relative slug handler object
+     * classes
+     *
+     * @var array
+     */
+    private $options;
+
+    /**
+     * $options = array(
+     *     'relationClass' => 'objectclass',
+     *     'inverseSlugField' => 'slug',
+     *     'mappedBy' => 'relationField'
+     * )
+     * {@inheritDoc}
+     */
+    public function __construct(ObjectManager $om, SluggableListener $sluggable)
+    {
+        $this->om = $om;
+        $this->sluggable = $sluggable;
+    }
+
+    /**
+    * {@inheritDoc}
+    */
+    public function getOptions($object)
+    {
+        $meta = $this->om->getClassMetadata(get_class($object));
+        if (!isset($this->options[$meta->name])) {
+            $config = $this->sluggable->getConfiguration($this->om, $meta->name);
+            $this->options[$meta->name] = $config['handlers'][get_called_class()];
+        }
+        return $this->options[$meta->name];
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function postSlugBuild(SluggableAdapter $ea, array &$config, $object, &$slug)
+    {}
+
+    /**
+     * {@inheritDoc}
+     */
+    public static function validate(array $options, ClassMetadata $meta)
+    {}
+
+    /**
+     * {@inheritDoc}
+     */
+    public function onSlugCompletion(SluggableAdapter $ea, array &$config, $object, &$slug)
+    {
+        $isInsert = $this->om->getUnitOfWork()->isScheduledForInsert($object);
+        if (!$isInsert) {
+            $options = $this->getOptions($object);
+            $wrapped = AbstractWrapper::wrapp($object, $this->om);
+            $oldSlug = $wrapped->getPropertyValue($config['slug']);
+            $mappedByConfig = $this->sluggable->getConfiguration(
+                $this->om,
+                $options['relationClass']
+            );
+            if ($mappedByConfig) {
+                $meta = $this->om->getClassMetadata($options['relationClass']);
+                if (!$meta->isSingleValuedAssociation($options['mappedBy'])) {
+                    throw new InvalidMappingException("Unable to find ".$wrapped->getMetadata()->name." relation - [{$options['mappedBy']}] in class - {$meta->name}");
+                }
+                if (!isset($mappedByConfig['slugFields'][$options['inverseSlugField']])) {
+                    throw new InvalidMappingException("Unable to find slug field - [{$options['inverseSlugField']}] in class - {$meta->name}");
+                }
+                $mappedByConfig['slug'] = $mappedByConfig['slugFields'][$options['inverseSlugField']]['slug'];
+                $mappedByConfig['mappedBy'] = $options['mappedBy'];
+                $ea->replaceInverseRelative($object, $mappedByConfig, $slug, $oldSlug);
+                $uow = $this->om->getUnitOfWork();
+                // update in memory objects
+                foreach ($uow->getIdentityMap() as $className => $objects) {
+                    // for inheritance mapped classes, only root is always in the identity map
+                    if ($className !== $meta->rootEntityName) {
+                        continue;
+                    }
+                    foreach ($objects as $object) {
+                        if (property_exists($object, '__isInitialized__') && !$object->__isInitialized__) {
+                            continue;
+                        }
+                        $oid = spl_object_hash($object);
+                        $objectSlug = $meta->getReflectionProperty($mappedByConfig['slug'])->getValue($object);
+                        if (preg_match("@^{$oldSlug}@smi", $objectSlug)) {
+                            $objectSlug = str_replace($oldSlug, $slug, $objectSlug);
+                            $meta->getReflectionProperty($mappedByConfig['slug'])->setValue($object, $objectSlug);
+                            $ea->setOriginalObjectProperty($uow, $oid, $mappedByConfig['slug'], $objectSlug);
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 67 - 56
lib/Gedmo/Sluggable/Handler/RelativeSlugHandler.php

@@ -9,43 +9,75 @@ use Gedmo\Sluggable\Mapping\Event\SluggableAdapter;
 use Gedmo\Tool\Wrapper\AbstractWrapper;
 use Gedmo\Exception\InvalidMappingException;
 
+/**
+* Sluggable handler which should be used in order to prefix
+* a slug of related object. For instance user may belong to a company
+* in this case user slug could look like 'company-name/user-firstname'
+* where path separator separates the relative slug
+*
+* @author Gediminas Morkevicius <gediminas.morkevicius@gmail.com>
+* @package Gedmo.Sluggable.Handler
+* @subpackage RelativeSlugHandler
+* @link http://www.gediminasm.org
+* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+*/
 class RelativeSlugHandler implements SlugHandlerInterface
 {
     /**
      * @var Doctrine\Common\Persistence\ObjectManager
      */
-    private $om;
+    protected $om;
 
     /**
      * @var Gedmo\Sluggable\SluggableListener
      */
-    private $sluggable;
+    protected $sluggable;
 
     /**
-     * Options for relative slug handler
+     * Options for relative slug handler object
+     * classes
      *
      * @var array
      */
     private $options;
 
+    /**
+     * Callable of original transliterator
+     * which is used by sluggable
+     *
+     * @var callable
+     */
     private $originalTransliterator;
 
-    private $parts = array();
-
-    private $isInsert = false;
-
     /**
+     * $options = array(
+     *     'separator' => '/',
+     *     'relationField' => 'something',
+     *     'relativeSlugField' => 'slug'
+     * )
      * {@inheritDoc}
      */
-    public function __construct(ObjectManager $om, SluggableListener $sluggable, array $options)
+    public function __construct(ObjectManager $om, SluggableListener $sluggable)
     {
         $this->om = $om;
         $this->sluggable = $sluggable;
-        $default = array(
-            'recursive' => true,
-            'separator' => '/'
-        );
-        $this->options = array_merge($default, $options);
+    }
+
+    /**
+    * {@inheritDoc}
+    */
+    public function getOptions($object)
+    {
+        $meta = $this->om->getClassMetadata(get_class($object));
+        if (!isset($this->options[$meta->name])) {
+            $config = $this->sluggable->getConfiguration($this->om, $meta->name);
+            $options = $config['handlers'][get_called_class()];
+            $default = array(
+                'separator' => '/'
+            );
+            $this->options[$meta->name] = array_merge($default, $options);
+        }
+        return $this->options[$meta->name];
     }
 
     /**
@@ -55,22 +87,6 @@ class RelativeSlugHandler implements SlugHandlerInterface
     {
         $this->originalTransliterator = $this->sluggable->getTransliterator();
         $this->sluggable->setTransliterator(array($this, 'transliterate'));
-        $this->parts = array();
-        $this->isInsert = $this->om->getUnitOfWork()->isScheduledForInsert($object);
-
-        $wrapped = AbstractWrapper::wrapp($object, $this->om);
-        if ($this->isInsert) {
-            do {
-                $relation = $wrapped->getPropertyValue($this->options['relation']);
-                if ($relation) {
-                    $wrappedRelation = AbstractWrapper::wrapp($relation, $this->om);
-                    array_unshift($this->parts, $wrappedRelation->getPropertyValue($this->options['targetField']));
-                    $wrapped = $wrappedRelation;
-                }
-            } while ($this->options['recursive'] && $relation);
-        } else {
-            $this->parts = explode($this->options['separator'], $wrapped->getPropertyValue($config['slug']));
-        }
     }
 
     /**
@@ -78,8 +94,8 @@ class RelativeSlugHandler implements SlugHandlerInterface
      */
     public static function validate(array $options, ClassMetadata $meta)
     {
-        if (!$meta->isSingleValuedAssociation($options['relation'])) {
-            throw new InvalidMappingException("Unable to find slug relation through field - [{$options['relation']}] in class - {$meta->name}");
+        if (!$meta->isSingleValuedAssociation($options['relationField'])) {
+            throw new InvalidMappingException("Unable to find slug relation through field - [{$options['relationField']}] in class - {$meta->name}");
         }
         /*if (!$meta->isSingleValuedAssociation($options['relation'])) {
             throw new InvalidMappingException("Unable to find slug relation through field - [{$options['relation']}] in class - {$meta->name}");
@@ -90,37 +106,32 @@ class RelativeSlugHandler implements SlugHandlerInterface
      * {@inheritDoc}
      */
     public function onSlugCompletion(SluggableAdapter $ea, array &$config, $object, &$slug)
-    {
-        if (!$this->isInsert) {
-            $wrapped = AbstractWrapper::wrapp($object, $this->om);
-            $extConfig = $this->sluggable->getConfiguration($this->om, $wrapped->getMetadata()->name);
-            $config['useObjectClass'] = $extConfig['useObjectClass'];
-            $ea->replaceRelative(
-                $object,
-                $config,
-                $wrapped->getPropertyValue($config['slug']).$this->options['separator'],
-                $slug
-            );
-        }
-    }
+    {}
 
+    /**
+     * Transliterates the slug and prefixes the slug
+     * by relative one
+     *
+     * @param string $text
+     * @param string $separator
+     * @param object $object
+     * @return string
+     */
     public function transliterate($text, $separator, $object)
     {
-        if ($this->isInsert) {
-            foreach ($this->parts as &$part) {
-                $part = call_user_func_array(
-                    $this->originalTransliterator,
-                    array($part, $separator, $object)
-                );
-            }
-        } else {
-            array_pop($this->parts);
-        }
-        $this->parts[] = call_user_func_array(
+        $options = $this->getOptions($object);
+        $result = call_user_func_array(
             $this->originalTransliterator,
             array($text, $separator, $object)
         );
+        $wrapped = AbstractWrapper::wrapp($object, $this->om);
+        $relation = $wrapped->getPropertyValue($options['relationField']);
+        if ($relation) {
+            $wrappedRelation = AbstractWrapper::wrapp($relation, $this->om);
+            $slug = $wrappedRelation->getPropertyValue($options['relativeSlugField']);
+            $result = $slug . $options['separator'] . $result;
+        }
         $this->sluggable->setTransliterator($this->originalTransliterator);
-        return implode($this->options['separator'], $this->parts);
+        return $result;
     }
 }

+ 21 - 2
lib/Gedmo/Sluggable/Handler/SlugHandlerInterface.php

@@ -7,6 +7,18 @@ use Doctrine\Common\Persistence\Mapping\ClassMetadata;
 use Gedmo\Sluggable\SluggableListener;
 use Gedmo\Sluggable\Mapping\Event\SluggableAdapter;
 
+/**
+* Sluggable handler interface is a common pattern for all
+* slug handlers which can be attached to the sluggable listener.
+* Usage is intented only for internal access of sluggable.
+* Should not be used outside of sluggable extension
+*
+* @author Gediminas Morkevicius <gediminas.morkevicius@gmail.com>
+* @package Gedmo.Sluggable.Handler
+* @subpackage SlugHandlerInterface
+* @link http://www.gediminasm.org
+* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+*/
 interface SlugHandlerInterface
 {
     /**
@@ -14,9 +26,16 @@ interface SlugHandlerInterface
      *
      * @param Doctrine\Common\Persistence\ObjectManager $om
      * @param Gedmo\Sluggable\SluggableListener $sluggable
-     * @param array $options
      */
-    function __construct(ObjectManager $om, SluggableListener $sluggable, array $options);
+    function __construct(ObjectManager $om, SluggableListener $sluggable);
+
+    /**
+     * Get the options for specific object
+     *
+     * @param object $object
+     * @return array
+     */
+    function getOptions($object);
 
     /**
      * Callback on slug handlers right after the slug is built

+ 206 - 0
lib/Gedmo/Sluggable/Handler/TreeSlugHandler.php

@@ -0,0 +1,206 @@
+<?php
+
+namespace Gedmo\Sluggable\Handler;
+
+use Doctrine\Common\Persistence\ObjectManager;
+use Doctrine\Common\Persistence\Mapping\ClassMetadata;
+use Gedmo\Sluggable\SluggableListener;
+use Gedmo\Sluggable\Mapping\Event\SluggableAdapter;
+use Gedmo\Tool\Wrapper\AbstractWrapper;
+use Gedmo\Exception\InvalidMappingException;
+
+/**
+* Sluggable handler which slugs all parent nodes
+* recursively and synchronizes on updates. For instance
+* category tree slug could look like "food/fruits/apples"
+*
+* @author Gediminas Morkevicius <gediminas.morkevicius@gmail.com>
+* @package Gedmo.Sluggable.Handler
+* @subpackage TreeSlugHandler
+* @link http://www.gediminasm.org
+* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+*/
+class TreeSlugHandler implements SlugHandlerInterface
+{
+    /**
+     * @var Doctrine\Common\Persistence\ObjectManager
+     */
+    protected $om;
+
+    /**
+     * @var Gedmo\Sluggable\SluggableListener
+     */
+    protected $sluggable;
+
+    /**
+     * Options for relative slug handler object
+     * classes
+     *
+     * @var array
+     */
+    private $options;
+
+    /**
+     * Callable of original transliterator
+     * which is used by sluggable
+     *
+     * @var callable
+     */
+    private $originalTransliterator;
+
+    /**
+     * List of node slugs to transliterate
+     *
+     * @var array
+     */
+    private $parts = array();
+
+    /**
+     * True if node is being inserted
+     *
+     * @var boolean
+     */
+    private $isInsert = false;
+
+    /**
+     * Used separator for slugs
+     *
+     * @var string
+     */
+    private $usedSeparator;
+
+    /**
+     * {@inheritDoc}
+     */
+    public function __construct(ObjectManager $om, SluggableListener $sluggable)
+    {
+        $this->om = $om;
+        $this->sluggable = $sluggable;
+    }
+
+    /**
+     * $options = array(
+     *     'separator' => '/',
+     *     'parentRelation' => 'parent',
+     *     'targetField' => 'title'
+     * )
+     * {@inheritDoc}
+     */
+    public function getOptions($object)
+    {
+        $meta = $this->om->getClassMetadata(get_class($object));
+        if (!isset($this->options[$meta->name])) {
+            $config = $this->sluggable->getConfiguration($this->om, $meta->name);
+            $options = $config['handlers'][get_called_class()];
+            $default = array(
+                'separator' => '/'
+            );
+            $this->options[$meta->name] = array_merge($default, $options);
+        }
+        return $this->options[$meta->name];
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function postSlugBuild(SluggableAdapter $ea, array &$config, $object, &$slug)
+    {
+        $options = $this->getOptions($object);
+        $this->originalTransliterator = $this->sluggable->getTransliterator();
+        $this->sluggable->setTransliterator(array($this, 'transliterate'));
+        $this->parts = array();
+        $this->isInsert = $this->om->getUnitOfWork()->isScheduledForInsert($object);
+
+        $wrapped = AbstractWrapper::wrapp($object, $this->om);
+        if ($this->isInsert) {
+            do {
+                $relation = $wrapped->getPropertyValue($options['parentRelation']);
+                if ($relation) {
+                    $wrappedRelation = AbstractWrapper::wrapp($relation, $this->om);
+                    array_unshift($this->parts, $wrappedRelation->getPropertyValue($options['targetField']));
+                    $wrapped = $wrappedRelation;
+                }
+            } while ($relation);
+        } else {
+            $this->parts = explode($options['separator'], $wrapped->getPropertyValue($config['slug']));
+        }
+        $this->usedSeparator = $options['separator'];
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public static function validate(array $options, ClassMetadata $meta)
+    {
+        if (!$meta->isSingleValuedAssociation($options['parentRelation'])) {
+            throw new InvalidMappingException("Unable to find tree parent slug relation through field - [{$options['parentRelation']}] in class - {$meta->name}");
+        }
+        /*if (!$meta->isSingleValuedAssociation($options['relation'])) {
+            throw new InvalidMappingException("Unable to find slug relation through field - [{$options['relation']}] in class - {$meta->name}");
+        }*/
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function onSlugCompletion(SluggableAdapter $ea, array &$config, $object, &$slug)
+    {
+        if (!$this->isInsert) {
+            $wrapped = AbstractWrapper::wrapp($object, $this->om);
+            $meta = $wrapped->getMetadata();
+            $extConfig = $this->sluggable->getConfiguration($this->om, $meta->name);
+            $config['useObjectClass'] = $extConfig['useObjectClass'];
+            $target = $wrapped->getPropertyValue($config['slug']);
+            $ea->replaceRelative($object, $config, $target.$this->usedSeparator, $slug);
+            $uow = $this->om->getUnitOfWork();
+            // update in memory objects
+            foreach ($uow->getIdentityMap() as $className => $objects) {
+                // for inheritance mapped classes, only root is always in the identity map
+                if ($className !== $meta->rootEntityName) {
+                    continue;
+                }
+                foreach ($objects as $object) {
+                    if (property_exists($object, '__isInitialized__') && !$object->__isInitialized__) {
+                        continue;
+                    }
+                    $oid = spl_object_hash($object);
+                    $objectSlug = $meta->getReflectionProperty($config['slug'])->getValue($object);
+                    if (preg_match("@^{$target}{$this->usedSeparator}@smi", $objectSlug)) {
+                        $objectSlug = str_replace($target, $slug, $objectSlug);
+                        $meta->getReflectionProperty($config['slug'])->setValue($object, $objectSlug);
+                        $ea->setOriginalObjectProperty($uow, $oid, $config['slug'], $objectSlug);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Transliterates the slug and prefixes the slug
+     * by collection of parent slugs
+     *
+     * @param string $text
+     * @param string $separator
+     * @param object $object
+     * @return string
+     */
+    public function transliterate($text, $separator, $object)
+    {
+        if ($this->isInsert) {
+            foreach ($this->parts as &$part) {
+                $part = call_user_func_array(
+                    $this->originalTransliterator,
+                    array($part, $separator, $object)
+                );
+            }
+        } else {
+            array_pop($this->parts);
+        }
+        $this->parts[] = call_user_func_array(
+            $this->originalTransliterator,
+            array($text, $separator, $object)
+        );
+        $this->sluggable->setTransliterator($this->originalTransliterator);
+        return implode($this->usedSeparator, $this->parts);
+    }
+}

+ 26 - 0
lib/Gedmo/Sluggable/Mapping/Event/Adapter/ORM.php

@@ -49,6 +49,9 @@ final class ORM extends BaseAdapterORM implements SluggableAdapter
         return $q->execute();
     }
 
+    /**
+     * {@inheritDoc}
+     */
     public function replaceRelative($object, array $config, $target, $replacement)
     {
         $em = $this->getObjectManager();
@@ -67,4 +70,27 @@ final class ORM extends BaseAdapterORM implements SluggableAdapter
         $q = $qb->getQuery();
         return $q->execute();
     }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function replaceInverseRelative($object, array $config, $target, $replacement)
+    {
+        $em = $this->getObjectManager();
+        $qb = $em->createQueryBuilder();
+        $qb->update($config['useObjectClass'], 'rec')
+            ->set('rec.'.$config['slug'], $qb->expr()->concat(
+                $qb->expr()->literal($replacement),
+                $qb->expr()->substring('rec.'.$config['slug'], strlen($target)+1)
+            ))
+            ->where('rec.'.$config['mappedBy'].' = :object')
+            ->andWhere($qb->expr()->like(
+                'rec.'.$config['slug'],
+                $qb->expr()->literal($target . '%'))
+            )
+        ;
+        $q = $qb->getQuery();
+        $q->setParameters(compact('object'));
+        return $q->execute();
+    }
 }

+ 25 - 0
lib/Gedmo/Sluggable/Mapping/Event/SluggableAdapter.php

@@ -27,4 +27,29 @@ interface SluggableAdapter extends AdapterInterface
      * @return array
      */
     function getSimilarSlugs($object, ClassMetadata $meta, array $config, $slug);
+
+    /**
+     * Replace part of slug to all objects
+     * matching $target pattern
+     *
+     * @param object $object
+     * @param array $config
+     * @param string $target
+     * @param string $replacement
+     * @return integer
+     */
+    function replaceRelative($object, array $config, $target, $replacement);
+
+    /**
+    * Replace part of slug to all objects
+    * matching $target pattern and having $object
+    * related
+    *
+    * @param object $object
+    * @param array $config
+    * @param string $target
+    * @param string $replacement
+    * @return integer
+    */
+    function replaceInverseRelative($object, array $config, $target, $replacement);
 }

+ 4 - 4
lib/Gedmo/Sluggable/SluggableListener.php

@@ -152,10 +152,10 @@ class SluggableListener extends MappedEventSubscriber
         return __NAMESPACE__;
     }
 
-    private function getHandler($class, ObjectManager $om, array $options)
+    private function getHandler($class, ObjectManager $om)
     {
         if (!isset($this->handlers[$class])) {
-            $this->handlers[$class] = new $class($om, $this, $options);
+            $this->handlers[$class] = new $class($om, $this);
         }
         return $this->handlers[$class];
     }
@@ -206,7 +206,7 @@ class SluggableListener extends MappedEventSubscriber
                 if (isset($config['handlers'])) {
                     foreach ($config['handlers'] as $class => $options) {
                         $this
-                            ->getHandler($class, $om, $options)
+                            ->getHandler($class, $om)
                             ->postSlugBuild($ea, $slugFieldConfig, $object, $slug)
                         ;
                     }
@@ -249,7 +249,7 @@ class SluggableListener extends MappedEventSubscriber
                 if (isset($config['handlers'])) {
                     foreach ($config['handlers'] as $class => $options) {
                         $this
-                            ->getHandler($class, $om, $options)
+                            ->getHandler($class, $om)
                             ->onSlugCompletion($ea, $slugFieldConfig, $object, $slug)
                         ;
                     }

+ 7 - 1
tests/Gedmo/Sluggable/Fixture/Article.php

@@ -27,7 +27,13 @@ class Article implements Sluggable
     private $code;
 
     /**
-     * @Gedmo\Slug
+     * @Gedmo\Slug(handlers={
+     *      @Gedmo\SlugHandler(class="Gedmo\Sluggable\Handler\InversedRelativeSlugHandler", options={
+     *          @Gedmo\SlugHandlerOption(name="relationClass", value="Sluggable\Fixture\ArticleRelativeSlug"),
+     *          @Gedmo\SlugHandlerOption(name="mappedBy", value="article"),
+     *          @Gedmo\SlugHandlerOption(name="inverseSlugField", value="slug")
+     *      })
+     * }, separator="-", updatable=true)
      * @ORM\Column(name="slug", type="string", length=64, unique=true)
      */
     private $slug;

+ 72 - 0
tests/Gedmo/Sluggable/Fixture/ArticleRelativeSlug.php

@@ -0,0 +1,72 @@
+<?php
+
+namespace Sluggable\Fixture;
+
+use Gedmo\Mapping\Annotation as Gedmo;
+use Doctrine\ORM\Mapping as ORM;
+
+/**
+ * @ORM\Entity
+ */
+class ArticleRelativeSlug
+{
+    /**
+     * @ORM\Id
+     * @ORM\GeneratedValue
+     * @ORM\Column(type="integer")
+     */
+    private $id;
+
+    /**
+     * @Gedmo\Sluggable
+     * @ORM\Column(length=64)
+     */
+    private $title;
+
+    /**
+     * @Gedmo\Slug(handlers={
+     *      @Gedmo\SlugHandler(class="Gedmo\Sluggable\Handler\RelativeSlugHandler", options={
+     *          @Gedmo\SlugHandlerOption(name="relationField", value="article"),
+     *          @Gedmo\SlugHandlerOption(name="relativeSlugField", value="slug"),
+     *          @Gedmo\SlugHandlerOption(name="separator", value="/")
+     *      })
+     * }, separator="-", updatable=true)
+     * @ORM\Column(name="slug", type="string", length=64, unique=true)
+     */
+    private $slug;
+
+    /**
+     * @ORM\ManyToOne(targetEntity="Article")
+     */
+    private $article;
+
+    public function setArticle(Article $article = null)
+    {
+        $this->article = $article;
+    }
+
+    public function getArticle()
+    {
+        return $this->article;
+    }
+
+    public function getId()
+    {
+        return $this->id;
+    }
+
+    public function setTitle($title)
+    {
+        $this->title = $title;
+    }
+
+    public function getTitle()
+    {
+        return $this->title;
+    }
+
+    public function getSlug()
+    {
+        return $this->slug;
+    }
+}

+ 5 - 5
tests/Gedmo/Sluggable/Fixture/RelativeSlug.php

@@ -9,7 +9,7 @@ use Doctrine\ORM\Mapping as ORM;
  * @Gedmo\Tree(type="nested")
  * @ORM\Entity(repositoryClass="Gedmo\Tree\Entity\Repository\NestedTreeRepository")
  */
-class RelativeSlug
+class TreeSlug
 {
     /**
      * @ORM\Id
@@ -26,8 +26,8 @@ class RelativeSlug
 
     /**
      * @Gedmo\Slug(handlers={
-     *      @Gedmo\SlugHandler(class="Gedmo\Sluggable\Handler\RelativeSlugHandler", options={
-     *          @Gedmo\SlugHandlerOption(name="relation", value="parent"),
+     *      @Gedmo\SlugHandler(class="Gedmo\Sluggable\Handler\TreeSlugHandler", options={
+     *          @Gedmo\SlugHandlerOption(name="parentRelation", value="parent"),
      *          @Gedmo\SlugHandlerOption(name="targetField", value="title"),
      *          @Gedmo\SlugHandlerOption(name="separator", value="/")
      *      })
@@ -38,7 +38,7 @@ class RelativeSlug
 
     /**
      * @Gedmo\TreeParent
-     * @ORM\ManyToOne(targetEntity="RelativeSlug")
+     * @ORM\ManyToOne(targetEntity="TreeSlug")
      * @ORM\JoinColumn(name="parent_id", referencedColumnName="id", onDelete="SET NULL")
      */
     private $parent;
@@ -67,7 +67,7 @@ class RelativeSlug
      */
     private $level;
 
-    public function setParent($parent = null)
+    public function setParent(TreeSlug $parent = null)
     {
         $this->parent = $parent;
     }

+ 61 - 51
tests/Gedmo/Sluggable/RelativeSlugHandlerTest.php

@@ -4,8 +4,8 @@ namespace Gedmo\Sluggable;
 
 use Doctrine\Common\EventManager;
 use Tool\BaseTestCaseORM;
-use Sluggable\Fixture\RelativeSlug;
-use Gedmo\Tree\TreeListener;
+use Sluggable\Fixture\Article;
+use Sluggable\Fixture\ArticleRelativeSlug;
 
 /**
  * These are tests for Sluggable behavior
@@ -17,7 +17,8 @@ use Gedmo\Tree\TreeListener;
  */
 class RelativeSlugHandlerTest extends BaseTestCaseORM
 {
-    const TARGET = "Sluggable\\Fixture\\RelativeSlug";
+    const SLUG = "Sluggable\\Fixture\\ArticleRelativeSlug";
+    const ARTICLE = "Sluggable\\Fixture\\Article";
 
     protected function setUp()
     {
@@ -25,12 +26,11 @@ class RelativeSlugHandlerTest extends BaseTestCaseORM
 
         $evm = new EventManager;
         $evm->addEventSubscriber(new SluggableListener);
-        $evm->addEventSubscriber(new TreeListener);
 
         $conn = array(
             'driver' => 'pdo_mysql',
             'host' => '127.0.0.1',
-            'dbname' => 'test',
+            'dbname' => 'tests',
             'user' => 'root',
             'password' => 'nimda'
         );
@@ -41,73 +41,83 @@ class RelativeSlugHandlerTest extends BaseTestCaseORM
     public function testSlugGeneration()
     {
         $this->populate();
-        $repo = $this->em->getRepository(self::TARGET);
+        $repo = $this->em->getRepository(self::SLUG);
 
-        $food = $repo->findOneByTitle('Food');
-        $this->assertEquals('food', $food->getSlug());
+        $thomas = $repo->findOneByTitle('Thomas');
+        $this->assertEquals('sport-test/thomas', $thomas->getSlug());
 
-        $fruits = $repo->findOneByTitle('Fruits');
-        $this->assertEquals('food/fruits', $fruits->getSlug());
+        $jen = $repo->findOneByTitle('Jen');
+        $this->assertEquals('sport-test/jen', $jen->getSlug());
 
-        $oranges = $repo->findOneByTitle('Oranges');
-        $this->assertEquals('food/fruits/oranges', $oranges->getSlug());
+        $john = $repo->findOneByTitle('John');
+        $this->assertEquals('cars-code/john', $john->getSlug());
 
-        $citrons = $repo->findOneByTitle('Citrons');
-        $this->assertEquals('food/fruits/citrons', $citrons->getSlug());
+        $single = $repo->findOneByTitle('Single');
+        $this->assertEquals('single', $single->getSlug());
     }
 
-    public function testSlugUpdates()
+    public function testUpdateOperations()
     {
         $this->populate();
-        $repo = $this->em->getRepository(self::TARGET);
+        $repo = $this->em->getRepository(self::SLUG);
 
-        $fruits = $repo->findOneByTitle('Fruits');
-        $fruits->setTitle('Fructis');
+        $thomas = $repo->findOneByTitle('Thomas');
+        $thomas->setTitle('Ninja');
+        $this->em->persist($thomas);
+        $this->em->flush();
+
+        $this->assertEquals('sport-test/ninja', $thomas->getSlug());
 
-        $this->em->persist($fruits);
+        $sport = $this->em->getRepository(self::ARTICLE)->findOneByTitle('Sport');
+        $sport->setTitle('Martial Arts');
+
+        $this->em->persist($sport);
         $this->em->flush();
+
+        $this->assertEquals('martial-arts-test/ninja', $thomas->getSlug());
+
+        $jen = $repo->findOneByTitle('Jen');
+        $this->assertEquals('martial-arts-test/jen', $jen->getSlug());
     }
 
     protected function getUsedEntityFixtures()
     {
         return array(
-            self::TARGET
+            self::SLUG,
+            self::ARTICLE
         );
     }
 
     private function populate()
     {
-        $repo = $this->em->getRepository(self::TARGET);
-
-        $food = new RelativeSlug;
-        $food->setTitle('Food');
-
-        $fruits = new RelativeSlug;
-        $fruits->setTitle('Fruits');
-
-        $vegitables = new RelativeSlug;
-        $vegitables->setTitle('Vegitables');
-
-        $milk = new RelativeSlug;
-        $milk->setTitle('Milk');
-
-        $meat = new RelativeSlug;
-        $meat->setTitle('Meat');
-
-        $oranges = new RelativeSlug;
-        $oranges->setTitle('Oranges');
-
-        $citrons = new RelativeSlug;
-        $citrons->setTitle('Citrons');
-
-        $repo
-            ->persistAsFirstChild($food)
-            ->persistAsFirstChildOf($fruits, $food)
-            ->persistAsFirstChildOf($vegitables, $food)
-            ->persistAsLastChildOf($milk, $food)
-            ->persistAsLastChildOf($meat, $food)
-            ->persistAsFirstChildOf($oranges, $fruits)
-            ->persistAsFirstChildOf($citrons, $fruits);
+        $sport = new Article;
+        $sport->setTitle('Sport');
+        $sport->setCode('test');
+        $this->em->persist($sport);
+
+        $cars = new Article;
+        $cars->setTitle('Cars');
+        $cars->setCode('code');
+        $this->em->persist($cars);
+
+        $thomas = new ArticleRelativeSlug;
+        $thomas->setTitle('Thomas');
+        $thomas->setArticle($sport);
+        $this->em->persist($thomas);
+
+        $jen = new ArticleRelativeSlug;
+        $jen->setTitle('Jen');
+        $jen->setArticle($sport);
+        $this->em->persist($jen);
+
+        $john = new ArticleRelativeSlug;
+        $john->setTitle('John');
+        $john->setArticle($cars);
+        $this->em->persist($john);
+
+        $single = new ArticleRelativeSlug;
+        $single->setTitle('Single');
+        $this->em->persist($single);
 
         $this->em->flush();
     }

+ 132 - 0
tests/Gedmo/Sluggable/TreeSlugHandlerTest.php

@@ -0,0 +1,132 @@
+<?php
+
+namespace Gedmo\Sluggable;
+
+use Doctrine\Common\EventManager;
+use Tool\BaseTestCaseORM;
+use Sluggable\Fixture\TreeSlug;
+use Gedmo\Tree\TreeListener;
+
+/**
+ * These are tests for Sluggable behavior
+ *
+ * @author Gediminas Morkevicius <gediminas.morkevicius@gmail.com>
+ * @package Gedmo.Sluggable
+ * @link http://www.gediminasm.org
+ * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+class TreeSlugHandlerTest extends BaseTestCaseORM
+{
+    const TARGET = "Sluggable\\Fixture\\TreeSlug";
+
+    protected function setUp()
+    {
+        parent::setUp();
+
+        $evm = new EventManager;
+        $evm->addEventSubscriber(new SluggableListener);
+        $evm->addEventSubscriber(new TreeListener);
+
+        $conn = array(
+            'driver' => 'pdo_mysql',
+            'host' => '127.0.0.1',
+            'dbname' => 'tests',
+            'user' => 'root',
+            'password' => 'nimda'
+        );
+        //$this->getMockCustomEntityManager($conn, $evm);
+        $this->getMockSqliteEntityManager($evm);
+    }
+
+    public function testSlugGeneration()
+    {
+        $this->populate();
+        $repo = $this->em->getRepository(self::TARGET);
+
+        $food = $repo->findOneByTitle('Food');
+        $this->assertEquals('food', $food->getSlug());
+
+        $fruits = $repo->findOneByTitle('Fruits');
+        $this->assertEquals('food/fruits', $fruits->getSlug());
+
+        $oranges = $repo->findOneByTitle('Oranges');
+        $this->assertEquals('food/fruits/oranges', $oranges->getSlug());
+
+        $citrons = $repo->findOneByTitle('Citrons');
+        $this->assertEquals('food/fruits/citrons', $citrons->getSlug());
+    }
+
+    public function testSlugUpdates()
+    {
+        $this->populate();
+        $repo = $this->em->getRepository(self::TARGET);
+
+        $fruits = $repo->findOneByTitle('Fruits');
+        $fruits->setTitle('Fructis');
+
+        $this->em->persist($fruits);
+        $this->em->flush();
+
+        $this->assertEquals('food/fructis', $fruits->getSlug());
+
+        $oranges = $repo->findOneByTitle('Oranges');
+        $this->assertEquals('food/fructis/oranges', $oranges->getSlug());
+
+        $citrons = $repo->findOneByTitle('Citrons');
+        $this->assertEquals('food/fructis/citrons', $citrons->getSlug());
+
+        $food = $repo->findOneByTitle('Food');
+        $food->setTitle('Foodissimo');
+
+        $this->em->persist($food);
+        $this->em->flush();
+
+        $this->assertEquals('foodissimo', $food->getSlug());
+        $this->assertEquals('foodissimo/fructis/oranges', $oranges->getSlug());
+        $this->assertEquals('foodissimo/fructis/citrons', $citrons->getSlug());
+    }
+
+    protected function getUsedEntityFixtures()
+    {
+        return array(
+            self::TARGET
+        );
+    }
+
+    private function populate()
+    {
+        $repo = $this->em->getRepository(self::TARGET);
+
+        $food = new TreeSlug;
+        $food->setTitle('Food');
+
+        $fruits = new TreeSlug;
+        $fruits->setTitle('Fruits');
+
+        $vegitables = new TreeSlug;
+        $vegitables->setTitle('Vegitables');
+
+        $milk = new TreeSlug;
+        $milk->setTitle('Milk');
+
+        $meat = new TreeSlug;
+        $meat->setTitle('Meat');
+
+        $oranges = new TreeSlug;
+        $oranges->setTitle('Oranges');
+
+        $citrons = new TreeSlug;
+        $citrons->setTitle('Citrons');
+
+        $repo
+            ->persistAsFirstChild($food)
+            ->persistAsFirstChildOf($fruits, $food)
+            ->persistAsFirstChildOf($vegitables, $food)
+            ->persistAsLastChildOf($milk, $food)
+            ->persistAsLastChildOf($meat, $food)
+            ->persistAsFirstChildOf($oranges, $fruits)
+            ->persistAsFirstChildOf($citrons, $fruits);
+
+        $this->em->flush();
+    }
+}