Commit 327914e4 by Klimov Paul

Added automatic generating of unique slug value to `yii\behaviors\Sluggable`

parent c4b8e045
......@@ -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)
......
......@@ -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);
}
}
<?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
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment