Browse Source

Merge pull request #17904 from cakephp/4.next-merge

merge 4.x => 4.next
Mark Story 1 year ago
parent
commit
7decfc1ade

+ 5 - 0
phpstan.neon.dist

@@ -24,3 +24,8 @@ services:
 		tags:
 			- phpstan.broker.methodsClassReflectionExtension
 			- phpstan.broker.propertiesClassReflectionExtension
+
+	-
+		class: Cake\PHPStan\PhpDoc\TableAssociationTypeNodeResolverExtension
+		tags:
+			- phpstan.phpDoc.typeNodeResolverExtension

+ 1 - 1
src/Cache/Engine/MemcachedEngine.php

@@ -437,7 +437,7 @@ class MemcachedEngine extends CacheEngine
         }
 
         foreach ($keys as $key) {
-            if (strpos($key, $this->_config['prefix']) === 0) {
+            if ($this->_config['prefix'] === '' || strpos($key, $this->_config['prefix']) === 0) {
                 $this->_Memcached->delete($key);
             }
         }

+ 3 - 0
src/ORM/Association/BelongsTo.php

@@ -31,6 +31,9 @@ use function Cake\Core\pluginSplit;
  * related to only one record in the target table.
  *
  * An example of a BelongsTo association would be Article belongs to Author.
+ *
+ * @template T of \Cake\ORM\Table
+ * @mixin T
  */
 class BelongsTo extends Association
 {

+ 3 - 0
src/ORM/Association/BelongsToMany.php

@@ -37,6 +37,9 @@ use SplObjectStorage;
  *
  * An example of a BelongsToMany association would be Article belongs to many Tags.
  * In this example 'Article' is the source table and 'Tags' is the target table.
+ *
+ * @template T of \Cake\ORM\Table
+ * @mixin T
  */
 class BelongsToMany extends Association
 {

+ 3 - 0
src/ORM/Association/HasMany.php

@@ -33,6 +33,9 @@ use InvalidArgumentException;
  * will have one or multiple records per each one in the source side.
  *
  * An example of a HasMany association would be Author has many Articles.
+ *
+ * @template T of \Cake\ORM\Table
+ * @mixin T
  */
 class HasMany extends Association
 {

+ 3 - 0
src/ORM/Association/HasOne.php

@@ -29,6 +29,9 @@ use function Cake\Core\pluginSplit;
  * related to only one record in the target table and vice versa.
  *
  * An example of a HasOne association would be User has one Profile.
+ *
+ * @template T of \Cake\ORM\Table
+ * @mixin T
  */
 class HasOne extends Association
 {

+ 25 - 0
src/ORM/BehaviorRegistry.php

@@ -204,6 +204,31 @@ class BehaviorRegistry extends ObjectRegistry implements EventDispatcherInterfac
     }
 
     /**
+     * Remove an object from the registry.
+     *
+     * If this registry has an event manager, the object will be detached from any events as well.
+     *
+     * @param string $name The name of the object to remove from the registry.
+     * @return $this
+     */
+    public function unload(string $name)
+    {
+        $instance = $this->get($name);
+        $result = parent::unload($name);
+
+        $methods = array_change_key_case($instance->implementedMethods());
+        foreach (array_keys($methods) as $method) {
+            unset($this->_methodMap[$method]);
+        }
+        $finders = array_change_key_case($instance->implementedFinders());
+        foreach (array_keys($finders) as $finder) {
+            unset($this->_finderMap[$finder]);
+        }
+
+        return $result;
+    }
+
+    /**
      * Check if any loaded behavior implements a method.
      *
      * Will return true if any behavior provides a public non-finder method

+ 2 - 2
src/Utility/Inflector.php

@@ -464,7 +464,7 @@ class Inflector
     }
 
     /**
-     * Returns corresponding table name for given model $className. ("people" for the model class "Person").
+     * Returns corresponding table name for given model $className. ("people" for the class name "Person").
      *
      * @param string $className Name of class to get database table name for
      * @return string Name of the database table for given class
@@ -483,7 +483,7 @@ class Inflector
     }
 
     /**
-     * Returns Cake model class name ("Person" for the database table "people".) for given database table.
+     * Returns a singular, CamelCase inflection for given database table. ("Person" for the table name "people")
      *
      * @param string $tableName Name of database table to get class name for
      * @return string Class name

+ 5 - 1
src/View/Helper/FormHelper.php

@@ -1396,9 +1396,13 @@ class FormHelper extends Helper
             $options['templateVars']['customValidityMessage'] = $message;
 
             if ($this->getConfig('autoSetCustomValidity')) {
+                $condition = 'this.value';
+                if ($options['type'] === 'checkbox') {
+                    $condition = 'this.checked';
+                }
                 $options['data-validity-message'] = $message;
                 $options['oninvalid'] = "this.setCustomValidity(''); "
-                    . 'if (!this.value) this.setCustomValidity(this.dataset.validityMessage)';
+                    . "if (!{$condition}) this.setCustomValidity(this.dataset.validityMessage)";
                 $options['oninput'] = "this.setCustomValidity('')";
             }
         }

+ 87 - 0
tests/PHPStan/PhpDoc/TableAssociationTypeNodeResolverExtension.php

@@ -0,0 +1,87 @@
+<?php
+declare(strict_types=1);
+
+namespace Cake\PHPStan\PhpDoc;
+
+use Cake\ORM\Association;
+use Cake\ORM\Association\BelongsTo;
+use Cake\ORM\Association\BelongsToMany;
+use Cake\ORM\Association\HasMany;
+use Cake\ORM\Association\HasOne;
+use PHPStan\Analyser\NameScope;
+use PHPStan\PhpDoc\TypeNodeResolver;
+use PHPStan\PhpDoc\TypeNodeResolverAwareExtension;
+use PHPStan\PhpDoc\TypeNodeResolverExtension;
+use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode;
+use PHPStan\PhpDocParser\Ast\Type\TypeNode;
+use PHPStan\Type\Generic\GenericObjectType;
+use PHPStan\Type\ObjectType;
+use PHPStan\Type\Type;
+
+/**
+ * Fix intersection association phpDoc to correct generic object type, ex:
+ *
+ * Change `\Cake\ORM\Association\BelongsTo&\App\Model\Table\UsersTable` to `\Cake\ORM\Association\BelongsTo<\App\Model\Table\UsersTable>`
+ *
+ * The type `\Cake\ORM\Association\BelongsTo&\App\Model\Table\UsersTable` is considered invalid (NeverType) by PHPStan
+ */
+class TableAssociationTypeNodeResolverExtension implements TypeNodeResolverExtension, TypeNodeResolverAwareExtension
+{
+    private TypeNodeResolver $typeNodeResolver;
+
+    /**
+     * @var array<string>
+     */
+    protected array $associationTypes = [
+        BelongsTo::class,
+        BelongsToMany::class,
+        HasMany::class,
+        HasOne::class,
+        Association::class,
+    ];
+
+    /**
+     * @param \PHPStan\PhpDoc\TypeNodeResolver $typeNodeResolver
+     * @return void
+     */
+    public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void
+    {
+        $this->typeNodeResolver = $typeNodeResolver;
+    }
+
+    /**
+     * @param \PHPStan\PhpDocParser\Ast\Type\TypeNode $typeNode
+     * @param \PHPStan\Analyser\NameScope $nameScope
+     * @return \PHPStan\Type\Type|null
+     */
+    public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type
+    {
+        if (!$typeNode instanceof IntersectionTypeNode) {
+            return null;
+        }
+        $types = $this->typeNodeResolver->resolveMultiple($typeNode->types, $nameScope);
+        $config = [
+            'association' => null,
+            'table' => null,
+        ];
+        foreach ($types as $type) {
+            if (!$type instanceof ObjectType) {
+                continue;
+            }
+            $className = $type->getClassName();
+            if ($config['association'] === null && in_array($className, $this->associationTypes)) {
+                $config['association'] = $type;
+            } elseif ($config['table'] === null && str_ends_with($className, 'Table')) {
+                $config['table'] = $type;
+            }
+        }
+        if ($config['table'] && $config['association']) {
+            return new GenericObjectType(
+                $config['association']->getClassName(),
+                [$config['table']]
+            );
+        }
+
+        return null;
+    }
+}

+ 20 - 0
tests/TestCase/Cache/Engine/MemcachedEngineTest.php

@@ -819,6 +819,26 @@ class MemcachedEngineTest extends TestCase
     }
 
     /**
+     * test clearing memcached with empty prefix.
+     */
+    public function testClearWithEmptyPrefix(): void
+    {
+        Cache::setConfig('memcached2', [
+            'engine' => 'Memcached',
+            'prefix' => '',
+            'duration' => 3600,
+            'servers' => ['127.0.0.1:' . $this->port],
+        ]);
+
+        Cache::write('some_value', 'cache1', 'memcached2');
+        sleep(1);
+        $this->assertTrue(Cache::clear('memcached2'));
+        $this->assertNull(Cache::read('some_value', 'memcached2'));
+
+        Cache::clear('memcached2');
+    }
+
+    /**
      * test that a 0 duration can successfully write.
      */
     public function testZeroDuration(): void

+ 14 - 25
tests/TestCase/ORM/BehaviorRegistryTest.php

@@ -23,6 +23,7 @@ use Cake\ORM\Query;
 use Cake\ORM\Table;
 use Cake\TestSuite\TestCase;
 use LogicException;
+use RuntimeException;
 
 /**
  * Test case for BehaviorRegistry.
@@ -257,19 +258,8 @@ class BehaviorRegistryTest extends TestCase
     public function testCall(): void
     {
         $this->Behaviors->load('Sluggable');
-        $mockedBehavior = $this->getMockBuilder('Cake\ORM\Behavior')
-            ->addMethods(['slugify'])
-            ->disableOriginalConstructor()
-            ->getMock();
-        $this->Behaviors->set('Sluggable', $mockedBehavior);
-
-        $mockedBehavior
-            ->expects($this->once())
-            ->method('slugify')
-            ->with(['some value'])
-            ->will($this->returnValue('some-thing'));
-        $return = $this->Behaviors->call('slugify', [['some value']]);
-        $this->assertSame('some-thing', $return);
+        $return = $this->Behaviors->call('slugify', ['some value']);
+        $this->assertSame('some-value', $return);
     }
 
     /**
@@ -292,20 +282,12 @@ class BehaviorRegistryTest extends TestCase
     public function testCallFinder(): void
     {
         $this->Behaviors->load('Sluggable');
-        $mockedBehavior = $this->getMockBuilder('Cake\ORM\Behavior')
-            ->addMethods(['findNoSlug'])
-            ->disableOriginalConstructor()
-            ->getMock();
-        $this->Behaviors->set('Sluggable', $mockedBehavior);
 
         $query = new Query($this->Table->getConnection(), $this->Table);
-        $mockedBehavior
-            ->expects($this->once())
-            ->method('findNoSlug')
-            ->with($query, [])
-            ->will($this->returnValue($query));
         $return = $this->Behaviors->callFinder('noSlug', [$query, []]);
         $this->assertSame($query, $return);
+        $sql = $query->sql();
+        $this->assertMatchesRegularExpression('/slug[^ ]+ IS NULL/', $sql);
     }
 
     /**
@@ -327,8 +309,13 @@ class BehaviorRegistryTest extends TestCase
         $this->expectException(BadMethodCallException::class);
         $this->expectExceptionMessage('Cannot call "slugify" it does not belong to any attached behavior.');
         $this->Behaviors->load('Sluggable');
+
+        $this->assertTrue($this->Behaviors->hasMethod('slugify'));
+        $this->assertTrue($this->Behaviors->hasMethod('camelCase'));
         $this->Behaviors->unload('Sluggable');
 
+        $this->assertFalse($this->Behaviors->hasMethod('slugify'), 'should not have method anymore');
+        $this->assertFalse($this->Behaviors->hasMethod('camelCase'), 'should not have method anymore');
         $this->Behaviors->call('slugify');
     }
 
@@ -340,8 +327,10 @@ class BehaviorRegistryTest extends TestCase
         $this->expectException(BadMethodCallException::class);
         $this->expectExceptionMessage('Cannot call finder "noslug" it does not belong to any attached behavior.');
         $this->Behaviors->load('Sluggable');
+        $this->assertTrue($this->Behaviors->hasFinder('noSlug'));
         $this->Behaviors->unload('Sluggable');
 
+        $this->assertFalse($this->Behaviors->hasFinder('noSlug'));
         $this->Behaviors->callFinder('noSlug');
     }
 
@@ -377,8 +366,8 @@ class BehaviorRegistryTest extends TestCase
      */
     public function testUnloadUnknown(): void
     {
-        $this->expectException(MissingBehaviorException::class);
-        $this->expectExceptionMessage('Behavior class FooBehavior could not be found.');
+        $this->expectException(RuntimeException::class);
+        $this->expectExceptionMessage('Unknown object "Foo"');
         $this->Behaviors->unload('Foo');
     }
 

+ 14 - 0
tests/TestCase/ORM/TableTest.php

@@ -1790,6 +1790,20 @@ class TableTest extends TestCase
     }
 
     /**
+     * Test removing a behavior from a table clears the method map for the behavior
+     */
+    public function testRemoveBehaviorMethodMapCleared(): void
+    {
+        $table = new Table(['table' => 'articles']);
+        $table->addBehavior('Sluggable');
+        $this->assertTrue($table->behaviors()->hasMethod('slugify'), 'slugify should be mapped');
+        $this->assertSame('foo-bar', $table->slugify('foo bar'));
+
+        $table->removeBehavior('Sluggable');
+        $this->assertFalse($table->behaviors()->hasMethod('slugify'), 'slugify should not be callable');
+    }
+
+    /**
      * Test adding multiple behaviors to a table.
      */
     public function testAddBehaviors(): void

+ 23 - 0
tests/TestCase/View/Helper/FormHelperTest.php

@@ -7740,6 +7740,7 @@ class FormHelperTest extends TestCase
             ->notEmptyString('email', 'Custom error message')
             ->requirePresence('password')
             ->alphaNumeric('password')
+            ->requirePresence('accept_tos')
             ->notBlank('phone');
 
         $table = $this->getTableLocator()->get('Contacts', [
@@ -7809,6 +7810,28 @@ class FormHelperTest extends TestCase
             ],
         ];
         $this->assertHtml($expected, $result);
+
+        $result = $this->Form->control('accept_tos', ['type' => 'checkbox']);
+        $expected = [
+            ['input' => ['type' => 'hidden', 'name' => 'accept_tos', 'value' => '0']],
+            'label' => ['for' => 'accept-tos'],
+            [
+                'input' => [
+                    'aria-required' => 'true',
+                    'required' => 'required',
+                    'type' => 'checkbox',
+                    'name' => 'accept_tos',
+                    'id' => 'accept-tos',
+                    'value' => '1',
+                    'data-validity-message' => 'This field cannot be left empty',
+                    'oninput' => 'this.setCustomValidity(&#039;&#039;)',
+                    'oninvalid' => 'this.setCustomValidity(&#039;&#039;); if (!this.checked) this.setCustomValidity(this.dataset.validityMessage)',
+                ],
+            ],
+            'Accept Tos',
+            '/label',
+        ];
+        $this->assertHtml($expected, $result);
     }
 
     /**

+ 6 - 1
tests/test_app/TestApp/Model/Behavior/SluggableBehavior.php

@@ -27,7 +27,7 @@ use Cake\Utility\Text;
 
 class SluggableBehavior extends Behavior
 {
-    public function beforeFind(EventInterface $event, Query $query, array $options = []): Query
+    public function beforeFind(EventInterface $event, Query $query, $options = []): Query
     {
         $query->where(['slug' => 'test']);
 
@@ -45,4 +45,9 @@ class SluggableBehavior extends Behavior
     {
         return Text::slug($value);
     }
+
+    public function camelCase(): string
+    {
+        return 'camelCase';
+    }
 }