瀏覽代碼

[sluggable] allow to force and regenerate slug

gedi 13 年之前
父節點
當前提交
4f69bdd856

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

@@ -1,132 +0,0 @@
-<?php
-
-namespace Gedmo\Sluggable\Handler;
-
-use Doctrine\Common\Persistence\ObjectManager;
-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 = array(
-     *     'relationClass' => 'objectclass',
-     *     'inverseSlugField' => 'slug',
-     *     'mappedBy' => 'relationField'
-     * )
-     * {@inheritDoc}
-     */
-    public function __construct(SluggableListener $sluggable)
-    {
-        $this->sluggable = $sluggable;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function onChangeDecision(SluggableAdapter $ea, $slugFieldConfig, $object, &$slug, &$needToChangeSlug)
-    {}
-
-    /**
-     * {@inheritDoc}
-     */
-    public function postSlugBuild(SluggableAdapter $ea, array &$config, $object, &$slug)
-    {}
-
-    /**
-     * {@inheritDoc}
-     */
-    public static function validate(array $options, $meta)
-    {
-        if (!isset($options['relationClass']) || !strlen($options['relationClass'])) {
-            throw new InvalidMappingException("'relationClass' option must be specified for object slug mapping - {$meta->name}");
-        }
-        if (!isset($options['mappedBy']) || !strlen($options['mappedBy'])) {
-            throw new InvalidMappingException("'mappedBy' option must be specified for object slug mapping - {$meta->name}");
-        }
-        if (!isset($options['inverseSlugField']) || !strlen($options['inverseSlugField'])) {
-            throw new InvalidMappingException("'inverseSlugField' option must be specified for object slug mapping - {$meta->name}");
-        }
-    }
-    
-    /**
-     * {@inheritDoc}
-     */
-    public function handlesUrlization()
-    {
-        return false;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function onSlugCompletion(SluggableAdapter $ea, array &$config, $object, &$slug)
-    {
-        $this->om = $ea->getObjectManager();
-        $isInsert = $this->om->getUnitOfWork()->isScheduledForInsert($object);
-        if (!$isInsert) {
-            $options = $config['handlers'][get_called_class()];
-            $wrapped = AbstractWrapper::wrap($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['slugs'][$options['inverseSlugField']])) {
-                    throw new InvalidMappingException("Unable to find slug field - [{$options['inverseSlugField']}] in class - {$meta->name}");
-                }
-                $mappedByConfig['slug'] = $mappedByConfig['slugs'][$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 !== $mappedByConfig['useObjectClass']) {
-                        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);
-                        }
-                    }
-                }
-            }
-        }
-    }
-}

+ 0 - 161
lib/Gedmo/Sluggable/Handler/RelativeSlugHandler.php

@@ -1,161 +0,0 @@
-<?php
-
-namespace Gedmo\Sluggable\Handler;
-
-use Doctrine\Common\Persistence\ObjectManager;
-use Gedmo\Sluggable\SluggableListener;
-use Gedmo\Sluggable\Mapping\Event\SluggableAdapter;
-use Gedmo\Tool\Wrapper\AbstractWrapper;
-use Gedmo\Exception\InvalidMappingException;
-use Gedmo\Sluggable\Util\Urlizer;
-
-/**
-* 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
-{
-    const SEPARATOR = '/';
-
-    /**
-     * @var Doctrine\Common\Persistence\ObjectManager
-     */
-    protected $om;
-
-    /**
-     * @var Gedmo\Sluggable\SluggableListener
-     */
-    protected $sluggable;
-
-    /**
-     * Used options
-     *
-     * @var array
-     */
-    private $usedOptions;
-
-    /**
-     * Callable of original transliterator
-     * which is used by sluggable
-     *
-     * @var callable
-     */
-    private $originalTransliterator;
-
-    /**
-     * $options = array(
-     *     'separator' => '/',
-     *     'relationField' => 'something',
-     *     'relationSlugField' => 'slug'
-     * )
-     * {@inheritDoc}
-     */
-    public function __construct(SluggableListener $sluggable)
-    {
-        $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);
-            $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 handlesUrlization()
-    {
-        return true;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function onChangeDecision(SluggableAdapter $ea, $config, $object, &$slug, &$needToChangeSlug)
-    {
-        $this->om = $ea->getObjectManager();
-        $isInsert = $this->om->getUnitOfWork()->isScheduledForInsert($object);
-        $this->usedOptions = $config['handlers'][get_called_class()];
-        if (!isset($this->usedOptions['separator'])) {
-            $this->usedOptions['separator'] = self::SEPARATOR;
-        }
-        if (!$isInsert && !$needToChangeSlug) {
-            $changeSet = $ea->getObjectChangeSet($this->om->getUnitOfWork(), $object);
-            if (isset($changeSet[$this->usedOptions['relationField']])) {
-                $needToChangeSlug = true;
-            }
-        }
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function postSlugBuild(SluggableAdapter $ea, array &$config, $object, &$slug)
-    {
-        $this->originalTransliterator = $this->sluggable->getTransliterator();
-        $this->sluggable->setTransliterator(array($this, 'transliterate'));
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public static function validate(array $options, $meta)
-    {
-        if (!$meta->isSingleValuedAssociation($options['relationField'])) {
-            throw new InvalidMappingException("Unable to find slug relation through field - [{$options['relationField']}] in class - {$meta->name}");
-        }
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function onSlugCompletion(SluggableAdapter $ea, array &$config, $object, &$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)
-    {
-        $result = call_user_func_array(
-            $this->originalTransliterator,
-            array($text, $separator, $object)
-        );
-        $result = Urlizer::urlize($result, $separator);
-        $wrapped = AbstractWrapper::wrap($object, $this->om);
-        $relation = $wrapped->getPropertyValue($this->usedOptions['relationField']);
-        if ($relation) {
-            $wrappedRelation = AbstractWrapper::wrap($relation, $this->om);
-            $slug = $wrappedRelation->getPropertyValue($this->usedOptions['relationSlugField']);
-            $result = $slug . $this->usedOptions['separator'] . $result;
-        }
-        $this->sluggable->setTransliterator($this->originalTransliterator);
-        return $result;
-    }
-}

+ 0 - 79
lib/Gedmo/Sluggable/Handler/SlugHandlerInterface.php

@@ -1,79 +0,0 @@
-<?php
-
-namespace Gedmo\Sluggable\Handler;
-
-use Doctrine\Common\Persistence\ObjectManager;
-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
-{
-    /**
-     * Construct the slug handler
-     *
-     * @param Gedmo\Sluggable\SluggableListener $sluggable
-     */
-    function __construct(SluggableListener $sluggable);
-
-    /**
-     * Callback on slug handlers before the decision
-     * is made whether or not the slug needs to be
-     * recalculated
-     *
-     * @param Gedmo\Sluggable\Mapping\Event\SluggableAdapter $ea
-     * @param array $config
-     * @param object $object
-     * @param string $slug
-     * @param boolean $needToChangeSlug
-     * @return void
-     */
-    function onChangeDecision(SluggableAdapter $ea, $slugFieldConfig, $object, &$slug, &$needToChangeSlug);
-
-    /**
-     * Callback on slug handlers right after the slug is built
-     *
-     * @param Gedmo\Sluggable\Mapping\Event\SluggableAdapter $ea
-     * @param array $config
-     * @param object $object
-     * @param string $slug
-     * @return void
-     */
-    function postSlugBuild(SluggableAdapter $ea, array &$config, $object, &$slug);
-
-    /**
-     * Callback for slug handlers on slug completion
-     *
-     * @param Gedmo\Sluggable\Mapping\Event\SluggableAdapter $ea
-     * @param array $config
-     * @param object $object
-     * @param string $slug
-     * @return void
-     */
-    function onSlugCompletion(SluggableAdapter $ea, array &$config, $object, &$slug);
-    
-    /**
-     * True if the handler also urlizes the slug on its own
-     * 
-     * @return boolean 
-     */
-    function handlesUrlization();
-
-    /**
-     * Validate handler options
-     *
-     * @param array $options
-     */
-    static function validate(array $options, $meta);
-}

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

@@ -1,184 +0,0 @@
-<?php
-
-namespace Gedmo\Sluggable\Handler;
-
-use Doctrine\Common\Persistence\ObjectManager;
-use Gedmo\Sluggable\SluggableListener;
-use Gedmo\Sluggable\Mapping\Event\SluggableAdapter;
-use Gedmo\Tool\Wrapper\AbstractWrapper;
-use Gedmo\Exception\InvalidMappingException;
-use Gedmo\Sluggable\Util\Urlizer;
-
-/**
-* 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
-{
-    const SEPARATOR = '/';
-
-    /**
-     * @var Doctrine\Common\Persistence\ObjectManager
-     */
-    protected $om;
-
-    /**
-     * @var Gedmo\Sluggable\SluggableListener
-     */
-    protected $sluggable;
-
-    /**
-     * Callable of original transliterator
-     * which is used by sluggable
-     *
-     * @var callable
-     */
-    private $originalTransliterator;
-
-    /**
-     * True if node is being inserted
-     *
-     * @var boolean
-     */
-    private $isInsert = false;
-
-    /**
-     * Transliterated parent slug
-     *
-     * @var string
-     */
-    private $parentSlug;
-
-    /**
-     * Used path separator
-     *
-     * @var string
-     */
-    private $usedPathSeparator;
-
-    /**
-     * {@inheritDoc}
-     */
-    public function __construct(SluggableListener $sluggable)
-    {
-        $this->sluggable = $sluggable;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function onChangeDecision(SluggableAdapter $ea, $config, $object, &$slug, &$needToChangeSlug)
-    {
-        $this->om = $ea->getObjectManager();
-        $this->isInsert = $this->om->getUnitOfWork()->isScheduledForInsert($object);
-        $options = $config['handlers'][get_called_class()];
-        $this->usedPathSeparator = isset($options['separator']) ? $options['separator'] : self::SEPARATOR;
-        if (!$this->isInsert && !$needToChangeSlug) {
-            $changeSet = $ea->getObjectChangeSet($this->om->getUnitOfWork(), $object);
-            if (isset($changeSet[$options['parentRelationField']])) {
-                $needToChangeSlug = true;
-            }
-        }
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function postSlugBuild(SluggableAdapter $ea, array &$config, $object, &$slug)
-    {
-        $options = $config['handlers'][get_called_class()];
-        $this->originalTransliterator = $this->sluggable->getTransliterator();
-        $this->sluggable->setTransliterator(array($this, 'transliterate'));
-        $this->parentSlug = '';
-
-        $wrapped = AbstractWrapper::wrap($object, $this->om);
-        if ($parent = $wrapped->getPropertyValue($options['parentRelationField'])) {
-            $parent = AbstractWrapper::wrap($parent, $this->om);
-            $this->parentSlug = $parent->getPropertyValue($config['slug']);
-        }
-    }
-    
-    /**
-     * {@inheritDoc}
-     */
-    public function handlesUrlization()
-    {
-        return true;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public static function validate(array $options, $meta)
-    {
-        if (!$meta->isSingleValuedAssociation($options['parentRelationField'])) {
-            throw new InvalidMappingException("Unable to find tree parent slug relation through field - [{$options['parentRelationField']}] in class - {$meta->name}");
-        }
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function onSlugCompletion(SluggableAdapter $ea, array &$config, $object, &$slug)
-    {
-        if (!$this->isInsert) {
-            $options = $config['handlers'][get_called_class()];
-            $wrapped = AbstractWrapper::wrap($object, $this->om);
-            $meta = $wrapped->getMetadata();
-            $target = $wrapped->getPropertyValue($config['slug']);
-            $config['pathSeparator'] = $this->usedPathSeparator;
-            $ea->replaceRelative($object, $config, $target.$config['pathSeparator'], $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 !== $wrapped->getRootObjectName()) {
-                    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}{$config['pathSeparator']}@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)
-    {
-        $slug = call_user_func_array(
-            $this->originalTransliterator,
-            array($text, $separator, $object)
-        );
-        // For tree slugs, we "urlize" each part of the slug before appending "/"
-        $slug = Urlizer::urlize($slug, $separator);
-        if (strlen($this->parentSlug)) {
-            $slug = $this->parentSlug . $this->usedPathSeparator . $slug;
-        }
-        $this->sluggable->setTransliterator($this->originalTransliterator);
-        return $slug;
-    }
-}

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

@@ -35,10 +35,10 @@ final class ORM extends BaseAdapterORM implements SluggableAdapter
             )
         ;
         // include identifiers
-        foreach ((array)$wrapped->getIdentifier(false) as $field => $value) {
+        foreach ((array)$wrapped->getIdentifier(false) as $id => $value) {
             if (strlen($value) && !$meta->isIdentifier($config['slug'])) {
-                $qb->andWhere('rec.' . $field . ' <> :' . $field);
-                $qb->setParameter($field, $value);
+                $qb->andWhere($qb->expr()->neq('rec.' . $id, ':' . $id));
+                $qb->setParameter($id, $value);
             }
         }
         $q = $qb->getQuery();

+ 19 - 11
lib/Gedmo/Sluggable/SluggableListener.php

@@ -129,6 +129,7 @@ class SluggableListener extends MappedEventSubscriber
      */
     public function onFlush(EventArgs $args)
     {
+        $this->persistedSlugs = array();
         $ea = $this->getEventAdapter($args);
         $om = $ea->getObjectManager();
         $uow = $om->getUnitOfWork();
@@ -152,11 +153,7 @@ class SluggableListener extends MappedEventSubscriber
         foreach ($ea->getScheduledObjectUpdates($uow) as $object) {
             $meta = $om->getClassMetadata(get_class($object));
             if ($config = $this->getConfiguration($om, $meta->name)) {
-                foreach ($config['slugs'] as $slugField => $options) {
-                    if ($options['updatable']) {
-                        $this->generateSlug($ea, $object);
-                    }
-                }
+                $this->generateSlug($ea, $object);
             }
         }
     }
@@ -184,19 +181,30 @@ class SluggableListener extends MappedEventSubscriber
         $meta = $om->getClassMetadata(get_class($object));
         $uow = $om->getUnitOfWork();
         $changeSet = $ea->getObjectChangeSet($uow, $object);
+        $isInsert = $uow->isScheduledForInsert($object);
         $config = $this->getConfiguration($om, $meta->name);
         foreach ($config['slugs'] as $slugField => $options) {
             $options['useObjectClass'] = $config['useObjectClass'];
             $fields = $options['fields'];
-            //$slugFieldConfig = $config['slugFields'][$slugField];
             // collect the slug from fields
-            $slug = '';
+            $slug = $meta->getReflectionProperty($slugField)->getValue($object);
+            // if slug should not be updated, skip it
+            if (!$options['updatable'] && !$isInsert && (!isset($changeSet[$slugField]) || $slug === '__id__')) {
+                continue;
+            }
             $needToChangeSlug = false;
-            foreach ($options['fields'] as $sluggableField) {
-                if (isset($changeSet[$sluggableField])) {
-                    $needToChangeSlug = true;
+            // if slug is null or set to empty, regenerate it, or needs an update
+            if (empty($slug) || $slug === '__id__' || !isset($changeSet[$slugField])) {
+                $slug = '';
+                foreach ($options['fields'] as $sluggableField) {
+                    if (isset($changeSet[$sluggableField]) || isset($changeSet[$slugField])) {
+                        $needToChangeSlug = true;
+                    }
+                    $slug .= $meta->getReflectionProperty($sluggableField)->getValue($object) . ' ';
                 }
-                $slug .= $meta->getReflectionProperty($sluggableField)->getValue($object) . ' ';
+            } else {
+                // slug was set manually
+                $needToChangeSlug = true;
             }
             // if slug is changed, do further processing
             if ($needToChangeSlug) {

+ 6 - 0
tests/Gedmo/Sluggable/Fixture/Article.php

@@ -55,8 +55,14 @@ class Article implements Sluggable
         return $this->code;
     }
 
+    public function setSlug($slug)
+    {
+        $this->slug = $slug;
+    }
+
     public function getSlug()
     {
         return $this->slug;
     }
 }
+

+ 6 - 1
tests/Gedmo/Sluggable/Fixture/ConfigurationArticle.php

@@ -55,8 +55,13 @@ class ConfigurationArticle implements Sluggable
         return $this->code;
     }
 
+    public function setSlug($slug)
+    {
+        $this->slug = $slug;
+    }
+
     public function getSlug()
     {
         return $this->slug;
     }
-}
+}

+ 67 - 14
tests/Gedmo/Sluggable/SluggableTest.php

@@ -30,15 +30,21 @@ class SluggableTest extends BaseTestCaseORM
         $this->populate();
     }
 
-    public function testInsertedNewSlug()
+    /**
+     * @test
+     */
+    function shouldInsertNewSlug()
     {
         $article = $this->em->find(self::ARTICLE, $this->articleId);
 
         $this->assertTrue($article instanceof Sluggable);
-        $this->assertEquals('the-title-my-code', $article->getSlug());
+        $this->assertEquals($article->getSlug(), 'the-title-my-code');
     }
 
-    public function testUniqueSlugGeneration()
+    /**
+     * @test
+     */
+    function shouldBuildUniqueSlug()
     {
         for ($i = 0; $i < 12; $i++) {
             $article = new Article();
@@ -48,11 +54,14 @@ class SluggableTest extends BaseTestCaseORM
             $this->em->persist($article);
             $this->em->flush();
             $this->em->clear();
-            $this->assertEquals('the-title-my-code-' . ($i + 1), $article->getSlug());
+            $this->assertEquals($article->getSlug(), 'the-title-my-code-' . ($i + 1));
         }
     }
 
-    public function testUniqueSlugLimit()
+    /**
+     * @test
+     */
+    function shouldHandleUniqueSlugLimitedLength()
     {
         $long = 'the title the title the title the title the title the title the title';
         $article = new Article();
@@ -75,11 +84,14 @@ class SluggableTest extends BaseTestCaseORM
             $this->assertEquals(64, strlen($shorten));
             $expected = 'the-title-the-title-the-title-the-title-the-title-the-title-the-';
             $expected = substr($expected, 0, 64 - (strlen($i+1) + 1)) . '-' . ($i+1);
-            $this->assertEquals($expected, $shorten);
+            $this->assertEquals($shorten, $expected);
         }
     }
 
-    public function testUniqueNumberedSlug()
+    /**
+     * @test
+     */
+    function shouldHandleNumbersInSlug()
     {
         $article = new Article();
         $article->setTitle('the title');
@@ -95,22 +107,60 @@ class SluggableTest extends BaseTestCaseORM
             $this->em->persist($article);
             $this->em->flush();
             $this->em->clear();
-            $this->assertEquals('the-title-my-code-123-' . ($i + 1), $article->getSlug());
+            $this->assertEquals($article->getSlug(), 'the-title-my-code-123-' . ($i + 1));
         }
     }
 
-    public function testUpdatableSlug()
+    /**
+     * @test
+     */
+    function shouldUpdateSlug()
     {
         $article = $this->em->find(self::ARTICLE, $this->articleId);
         $article->setTitle('the title updated');
         $this->em->persist($article);
         $this->em->flush();
-        $this->em->clear();
 
-        $this->assertEquals('the-title-updated-my-code', $article->getSlug());
+        $this->assertSame('the-title-updated-my-code', $article->getSlug());
+    }
+
+    /**
+     * @test
+     */
+    function shouldBeAbleToForceRegenerationOfSlug()
+    {
+        $article = $this->em->find(self::ARTICLE, $this->articleId);
+        $article->setSlug(null);
+        $this->em->persist($article);
+        $this->em->flush();
+
+        $this->assertSame('the-title-my-code', $article->getSlug());
+    }
+
+    /**
+     * @test
+     */
+    function shouldBeAbleToForceTheSlug()
+    {
+        $article = $this->em->find(self::ARTICLE, $this->articleId);
+        $article->setSlug('my forced slug');
+        $this->em->persist($article);
+
+        $new = new Article;
+        $new->setTitle('hey');
+        $new->setCode('cc');
+        $new->setSlug('forced');
+        $this->em->persist($new);
+
+        $this->em->flush();
+        $this->assertSame('my-forced-slug', $article->getSlug());
+        $this->assertSame('forced', $new->getSlug());
     }
 
-    public function testGithubIssue45()
+    /**
+     * @test
+     */
+    function shouldSolveGithubIssue45()
     {
         // persist new records with same slug
         $article = new Article;
@@ -128,7 +178,10 @@ class SluggableTest extends BaseTestCaseORM
         $this->assertEquals('test-code-1', $article2->getSlug());
     }
 
-    public function testGithubIssue57()
+    /**
+     * @test
+     */
+    function shouldSolveGithubIssue57()
     {
         // slug matched by prefix
         $article = new Article;
@@ -163,4 +216,4 @@ class SluggableTest extends BaseTestCaseORM
         $this->em->clear();
         $this->articleId = $article->getId();
     }
-}
+}