diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index 69a72a4..32dc7f6 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -173,6 +173,7 @@ Yii Framework 2 Change Log - Enh #4566: Added client validation support for image validator (Skysplit, qiangxue) - Enh #4581: Added ability to disable url encoding in `UrlRule` (tadaszelvys) - Enh #4602: Added $key param in ActionColumn buttons Closure call (disem) +- Enh #4630: Added automatic generating of unique slug value to `yii\behaviors\Sluggable` (klimov-paul) - Enh: Added support for using sub-queries when building a DB query with `IN` condition (qiangxue) - Enh: Supported adding a new response formatter without the need to reconfigure existing formatters (qiangxue) - Enh: Added `yii\web\UrlManager::addRules()` to simplify adding new URL rules (qiangxue) diff --git a/framework/behaviors/SluggableBehavior.php b/framework/behaviors/SluggableBehavior.php index 8dd0a12..6703891 100644 --- a/framework/behaviors/SluggableBehavior.php +++ b/framework/behaviors/SluggableBehavior.php @@ -7,6 +7,8 @@ namespace yii\behaviors; +use yii\base\DynamicModel; +use yii\base\Exception; use yii\base\InvalidConfigException; use yii\db\BaseActiveRecord; use yii\helpers\Inflector; @@ -47,6 +49,7 @@ use yii\helpers\Inflector; * } * ``` * @author Alexander Kochetov <creocoder@gmail.com> + * @author Paul Klimov <klimov.paul@gmail.com> * @since 2.0 */ class SluggableBehavior extends AttributeBehavior @@ -56,11 +59,11 @@ class SluggableBehavior extends AttributeBehavior */ public $slugAttribute = 'slug'; /** - * @var string the attribute whose value will be converted into a slug + * @var string|array the attribute or list of attributes whose value will be converted into a slug */ public $attribute; /** - * @var string|callable the value that will be used as a slug. This can be an anonymous function + * @var mixed the value that will be used as a slug. This can be an anonymous function * or an arbitrary value. If the former, the return value of the function will be used as a slug. * The signature of the function should be as follows, * @@ -72,6 +75,39 @@ class SluggableBehavior extends AttributeBehavior * ``` */ public $value; + /** + * @var boolean whether to ensure generated slug value to be unique among owner class records. + * If enabled behavior will validate slug uniqueness automatically. If validation fails it will attempt + * generating unique slug value from based one until success. + */ + public $unique = false; + /** + * @var array configuration for slug uniqueness validator. This configuration should not contain validator name + * and validated attributes - only options in format 'name => value' are allowed. + * For example: + * [ + * 'filter' => ['type' => 1, 'status' => 2] + * ] + * @see yii\validators\UniqueValidator + */ + public $uniqueValidatorConfig = []; + /** + * @var string|callable slug unique value generator. It is used in case [[unique]] enabled and generated + * slug is not unique. This can be a PHP callable with following signature: + * + * ```php + * function ($baseSlug, $iteration) + * { + * // return uniqueSlug + * } + * ``` + * + * Also one of the following predefined values can be used: + * - 'increment' - adds incrementing suffix to the base slug + * - 'uniqueid' - adds part of uniqueId hash string to the base slug + * - 'timestamp' - adds current UNIX timestamp to the base slug + */ + public $uniqueSlugGenerator = 'increment'; /** @@ -96,9 +132,112 @@ class SluggableBehavior extends AttributeBehavior protected function getValue($event) { if ($this->attribute !== null) { - $this->value = Inflector::slug($this->owner->{$this->attribute}); + if (is_array($this->attribute)) { + $slugParts = []; + foreach ($this->attribute as $attribute) { + $slugParts[] = Inflector::slug($this->owner->{$attribute}); + } + $this->value = implode('-', $slugParts); + } else { + $this->value = Inflector::slug($this->owner->{$this->attribute}); + } + } + $slug = parent::getValue($event); + + if ($this->unique) { + $baseSlug = $slug; + $iteration = 0; + while (!$this->validateSlugUnique($slug)) { + $iteration++; + $slug = $this->generateUniqueSlug($baseSlug, $iteration); + } } + return $slug; + } + + /** + * Checks if given slug value is unique. + * @param string $slug slug value + * @return boolean whether slug is unique. + */ + private function validateSlugUnique($slug) + { + $validator = array_merge( + [ + ['slug'], + 'unique', + 'targetClass' => get_class($this->owner) + ], + $this->uniqueValidatorConfig + ); + $model = DynamicModel::validateData(compact('slug'), [$validator]); + return !$model->hasErrors(); + } - return parent::getValue($event); + /** + * @param string $baseSlug base slug value + * @param integer $iteration iteration number + * @return string slug suffix + * @throws \yii\base\InvalidConfigException + */ + private function generateUniqueSlug($baseSlug, $iteration) + { + $generator = $this->uniqueSlugGenerator; + switch ($generator) { + case 'increment': + return $this->generateUniqueSlugIncrement($baseSlug, $iteration); + case 'uniqueid': + return $this->generateUniqueSlugUniqueId($baseSlug, $iteration); + case 'timestamp': + return $this->generateSuffixSlugTimestamp($baseSlug, $iteration); + default: + if (is_callable($generator)) { + return call_user_func($generator, $baseSlug, $iteration); + } + throw new InvalidConfigException("Unrecognized slug unique suffix generator '{$generator}'."); + } + } + + /** + * Generates slug using increment of iteration. + * @param string $baseSlug base slug value + * @param integer $iteration iteration number + * @return string generated suffix. + */ + protected function generateUniqueSlugIncrement($baseSlug, $iteration) + { + return $baseSlug . '-' . ($iteration + 1); + } + + /** + * Generates slug using unique id. + * @param string $baseSlug base slug value + * @param integer $iteration iteration number + * @throws \yii\base\Exception + * @return string generated suffix. + */ + protected function generateUniqueSlugUniqueId($baseSlug, $iteration) + { + static $uniqueId; + if ($iteration < 2) { + $uniqueId = sha1(uniqid(get_class($this), true)); + } + $subStringLength = 6 + $iteration; + if ($subStringLength > strlen($uniqueId)) { + throw new Exception('Unique id is exhausted.'); + } + return $baseSlug . '-' . substr($uniqueId, 0, $subStringLength); + } + + /** + * Generates slug using current timestamp. + * @param string $baseSlug base slug value + * @param integer $iteration iteration number + * @throws \yii\base\Exception + * @return string generated suffix. + */ + protected function generateSuffixSlugTimestamp($baseSlug, $iteration) + { + return $baseSlug . '-' . (time() + $iteration - 1); } } diff --git a/tests/unit/framework/behaviors/SluggableBehaviorTest.php b/tests/unit/framework/behaviors/SluggableBehaviorTest.php new file mode 100644 index 0000000..861b400 --- /dev/null +++ b/tests/unit/framework/behaviors/SluggableBehaviorTest.php @@ -0,0 +1,197 @@ +<?php + +namespace yiiunit\framework\behaviors; + +use Yii; +use yiiunit\TestCase; +use yii\db\Connection; +use yii\db\ActiveRecord; +use yii\behaviors\SluggableBehavior; + +/** + * Unit test for [[\yii\behaviors\SluggableBehavior]]. + * @see SluggableBehavior + * + * @group behaviors + */ +class SluggableBehaviorTest extends TestCase +{ + /** + * @var Connection test db connection + */ + protected $dbConnection; + + public static function setUpBeforeClass() + { + if (!extension_loaded('pdo') || !extension_loaded('pdo_sqlite')) { + static::markTestSkipped('PDO and SQLite extensions are required.'); + } + } + + public function setUp() + { + $this->mockApplication([ + 'components' => [ + 'db' => [ + 'class' => '\yii\db\Connection', + 'dsn' => 'sqlite::memory:', + ] + ] + ]); + + $columns = [ + 'id' => 'pk', + 'name' => 'string', + 'slug' => 'string', + 'category_id' => 'integer', + ]; + Yii::$app->getDb()->createCommand()->createTable('test_slug', $columns)->execute(); + } + + public function tearDown() + { + Yii::$app->getDb()->close(); + parent::tearDown(); + } + + // Tests : + + public function testSlug() + { + $model = new ActiveRecordSluggable(); + $model->name = 'test name'; + $model->validate(); + + $this->assertEquals('test-name', $model->slug); + } + + /** + * @depends testSlug + */ + public function testSlugSeveralAttributes() + { + $model = new ActiveRecordSluggable(); + $model->getBehavior('sluggable')->attribute = array('name', 'category_id'); + + $model->name = 'test'; + $model->category_id = 10; + + $model->validate(); + $this->assertEquals('test-10', $model->slug); + } + + /** + * @depends testSlug + */ + public function testUniqueByIncrement() + { + $name = 'test name'; + + $model = new ActiveRecordSluggable(); + $model->name = $name; + $model->save(); + + $model = new ActiveRecordSluggable(); + $model->sluggable->unique = true; + $model->name = $name; + $model->save(); + + $this->assertEquals('test-name-2', $model->slug); + } + + /** + * @depends testUniqueByIncrement + */ + public function testUniqueByCallback() + { + $name = 'test name'; + + $model = new ActiveRecordSluggable(); + $model->name = $name; + $model->save(); + + $model = new ActiveRecordSluggable(); + $model->sluggable->unique = true; + $model->sluggable->uniqueSlugGenerator = function($baseSlug, $iteration) {return $baseSlug . '-callback';}; + $model->name = $name; + $model->save(); + + $this->assertEquals('test-name-callback', $model->slug); + } + + /** + * @depends testUniqueByIncrement + */ + public function testUniqueByUniqueId() + { + $name = 'test name'; + + $model1 = new ActiveRecordSluggable(); + $model1->name = $name; + $model1->save(); + + $model2 = new ActiveRecordSluggable(); + $model2->sluggable->unique = true; + $model2->sluggable->uniqueSlugGenerator = 'uniqueid'; + $model2->name = $name; + $model2->save(); + + $this->assertNotEquals($model2->slug, $model1->slug); + } + + /** + * @depends testUniqueByIncrement + */ + public function testUniqueByTimestamp() + { + $name = 'test name'; + + $model1 = new ActiveRecordSluggable(); + $model1->name = $name; + $model1->save(); + + $model2 = new ActiveRecordSluggable(); + $model2->sluggable->unique = true; + $model2->sluggable->uniqueSlugGenerator = 'timestamp'; + $model2->name = $name; + $model2->save(); + + $this->assertNotEquals($model2->slug, $model1->slug); + } +} + +/** + * Test Active Record class with [[SluggableBehavior]] behavior attached. + * + * @property integer $id + * @property string $name + * @property string $slug + * @property integer $category_id + * + * @property SluggableBehavior $sluggable + */ +class ActiveRecordSluggable extends ActiveRecord +{ + public function behaviors() + { + return [ + 'sluggable' => [ + 'class' => SluggableBehavior::className(), + 'attribute' => 'name', + ], + ]; + } + + public static function tableName() + { + return 'test_slug'; + } + + /** + * @return SluggableBehavior + */ + public function getSluggable() + { + return $this->getBehavior('sluggable'); + } +} \ No newline at end of file