Browse Source

Merge branch '4.next' into 5.x

ADmad 3 years ago
parent
commit
e88e4f2799

+ 1 - 0
composer.json

@@ -29,6 +29,7 @@
         "cakephp/chronos": "^2.2",
         "composer/ca-bundle": "^1.2",
         "laminas/laminas-diactoros": "^2.2.2",
+        "laminas/laminas-httphandlerrunner": "^1.1 || ^2.0",
         "league/container": "^4.2.0",
         "psr/container": "^1.1 || ^2.0",
         "psr/http-client": "^1.0",

+ 6 - 0
src/Cache/CacheEngine.php

@@ -18,6 +18,7 @@ namespace Cake\Cache;
 
 use Cake\Core\InstanceConfigTrait;
 use DateInterval;
+use DateTime;
 use Psr\SimpleCache\CacheInterface;
 
 /**
@@ -375,6 +376,11 @@ abstract class CacheEngine implements CacheInterface, CacheEngineInterface
         if (is_int($ttl)) {
             return $ttl;
         }
+        if ($ttl instanceof DateInterval) {
+            return (int)DateTime::createFromFormat('U', '0')
+                ->add($ttl)
+                ->format('U');
+        }
 
         return (int)$ttl->format('%s');
     }

+ 2 - 0
src/Controller/Controller.php

@@ -737,6 +737,8 @@ class Controller implements EventListenerInterface, EventDispatcherInterface
                     return $typeMap[$extType];
                 }
             }
+
+            throw new NotFoundException();
         }
 
         // Use accept header based negotiation.

+ 1 - 1
src/Datasource/EntityTrait.php

@@ -107,7 +107,7 @@ trait EntityTrait
      * not defined in the map will take its value. For example, `'*' => true`
      * means that any field not defined in the map will be accessible by default
      *
-     * @var array<bool>
+     * @var array<string, bool>
      */
     protected array $_accessible = ['*' => true];
 

+ 25 - 11
src/ORM/Association/BelongsToMany.php

@@ -36,6 +36,7 @@ use SplObjectStorage;
  * that contains the association fields between the source and the target table.
  *
  * 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.
  */
 class BelongsToMany extends Association
 {
@@ -1269,29 +1270,42 @@ class BelongsToMany extends Association
 
         /** @var array<string> $keys */
         $keys = array_merge($foreignKey, $assocForeignKey);
-        $deletes = $indexed = $present = [];
+        $deletes = $unmatchedEntityKeys = $present = [];
 
         foreach ($jointEntities as $i => $entity) {
-            /** @psalm-suppress InvalidScalarArgument getForeignKey() returns false */
-            $indexed[$i] = $entity->extract($keys);
-            /** @psalm-suppress InvalidScalarArgument getForeignKey() returns false */
+            $unmatchedEntityKeys[$i] = $entity->extract($keys);
             $present[$i] = array_values($entity->extract($assocForeignKey));
         }
 
-        /** @var \Cake\Datasource\EntityInterface $result */
-        foreach ($existing as $result) {
-            $fields = $result->extract($keys);
+        foreach ($existing as $existingLink) {
+            $existingKeys = $existingLink->extract($keys);
             $found = false;
-            foreach ($indexed as $i => $data) {
-                if ($fields === $data) {
-                    unset($indexed[$i]);
+            foreach ($unmatchedEntityKeys as $i => $unmatchedKeys) {
+                $matched = false;
+                foreach ($keys as $key) {
+                    if (is_object($unmatchedKeys[$key]) && is_object($existingKeys[$key])) {
+                        // If both sides are an object then use == so that value objects
+                        // are seen as equivalent.
+                        $matched = $existingKeys[$key] == $unmatchedKeys[$key];
+                    } else {
+                        // Use strict equality for all other values.
+                        $matched = $existingKeys[$key] === $unmatchedKeys[$key];
+                    }
+                    // Stop checks on first failure.
+                    if (!$matched) {
+                        break;
+                    }
+                }
+                if ($matched) {
+                    // Remove the unmatched entity so we don't look at it again.
+                    unset($unmatchedEntityKeys[$i]);
                     $found = true;
                     break;
                 }
             }
 
             if (!$found) {
-                $deletes[] = $result;
+                $deletes[] = $existingLink;
             }
         }
 

+ 2 - 2
src/ORM/AssociationCollection.php

@@ -350,10 +350,10 @@ class AssociationCollection implements IteratorAggregate
      * array. If true is passed, then it returns all association names
      * in this collection.
      *
-     * @param array|bool $keys the list of association names to normalize
+     * @param array|string|bool $keys the list of association names to normalize
      * @return array
      */
-    public function normalizeKeys(array|bool $keys): array
+    public function normalizeKeys(array|string|bool $keys): array
     {
         if ($keys === true) {
             $keys = $this->keys();

+ 22 - 0
tests/Fixture/CompositeKeyArticlesFixture.php

@@ -0,0 +1,22 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         4.4.1
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\Fixture;
+
+use Cake\TestSuite\Fixture\TestFixture;
+
+class CompositeKeyArticlesFixture extends TestFixture
+{
+    public array $records = [];
+}

+ 22 - 0
tests/Fixture/CompositeKeyArticlesTagsFixture.php

@@ -0,0 +1,22 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         4.4.1
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\Fixture;
+
+use Cake\TestSuite\Fixture\TestFixture;
+
+class CompositeKeyArticlesTagsFixture extends TestFixture
+{
+    public array $records = [];
+}

+ 49 - 0
tests/TestCase/Cache/Engine/CacheEngineTest.php

@@ -0,0 +1,49 @@
+<?php
+declare(strict_types=1);
+
+namespace Cake\Test\TestCase\Cache\Engine;
+
+use Cake\Cache\InvalidArgumentException;
+use Cake\TestSuite\TestCase;
+use DateInterval;
+use TestApp\Cache\Engine\TestAppCacheEngine;
+
+class CacheEngineTest extends TestCase
+{
+    public function durationProvider(): array
+    {
+        return [
+            [null, 10],
+            [2, 2],
+            [new DateInterval('PT1S'), 1],
+            [new DateInterval('P1D'), 86400],
+        ];
+    }
+
+    /**
+     * Test duration with null, int and DateInterval multiple format.
+     *
+     * @dataProvider durationProvider
+     */
+    public function testDuration($ttl, $expected): void
+    {
+        $engine = new TestAppCacheEngine();
+        $engine->setConfig(['duration' => 10]);
+
+        $result = $engine->getDuration($ttl);
+
+        $this->assertSame($result, $expected);
+    }
+
+    /**
+     * Test duration value should be \DateInterval, int or null.
+     */
+    public function testDurationException(): void
+    {
+        $engine = new TestAppCacheEngine();
+
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('TTL values must be one of null, int, \DateInterval');
+        $engine->getDuration('ttl');
+    }
+}

+ 14 - 0
tests/TestCase/Controller/ControllerTest.php

@@ -359,6 +359,20 @@ class ControllerTest extends TestCase
         $this->assertTextStartsWith('<?xml', $response->getBody() . '', 'Body should be xml');
     }
 
+    public function testRenderViewClassesMineExtMissingView()
+    {
+        $request = new ServerRequest([
+            'url' => '/',
+            'environment' => [],
+            'params' => ['plugin' => null, 'controller' => 'ContentTypes', 'action' => 'all', '_ext' => 'json'],
+        ]);
+        $controller = new ContentTypesController($request);
+        $controller->plain();
+
+        $this->expectException(NotFoundException::class);
+        $controller->render();
+    }
+
     /**
      * test view rendering changing response
      */

+ 75 - 0
tests/TestCase/ORM/Association/BelongsToManyTest.php

@@ -22,6 +22,7 @@ use Cake\Database\Expression\QueryExpression;
 use Cake\Datasource\ConnectionManager;
 use Cake\Datasource\EntityInterface;
 use Cake\Event\EventInterface;
+use Cake\I18n\DateTime;
 use Cake\ORM\Association\BelongsTo;
 use Cake\ORM\Association\BelongsToMany;
 use Cake\ORM\Association\HasMany;
@@ -55,6 +56,8 @@ class BelongsToManyTest extends TestCase
         'core.BinaryUuidItems',
         'core.BinaryUuidTags',
         'core.BinaryUuidItemsBinaryUuidTags',
+        'core.CompositeKeyArticles',
+        'core.CompositeKeyArticlesTags',
     ];
 
     /**
@@ -1119,6 +1122,78 @@ class BelongsToManyTest extends TestCase
         $this->assertCount(1, $refresh->binary_uuid_tags, 'One tag should remain');
     }
 
+    public function testReplaceLinksComplexTypeForeignKey()
+    {
+        $articles = $this->fetchTable('CompositeKeyArticles');
+        $tags = $this->fetchTable('Tags');
+
+        $articles->belongsToMany('Tags', [
+            'foreignKey' => ['author_id', 'created'],
+        ]);
+
+        $article = $articles->newEntity([
+            'author_id' => 1,
+            'body' => 'First post',
+            'created' => new DateTime(),
+        ]);
+        $articles->saveOrFail($article);
+        $tag1 = $tags->find()->where(['Tags.name' => 'tag1'])->firstOrFail();
+        $tag2 = $tags->find()->where(['Tags.name' => 'tag2'])->firstOrFail();
+
+        $findArticle = function ($article) use ($articles) {
+            return $articles->find()
+                ->where(['CompositeKeyArticles.author_id' => $article->author_id])
+                ->contain('Tags')
+                ->firstOrFail();
+        };
+
+        $article = $findArticle($article);
+        $this->assertEmpty($article->tags);
+
+        // Create the first link
+        $article = $articles->patchEntity($article, ['tags' => ['_ids' => [$tag1->id]]]);
+        $result = $articles->save($article, ['associated' => 'Tags']);
+        $this->assertNotEmpty($result);
+        $this->assertCount(1, $result->tags);
+        $this->assertEquals($tag1->id, $result->tags[0]->id);
+
+        // Add second tag. Reload tag objects so created fields have different
+        // instances.
+        $article = $findArticle($article);
+        $article = $articles->patchEntity($article, ['tags' => ['_ids' => [$tag1->id, $tag2->id]]]);
+        $result = $articles->save($article, ['associated' => 'Tags']);
+
+        // Check in memory entity.
+        $this->assertNotEmpty($result);
+        $this->assertCount(2, $result->tags);
+        $this->assertEquals('tag1', $result->tags[0]->name);
+        $this->assertEquals('tag2', $result->tags[1]->name);
+
+        // Reload to check persisted state.
+        $result = $findArticle($article);
+        $this->assertNotEmpty($result);
+        $this->assertCount(2, $result->tags);
+        $this->assertEquals('tag1', $result->tags[0]->name);
+        $this->assertEquals('tag2', $result->tags[1]->name);
+    }
+
+    public function testReplaceLinksMissingKeyData()
+    {
+        $articles = $this->fetchTable('Articles');
+        $tags = $this->fetchTable('Tags');
+
+        $articles->belongsToMany('Tags');
+        $article = $articles->find()->firstOrFail();
+
+        $tag1 = $tags->find()->where(['Tags.name' => 'tag1'])->firstOrFail();
+        $tag1->_joinData = new ArticlesTag(['tag_id' => 99]);
+
+        $article->tags = [$tag1];
+        $articles->saveOrFail($article, ['associated' => ['Tags']]);
+
+        $this->assertCount(1, $article->tags);
+    }
+
     /**
      * Provider for empty values
      *

+ 1 - 1
tests/TestCase/Validation/ValidationTest.php

@@ -91,7 +91,7 @@ class ValidationTest extends TestCase
         $this->assertTrue(Validation::notBlank('fooo' . chr(243) . 'blabla'));
         $this->assertTrue(Validation::notBlank('abçďĕʑʘπй'));
         $this->assertTrue(Validation::notBlank('José'));
-        $this->assertTrue(Validation::notBlank(utf8_decode('José')));
+        $this->assertTrue(Validation::notBlank(mb_convert_encoding('José', 'ISO-8859-1', 'UTF-8')));
         $this->assertFalse(Validation::notBlank("\t "));
         $this->assertFalse(Validation::notBlank(''));
     }

+ 52 - 0
tests/schema.php

@@ -922,6 +922,58 @@ return [
         ],
     ],
     [
+        'table' => 'composite_key_articles',
+        'columns' => [
+            'author_id' => [
+                'type' => 'integer',
+                'null' => false,
+            ],
+            'created' => [
+                'type' => 'datetime',
+                'null' => false,
+            ],
+            'body' => [
+                'type' => 'text',
+            ],
+        ],
+        'constraints' => [
+            'composite_article_pk' => [
+                'type' => 'primary',
+                'columns' => [
+                    'author_id',
+                    'created',
+                ],
+            ],
+        ],
+    ],
+    [
+        'table' => 'composite_key_articles_tags',
+        'columns' => [
+            'author_id' => [
+                'type' => 'integer',
+                'null' => false,
+            ],
+            'created' => [
+                'type' => 'datetime',
+                'null' => false,
+            ],
+            'tag_id' => [
+                'type' => 'integer',
+                'null' => false,
+            ],
+        ],
+        'constraints' => [
+            'composite_article_tags_pk' => [
+                'type' => 'primary',
+                'columns' => [
+                    'author_id',
+                    'created',
+                    'tag_id',
+                ],
+            ],
+        ],
+    ],
+    [
         'table' => 'profiles',
         'columns' => [
             'id' => [

+ 11 - 0
tests/test_app/TestApp/Cache/Engine/TestAppCacheEngine.php

@@ -79,4 +79,15 @@ class TestAppCacheEngine extends CacheEngine
     public function clearGroup(string $group): bool
     {
     }
+
+    /**
+     * Return duration method result.
+     *
+     * @param mixed $ttl
+     * @return int
+     */
+    public function getDuration($ttl): int
+    {
+        return $this->duration($ttl);
+    }
 }