diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8886457..802a12c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,10 +13,10 @@ permissions: jobs: testsuite: - uses: ADmad/.github/.github/workflows/testsuite-with-db.yml@master + uses: ADmad/.github/.github/workflows/testsuite-with-db.yml@cake6 secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} cs-stan: - uses: ADmad/.github/.github/workflows/cs-stan.yml@master + uses: ADmad/.github/.github/workflows/cs-stan.yml@cake6 secrets: inherit diff --git a/README.md b/README.md index 8a915a8..b8c26d2 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Slug comes with the following configuration options: generating the slug. - `maxLength`: maximum length of a slug. Defaults to the field's limit as defined in the schema (when possible). Otherwise, no limit. -- `slugger`: class that implements the `Muffin\Slug\SlugInterface`. Defaults +- `slugger`: class that implements the `Muffin\Slug\SluggerInterface`. Defaults to `Muffin\Slug\Slugger\CakeSlugger`. - `unique:`: tells if slugs should be unique. Set this to a callable if you want to customize how unique slugs are generated. Defaults to `true`. diff --git a/composer.json b/composer.json index 566b90f..5b8157b 100644 --- a/composer.json +++ b/composer.json @@ -31,12 +31,13 @@ "source": "https://github.com/usemuffin/slug" }, "require": { - "cakephp/orm": "^5.0" + "php": ">=8.5", + "cakephp/orm": "^6.0@dev" }, "require-dev": { - "cakephp/cakephp": "^5.0", - "phpunit/phpunit": "^10.1 || ^11.1", - "cocur/slugify": "^4.3" + "cakephp/cakephp": "^6.0@dev", + "cocur/slugify": "^4.3", + "phpunit/phpunit": "^13.0" }, "autoload": { "psr-4": { @@ -53,5 +54,7 @@ "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true } - } + }, + "minimum-stability": "dev", + "prefer-stable": true } diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..b688120 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,7 @@ + + + + + src/ + tests/ + diff --git a/phpstan.neon b/phpstan.neon index 34cba21..7c4e018 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,8 +1,5 @@ parameters: - level: 7 - checkMissingIterableValueType: false - checkGenericClassInNonGenericObjectType: false - treatPhpDocTypesAsCertain: false + level: 8 bootstrapFiles: - tests/bootstrap.php paths: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 491c4be..e21ab05 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,5 @@ - + ./tests/TestCase diff --git a/psalm.xml b/psalm.xml index c4a31ba..3cef966 100644 --- a/psalm.xml +++ b/psalm.xml @@ -17,12 +17,10 @@ - - - - - - - + + + + + diff --git a/src/Model/Behavior/SlugBehavior.php b/src/Model/Behavior/SlugBehavior.php index 7ed2524..90905db 100644 --- a/src/Model/Behavior/SlugBehavior.php +++ b/src/Model/Behavior/SlugBehavior.php @@ -32,7 +32,7 @@ class SlugBehavior extends Behavior * generating the slug. * - maxLength: Maximum length of a slug. Defaults to the field's limit as * defined in the schema (when possible). Otherwise, no limit. - * - slugger: Class that implements the `Muffin\Slug\SlugInterface`. + * - slugger: Class that implements the `Muffin\Slug\SluggerInterface`. * Can either be a class name or instance. Defaults to * `Muffin\Slug\Slugger\CakeSlugger`. * - unique: Tells if slugs should be unique. Set this to a Closure if you @@ -52,7 +52,7 @@ class SlugBehavior extends Behavior * * @var array */ - protected array $_defaultConfig = [ + protected array $defaultConfig = [ 'field' => 'slug', 'displayField' => null, 'separator' => '-', @@ -75,9 +75,6 @@ class SlugBehavior extends Behavior 'implementedFinders' => [ 'slugged' => 'findSlugged', ], - 'implementedMethods' => [ - 'slug' => 'slug', - ], 'onUpdate' => false, 'onDirty' => false, ]; @@ -87,18 +84,18 @@ class SlugBehavior extends Behavior * * @var \Muffin\Slug\SluggerInterface|null */ - protected ?SluggerInterface $_slugger = null; + protected ?SluggerInterface $slugger = null; /** * Constructor. * * @param \Cake\ORM\Table $table The table this behavior is attached to. - * @param array $config The config for this behavior. + * @param array $config The config for this behavior. */ public function __construct(Table $table, array $config = []) { if (!empty($config['implementedEvents'])) { - $this->_defaultConfig['implementedEvents'] = $config['implementedEvents']; + $this->defaultConfig['implementedEvents'] = $config['implementedEvents']; unset($config['implementedEvents']); } parent::__construct($table, $config); @@ -113,19 +110,20 @@ public function __construct(Table $table, array $config = []) public function initialize(array $config): void { if (!$this->getConfig('displayField')) { - $this->setConfig('displayField', $this->_table->getDisplayField()); + $this->setConfig('displayField', $this->table->getDisplayField()); } if ($this->getConfig('maxLength') === null) { /** @psalm-suppress PossiblyNullArrayAccess */ $this->setConfig( 'maxLength', - $this->_table->getSchema()->getColumn($this->getConfig('field'))['length'] + // @phpstan-ignore offsetAccess.notFound + $this->table->getSchema()->getColumn($this->getConfig('field'))['length'], ); } if ($this->getConfig('unique') === true) { - $this->setConfig('unique', $this->_uniqueSlug(...)); + $this->setConfig('unique', $this->uniqueSlug(...)); } } @@ -136,29 +134,29 @@ public function initialize(array $config): void */ public function getSlugger(): SluggerInterface { - return $this->_slugger ??= $this->_createSlugger($this->getConfig('slugger')); + return $this->slugger ??= $this->createSlugger($this->getConfig('slugger')); } /** * Set slugger instance. * - * @param \Muffin\Slug\SluggerInterface|array|class-string<\Muffin\Slug\SluggerInterface> $slugger Sets slugger instance. + * @param \Muffin\Slug\SluggerInterface|array|class-string<\Muffin\Slug\SluggerInterface> $slugger Sets slugger instance. * Can be SluggerInterface instance or class name or config array. * @return void */ public function setSlugger(SluggerInterface|array|string $slugger): void { - $this->_slugger = $this->_createSlugger($slugger); + $this->slugger = $this->createSlugger($slugger); } /** * Create slugger instance * - * @param \Muffin\Slug\SluggerInterface|array|class-string<\Muffin\Slug\SluggerInterface> $slugger Sets slugger instance. + * @param \Muffin\Slug\SluggerInterface|array|class-string<\Muffin\Slug\SluggerInterface> $slugger Sets slugger instance. * Can be SluggerInterface instance or class name or config array. * @return \Muffin\Slug\SluggerInterface */ - protected function _createSlugger(SluggerInterface|array|string $slugger): SluggerInterface + protected function createSlugger(SluggerInterface|array|string $slugger): SluggerInterface { if (is_string($slugger)) { return new $slugger(); @@ -188,7 +186,7 @@ public function implementedEvents(): array /** * Callback for Model.buildValidator event. * - * @param \Cake\Event\EventInterface $event The beforeSave event that was fired. + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event The buildValidator event that was fired. * @param \Cake\Validation\Validator $validator Validator instance. * @param string $name Validator name. * @return void @@ -207,9 +205,9 @@ public function buildValidator(EventInterface $event, Validator $validator, stri /** * Callback for Model.beforeSave event. * - * @param \Cake\Event\EventInterface $event The afterSave event that was fired. + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event The beforeSave event that was fired. * @param \Cake\Datasource\EntityInterface $entity The entity that was saved. - * @param \ArrayObject $options Options. + * @param \ArrayObject $options Options. * @return void */ public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void @@ -245,7 +243,7 @@ public function beforeSave(EventInterface $event, EntityInterface $entity, Array return; } - $parts = $this->_getPartsFromEntity($entity); + $parts = $this->getPartsFromEntity($entity); if (empty($parts)) { return; } @@ -258,12 +256,13 @@ public function beforeSave(EventInterface $event, EntityInterface $entity, Array * Gets the parts from an entity * * @param \Cake\Datasource\EntityInterface $entity Entity - * @return array + * @return array */ - protected function _getPartsFromEntity(EntityInterface $entity): array + protected function getPartsFromEntity(EntityInterface $entity): array { $parts = []; foreach ((array)$this->getConfig('displayField') as $displayField) { + /** @psalm-suppress InvalidArgument */ $value = Hash::get($entity, $displayField); if ($value === null && !$entity->isNew()) { @@ -281,14 +280,14 @@ protected function _getPartsFromEntity(EntityInterface $entity): array /** * Custom finder. * - * @param \Cake\ORM\Query\SelectQuery $query Query. + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query Query. * @param string $slug Slug to search for. - * @return \Cake\ORM\Query\SelectQuery Query. + * @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> Query. */ public function findSlugged(SelectQuery $query, string $slug): SelectQuery { return $query->where([ - $this->_table->aliasField($this->getConfig('field')) => $slug, + $this->table->aliasField($this->getConfig('field')) => $slug, ]); } @@ -310,10 +309,10 @@ public function slug(EntityInterface|string $entity, ?string $string = null, ?st } $string = $entity; } elseif ($string === null) { - $string = $this->_getSlugStringFromEntity($entity, $separator); + $string = $this->getSlugStringFromEntity($entity, $separator); } - $slug = $this->_slug($string, $separator); + $slug = $this->slugify($string, $separator); $unique = $this->getConfig('unique'); if (!is_string($entity) && $unique) { @@ -330,16 +329,17 @@ public function slug(EntityInterface|string $entity, ?string $string = null, ?st * @param string $separator Separator * @return string */ - protected function _getSlugStringFromEntity(EntityInterface $entity, string $separator): string + protected function getSlugStringFromEntity(EntityInterface $entity, string $separator): string { $string = []; foreach ((array)$this->getConfig('displayField') as $field) { if ($entity->getError($field)) { throw new InvalidArgumentException(sprintf( 'Error while generating the slug, the field `%s` contains an invalid value.', - $field + $field, )); } + /** @psalm-suppress InvalidArgument */ $string[] = Hash::get($entity, $field); } @@ -351,13 +351,13 @@ protected function _getSlugStringFromEntity(EntityInterface $entity, string $sep * * @param \Cake\Datasource\EntityInterface $entity Entity. * @param string $slug Slug - * @return array + * @return array */ - protected function _conditions(EntityInterface $entity, string $slug): array + protected function conditions(EntityInterface $entity, string $slug): array { /** @var string $primaryKey */ - $primaryKey = $this->_table->getPrimaryKey(); - $field = $this->_table->aliasField($this->getConfig('field')); + $primaryKey = $this->table->getPrimaryKey(); + $field = $this->table->aliasField($this->getConfig('field')); $conditions = [$field => $slug]; @@ -371,7 +371,7 @@ protected function _conditions(EntityInterface $entity, string $slug): array $id = $entity->get($primaryKey); if ($id !== null) { /** @psalm-suppress PossiblyInvalidArrayOffset */ - $conditions['NOT'][$this->_table->aliasField($primaryKey)] = $id; + $conditions['NOT'][$this->table->aliasField($primaryKey)] = $id; } return $conditions; @@ -385,16 +385,16 @@ protected function _conditions(EntityInterface $entity, string $slug): array * @param string $separator Separator. * @return string Unique slug. */ - protected function _uniqueSlug(EntityInterface $entity, string $slug, string $separator): string + protected function uniqueSlug(EntityInterface $entity, string $slug, string $separator): string { - $field = $this->_table->aliasField($this->getConfig('field')); - $conditions = $this->_conditions($entity, $slug); + $field = $this->table->aliasField($this->getConfig('field')); + $conditions = $this->conditions($entity, $slug); $i = 0; $suffix = ''; $length = (int)$this->getConfig('maxLength'); - while ($this->_table->exists($conditions)) { + while ($this->table->exists($conditions)) { $i++; $suffix = $separator . $i; if ($length && $length < mb_strlen($slug . $suffix)) { @@ -413,8 +413,9 @@ protected function _uniqueSlug(EntityInterface $entity, string $slug, string $se * @param string $separator String to use as separator/separator. * @return string Slug. */ - protected function _slug(string $string, string $separator): string + protected function slugify(string $string, string $separator): string { + /** @var array $replacements */ $replacements = $this->getConfig('replacements'); $slugger = $this->getSlugger(); $slug = $slugger->slug(str_replace(array_keys($replacements), $replacements, $string), $separator); @@ -422,7 +423,7 @@ protected function _slug(string $string, string $separator): string $slug = Text::truncate( $slug, $this->getConfig('maxLength'), - ['ellipsis' => ''] + ['ellipsis' => ''], ); } diff --git a/tests/TestCase/Model/Behavior/SlugBehaviorTest.php b/tests/TestCase/Model/Behavior/SlugBehaviorTest.php index ca4351f..35ec0de 100644 --- a/tests/TestCase/Model/Behavior/SlugBehaviorTest.php +++ b/tests/TestCase/Model/Behavior/SlugBehaviorTest.php @@ -29,7 +29,7 @@ public function setUp(): void $this->Tags->setDisplayField('name'); $this->Tags->addBehavior('Muffin/Slug.Slug'); - $this->Behavior = $this->Tags->behaviors()->Slug; + $this->Behavior = $this->Tags->getBehavior('Slug'); } public function tearDown(): void @@ -40,7 +40,7 @@ public function tearDown(): void unset($this->Behavior, $this->Tags); } - public function testInitialize() + public function testInitialize(): void { $result = $this->Behavior->getConfig('displayField'); $expected = 'name'; @@ -51,7 +51,7 @@ public function testInitialize() $this->assertEquals($expected, $result); } - public function testImplementedEvents() + public function testImplementedEvents(): void { $result = $this->Behavior->implementedEvents(); $expected = [ @@ -65,12 +65,12 @@ public function testImplementedEvents() $this->Tags->removeBehavior('Slug'); $this->Tags->addBehavior('Muffin/Slug.Slug', compact('implementedEvents')); - $result = $this->Tags->behaviors()->Slug->implementedEvents(); + $result = $this->Tags->getBehavior('Slug')->implementedEvents(); $expected = $implementedEvents; $this->assertEquals($expected, $result); } - public function testBeforeSave() + public function testBeforeSave(): void { $data = ['name' => 'foo']; $tag = $this->Tags->newEntity($data); @@ -86,7 +86,7 @@ public function testBeforeSave() $this->assertEquals($expected, $result); } - public function testBeforeSaveOnUpdate() + public function testBeforeSaveOnUpdate(): void { $data = ['name' => 'foo']; $tag = $this->Tags->newEntity($data); @@ -99,7 +99,7 @@ public function testBeforeSaveOnUpdate() $result = $this->Tags->save($tag)->slug; $this->assertEquals($expected, $result); - $this->Tags->behaviors()->Slug->setConfig('onUpdate', true); + $this->Tags->getBehavior('Slug')->setConfig('onUpdate', true); $tag->name = 'baz'; $result = $this->Tags->save($tag)->slug; $expected = 'baz'; @@ -111,7 +111,7 @@ public function testBeforeSaveOnUpdate() $this->assertEquals($expected, $result); } - public function testBeforeSaveDirtyField() + public function testBeforeSaveDirtyField(): void { $data = ['name' => 'foo', 'slug' => 'bar']; $tag = $this->Tags->newEntity($data); @@ -127,7 +127,7 @@ public function testBeforeSaveDirtyField() $expected = 'baz'; $this->assertEquals($expected, $result); - $this->Tags->behaviors()->Slug->setConfig('onDirty', true); + $this->Tags->getBehavior('Slug')->setConfig('onDirty', true); $data = ['name' => 'I am nice', 'slug' => 'make ME Nice']; $tag = $this->Tags->newEntity($data); @@ -143,7 +143,7 @@ public function testBeforeSaveDirtyField() $expected = 'fooz'; $this->assertEquals($expected, $result); - $this->Tags->behaviors()->Slug->setConfig('onUpdate', true); + $this->Tags->getBehavior('Slug')->setConfig('onUpdate', true); $tag = $this->Tags->find()->where(['name' => 'I am nice'])->first(); $tag->slug = 'I is NICE'; @@ -159,9 +159,9 @@ public function testBeforeSaveDirtyField() * * @return void */ - public function testBeforeSaveOnUpdateTrueAndOnDirtyTrue() + public function testBeforeSaveOnUpdateTrueAndOnDirtyTrue(): void { - $this->Tags->behaviors()->Slug->setConfig([ + $this->Tags->getBehavior('Slug')->setConfig([ 'onUpdate' => true, 'onDirty' => true, ]); @@ -186,7 +186,7 @@ public function testBeforeSaveOnUpdateTrueAndOnDirtyTrue() /** * Make sure no slug is generated when `displayField` is empty. */ - public function testBeforeSaveEmptyField() + public function testBeforeSaveEmptyField(): void { $this->Tags->removeBehavior('Slug'); $this->Tags->addBehavior('Muffin/Slug.Slug', [ @@ -204,7 +204,7 @@ public function testBeforeSaveEmptyField() $this->assertEquals($expected, $result); } - public function testSlug() + public function testSlug(): void { $result = $this->Behavior->slug('foo/bar'); $expected = 'foo-bar'; @@ -219,7 +219,7 @@ public function testSlug() $this->assertEquals($expected, $result); } - public function testSlugWithAssociatedTableField() + public function testSlugWithAssociatedTableField(): void { $Articles = $this->getTableLocator()->get('Muffin/Slug.Articles', ['table' => 'slug_articles']); $Authors = $this->getTableLocator()->get('Muffin/Slug.Authors', ['table' => 'slug_authors']); @@ -231,12 +231,12 @@ public function testSlugWithAssociatedTableField() $article = $Articles->newEntity($data); $article->author = $Authors->get(1); - $result = $Articles->slug($article); + $result = $Articles->getBehavior('Slug')->slug($article); $expected = 'admad-foo'; $this->assertEquals($expected, $result); } - public function testBeforeSaveMultiField() + public function testBeforeSaveMultiField(): void { $Articles = $this->getTableLocator()->get('Muffin/Slug.Articles', ['table' => 'slug_articles']); $Articles->addBehavior('Muffin/Slug.Slug', ['displayField' => ['title', 'sub_title']]); @@ -270,12 +270,12 @@ public function testBeforeSaveMultiField() $this->assertEquals($expected, $result); $entity = new Entity(['title' => 'ad', 'sub_title' => 'mad']); - $result = $Articles->slug($entity); + $result = $Articles->getBehavior('Slug')->slug($entity); $expected = 'ad-mad'; $this->assertEquals($expected, $result); } - public function testBeforeSaveMultiWithOptionalField() + public function testBeforeSaveMultiWithOptionalField(): void { $Articles = $this->getTableLocator()->get('Muffin/Slug.Articles', ['table' => 'slug_articles']); $Articles->addBehavior('Muffin/Slug.Slug', [ @@ -293,7 +293,7 @@ public function testBeforeSaveMultiWithOptionalField() $this->assertEquals($expected, $result); } - public function testBeforeSaveSlugGenerationWithAssociatedTableField() + public function testBeforeSaveSlugGenerationWithAssociatedTableField(): void { $Articles = $this->getTableLocator()->get('Muffin/Slug.Articles', ['table' => 'slug_articles']); $Authors = $this->getTableLocator()->get('Muffin/Slug.Authors', ['table' => 'slug_authors']); @@ -310,7 +310,7 @@ public function testBeforeSaveSlugGenerationWithAssociatedTableField() $this->assertEquals($expected, $result); } - public function testCustomSlugField() + public function testCustomSlugField(): void { $Articles = $this->getTableLocator()->get('Muffin/Slug.Articles', ['table' => 'slug_articles']); $Articles->addBehavior('Muffin/Slug.Slug', [ @@ -326,7 +326,7 @@ public function testCustomSlugField() $this->assertEquals($expected, $result); } - public function testMaxLength() + public function testMaxLength(): void { $this->Tags->removeBehavior('Slug'); $this->Tags->addBehavior('Muffin/Slug.Slug'); @@ -345,7 +345,7 @@ public function testMaxLength() $this->assertEquals($expected, $result); } - public function testCustomMaxLength() + public function testCustomMaxLength(): void { $this->Tags->removeBehavior('Slug'); $this->Tags->addBehavior('Muffin/Slug.Slug', [ @@ -367,14 +367,14 @@ public function testCustomMaxLength() $this->assertEquals($expected, $result); } - public function testSlugThrowsInvalidArgumentException() + public function testSlugThrowsInvalidArgumentException(): void { $this->expectException(InvalidArgumentException::class); $this->Behavior->slug($this->Tags->newEntity([])); } - public function testSlugUnchanged() + public function testSlugUnchanged(): void { $data = ['name' => 'foo', 'slug' => 'my-slug']; $tag = $this->Tags->newEntity($data); @@ -390,7 +390,7 @@ public function testSlugUnchanged() $this->assertEquals($expected, $result); } - public function testSavingEntityWithErrors() + public function testSavingEntityWithErrors(): void { $data = ['name' => 'foo']; $tag = $this->Tags->newEntity($data); @@ -401,7 +401,7 @@ public function testSavingEntityWithErrors() $this->assertNull($tag->get('slug')); } - public function testFinder() + public function testFinder(): void { $result = $this->Tags->find('slugged', slug: 'dark-color') ->select(['slug', 'name']) @@ -415,10 +415,10 @@ public function testFinder() $this->assertEquals($expected, $result); $query = $this->Tags->find('slugged', slug: '0'); - $this->assertInstanceOf('Cake\ORM\Query', $query); + $this->assertInstanceOf('Cake\ORM\Query\SelectQuery', $query); } - public function testContainSluggedTables() + public function testContainSluggedTables(): void { $this->getTableLocator()->get('Muffin/Slug.Articles', ['table' => 'slug_articles']); @@ -438,7 +438,7 @@ public function testContainSluggedTables() $this->assertCount(1, $result['articles']); } - public function testSluggerConfig() + public function testSluggerConfig(): void { $this->Behavior->setConfig('slugger', [ 'className' => '\Muffin\Slug\Slugger\CakeSlugger', @@ -448,7 +448,7 @@ public function testSluggerConfig() $this->assertSame('FOO-BAR', $this->Behavior->getSlugger()->slug('FOO BAR')); } - public function testCallableForUnique() + public function testCallableForUnique(): void { $this->Behavior->setConfig('scope', function ($entity) { return ['namespace' => $entity->namespace]; @@ -456,6 +456,6 @@ public function testCallableForUnique() $newEntity = $this->Tags->newEntity(['namespace' => 'foo', 'name' => 'Color']); - $this->assertEquals('color', $this->Tags->slug($newEntity)); + $this->assertEquals('color', $this->Tags->getBehavior('Slug')->slug($newEntity)); } }