Browse Source

Merge branch 'master' into 3.1

Conflicts:
	VERSION.txt
	src/ORM/TableRegistry.php
	tests/TestCase/ORM/TableRegistryTest.php
Jose Lorenzo Rodriguez 10 years ago
parent
commit
655dafd0e1

+ 1 - 0
src/Database/Expression/OrderByExpression.php

@@ -15,6 +15,7 @@
 namespace Cake\Database\Expression;
 
 use Cake\Database\ExpressionInterface;
+use Cake\Database\Expression\IdentifierExpression;
 use Cake\Database\ValueBinder;
 
 /**

+ 10 - 2
src/Database/Expression/QueryExpression.php

@@ -587,11 +587,19 @@ class QueryExpression implements ExpressionInterface, Countable
         }
 
         if ($operator === 'is' && $value === null) {
-            return new UnaryExpression('IS NULL', $expression, UnaryExpression::POSTFIX);
+            return new UnaryExpression(
+                'IS NULL',
+                new IdentifierExpression($expression),
+                UnaryExpression::POSTFIX
+            );
         }
 
         if ($operator === 'is not' && $value === null) {
-            return new UnaryExpression('IS NOT NULL', $expression, UnaryExpression::POSTFIX);
+            return new UnaryExpression(
+                'IS NOT NULL',
+                new IdentifierExpression($expression),
+                UnaryExpression::POSTFIX
+            );
         }
 
         if ($operator === 'is' && $value !== null) {

+ 7 - 0
src/Database/IdentifierQuoter.php

@@ -206,6 +206,9 @@ class IdentifierQuoter
     /**
      * Quotes identifiers in "order by" expression objects
      *
+     * Strings with spaces are treated as literal expressions
+     * and will not have identifiers quoted.
+     *
      * @param \Cake\Database\Expression\OrderByExpression $expression The expression to quote.
      * @return void
      */
@@ -214,6 +217,10 @@ class IdentifierQuoter
         $expression->iterateParts(function ($part, &$field) {
             if (is_string($field)) {
                 $field = $this->_driver->quoteIdentifier($field);
+                return $part;
+            }
+            if (is_string($part) && strpos($part, ' ') === false) {
+                return $this->_driver->quoteIdentifier($part);
             }
             return $part;
         });

+ 6 - 2
src/Datasource/EntityTrait.php

@@ -423,10 +423,14 @@ trait EntityTrait
         $result = [];
         foreach ($this->visibleProperties() as $property) {
             $value = $this->get($property);
-            if (is_array($value) && isset($value[0]) && $value[0] instanceof EntityInterface) {
+            if (is_array($value)) {
                 $result[$property] = [];
                 foreach ($value as $k => $entity) {
-                    $result[$property][$k] = $entity->toArray();
+                    if ($entity instanceof EntityInterface) {
+                        $result[$property][$k] = $entity->toArray();
+                    } else {
+                        $result[$property][$k] = $entity;
+                    }
                 }
             } elseif ($value instanceof EntityInterface) {
                 $result[$property] = $value->toArray();

+ 7 - 5
src/Filesystem/File.php

@@ -14,6 +14,8 @@
  */
 namespace Cake\Filesystem;
 
+use finfo;
+
 /**
  * Convenience class for reading, writing and appending to files.
  *
@@ -554,13 +556,13 @@ class File
         if (!$this->exists()) {
             return false;
         }
-        if (function_exists('finfo_open')) {
-            $finfo = finfo_open(FILEINFO_MIME);
-            $finfo = finfo_file($finfo, $this->pwd());
-            if (!$finfo) {
+        if (class_exists('finfo')) {
+            $finfo = new finfo(FILEINFO_MIME);
+            $type = $finfo->file($this->pwd());
+            if (!$type) {
                 return false;
             }
-            list($type) = explode(';', $finfo);
+            list($type) = explode(';', $type);
             return $type;
         }
         if (function_exists('mime_content_type')) {

+ 14 - 11
src/I18n/I18n.php

@@ -113,24 +113,16 @@ class I18n
      * @param string|null $locale The locale for the translator.
      * @param callable|null $loader A callback function or callable class responsible for
      * constructing a translations package instance.
-     * @return \Aura\Intl\Translator The configured translator.
+     * @return \Aura\Intl\Translator|void The configured translator.
      */
     public static function translator($name = 'default', $locale = null, callable $loader = null)
     {
         if ($loader !== null) {
-            $packages = static::translators()->getPackages();
             $locale = $locale ?: static::locale();
 
-            if ($name !== 'default') {
-                $loader = function () use ($loader) {
-                    $package = $loader();
-                    if (!$package->getFallback()) {
-                        $package->setFallback('default');
-                    }
-                    return $package;
-                };
-            }
+            $loader = static::translators()->setLoaderFallback($name, $loader);
 
+            $packages = static::translators()->getPackages();
             $packages->set($name, $locale, $loader);
             return;
         }
@@ -261,6 +253,17 @@ class I18n
     }
 
     /**
+     * Set if the domain fallback is used.
+     *
+     * @param bool $enable flag to enable or disable fallback
+     * @return void
+     */
+    public static function useFallback($enable = true)
+    {
+        static::translators()->useFallback($enable);
+    }
+
+    /**
      * Destroys all translator instances and creates a new empty translations
      * collection.
      *

+ 43 - 9
src/I18n/TranslatorRegistry.php

@@ -43,6 +43,13 @@ class TranslatorRegistry extends TranslatorLocator
     protected $_defaultFormatter = 'default';
 
     /**
+     * Use fallback-domain for translation loaders.
+     *
+     * @var bool
+     */
+    protected $_useFallback = true;
+
+    /**
      * A CacheEngine object that is used to remember translator across
      * requests.
      *
@@ -155,6 +162,17 @@ class TranslatorRegistry extends TranslatorLocator
     }
 
     /**
+     * Set if the default domain fallback is used.
+     *
+     * @param bool $enable flag to enable or disable fallback
+     * @return void
+     */
+    public function useFallback($enable = true)
+    {
+        $this->_useFallback = $enable;
+    }
+
+    /**
      * Returns a new translator instance for the given name and locale
      * based of conventions.
      *
@@ -212,17 +230,33 @@ class TranslatorRegistry extends TranslatorLocator
             };
         }
 
-        if ($name !== 'default') {
-            $loader = function () use ($loader) {
-                $package = $loader();
-                if (!$package->getFallback()) {
-                    $package->setFallback('default');
-                }
-                return $package;
-            };
-        }
+        $loader = $this->setLoaderFallback($name, $loader);
 
         $this->packages->set($name, $locale, $loader);
         return parent::get($name, $locale);
     }
+
+    /**
+     * Set domain fallback for loader.
+     *
+     * @param string $name The name of the loader domain
+     * @param callable $loader invokable loader
+     * @return callable loader
+     */
+    public function setLoaderFallback($name, callable $loader)
+    {
+        $fallbackDomain = 'default';
+        if (!$this->_useFallback || $name === $fallbackDomain) {
+            return $loader;
+        }
+        $loader = function () use ($loader, $fallbackDomain) {
+            $package = $loader();
+            if (!$package->getFallback()) {
+                $package->setFallback($fallbackDomain);
+            }
+            return $package;
+        };
+
+        return $loader;
+    }
 }

+ 0 - 17
src/Network/Request.php

@@ -673,23 +673,6 @@ class Request implements ArrayAccess
     }
 
     /**
-     * Detects if a URL extension is present.
-     *
-     * @param array $detect Detector options array.
-     * @return bool Whether or not the request is the type you are checking.
-     */
-    protected function _extensionDetector($detect)
-    {
-        if (is_string($detect['extension'])) {
-            $detect['extension'] = [$detect['extension']];
-        }
-        if (in_array($this->params['_ext'], $detect['extension'])) {
-            return true;
-        }
-        return false;
-    }
-
-    /**
      * Detects if a specific accept header is present.
      *
      * @param array $detect Detector options array.

+ 1 - 1
src/ORM/Association/SelectableAssociationTrait.php

@@ -234,7 +234,7 @@ trait SelectableAssociationTrait
             $filterQuery->offset(null);
         }
 
-        $keys = (array)$query->repository()->primaryKey();
+        $keys = (array)$this->source()->primaryKey();
 
         if ($this->type() === $this::MANY_TO_ONE) {
             $keys = (array)$this->foreignKey();

+ 8 - 7
src/ORM/Behavior/TreeBehavior.php

@@ -737,7 +737,7 @@ class TreeBehavior extends Behavior
 
         $node = $this->_scope($this->_table->find())
             ->select($fields)
-            ->where([$this->_table->alias() . '.' . $primaryKey => $id])
+            ->where([$this->_table->aliasField($primaryKey) => $id])
             ->first();
 
         if (!$node) {
@@ -772,19 +772,20 @@ class TreeBehavior extends Behavior
     {
         $config = $this->config();
         list($parent, $left, $right) = [$config['parent'], $config['left'], $config['right']];
-        $pk = (array)$this->_table->primaryKey();
+        $primaryKey = $this->_getPrimaryKey();
+        $aliasedPrimaryKey = $this->_table->aliasField($primaryKey);
 
         $query = $this->_scope($this->_table->query())
-            ->select($pk)
-            ->where([$parent . ' IS' => $parentId])
-            ->order($pk)
+            ->select([$aliasedPrimaryKey])
+            ->where([$this->_table->aliasField($parent) . ' IS' => $parentId])
+            ->order([$aliasedPrimaryKey])
             ->hydrate(false);
 
         $leftCounter = $counter;
         $nextLevel = $level + 1;
         foreach ($query as $row) {
             $counter++;
-            $counter = $this->_recoverTree($counter, $row[$pk[0]], $nextLevel);
+            $counter = $this->_recoverTree($counter, $row[$primaryKey], $nextLevel);
         }
 
         if ($parentId === null) {
@@ -798,7 +799,7 @@ class TreeBehavior extends Behavior
 
         $this->_table->updateAll(
             $fields,
-            [$pk[0] => $parentId]
+            [$primaryKey => $parentId]
         );
 
         return $counter + 1;

+ 4 - 1
src/ORM/EagerLoader.php

@@ -287,7 +287,10 @@ class EagerLoader
                 $options = isset($options['config']) ?
                     $options['config'] + $options['associations'] :
                     $options;
-                $options = $this->_reformatContain($options, []);
+                $options = $this->_reformatContain(
+                    $options,
+                    isset($pointer[$table]) ? $pointer[$table] : []
+                );
             }
 
             if ($options instanceof Closure) {

+ 4 - 3
src/ORM/Locator/TableLocator.php

@@ -142,6 +142,10 @@ class TableLocator implements LocatorInterface
         list(, $classAlias) = pluginSplit($alias);
         $options = ['alias' => $classAlias] + $options;
 
+        if (isset($this->_config[$alias])) {
+            $options += $this->_config[$alias];
+        }
+
         if (empty($options['className'])) {
             $options['className'] = Inflector::camelize($alias);
         }
@@ -156,9 +160,6 @@ class TableLocator implements LocatorInterface
             $options['className'] = 'Cake\ORM\Table';
         }
 
-        if (isset($this->_config[$alias])) {
-            $options += $this->_config[$alias];
-        }
         if (empty($options['connection'])) {
             $connectionName = $options['className']::defaultConnectionName();
             $options['connection'] = ConnectionManager::get($connectionName);

+ 3 - 1
src/Validation/Validation.php

@@ -654,7 +654,9 @@ class Validation
         $defaults = ['in' => null, 'max' => null, 'min' => null];
         $options += $defaults;
 
-        $check = array_filter((array)$check);
+        $check = array_filter((array)$check, function ($value) {
+            return ($value || is_numeric($value));
+        });
         if (empty($check)) {
             return false;
         }

+ 9 - 2
src/Validation/ValidatorAwareTrait.php

@@ -15,7 +15,6 @@
 namespace Cake\Validation;
 
 use Cake\Event\EventDispatcherInterface;
-use Cake\Validation\Validator;
 
 /**
  * A trait that provides methods for building and
@@ -36,6 +35,14 @@ use Cake\Validation\Validator;
  */
 trait ValidatorAwareTrait
 {
+
+    /**
+     * Validator class.
+     *
+     * @var string
+     */
+    protected $_validatorClass = '\Cake\Validation\Validator';
+
     /**
      * A list of validation objects indexed by name
      *
@@ -95,7 +102,7 @@ trait ValidatorAwareTrait
         }
 
         if ($validator === null) {
-            $validator = new Validator();
+            $validator = new $this->_validatorClass;
             $validator = $this->{'validation' . ucfirst($name)}($validator);
             if ($this instanceof EventDispatcherInterface) {
                 $this->dispatchEvent('Model.buildValidator', compact('validator', 'name'));

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

@@ -1450,7 +1450,7 @@ class FormHelper extends Helper
         $radio = $this->widget('radio', $attributes);
 
         $hidden = '';
-        if ($hiddenField && (!isset($value) || $value === '')) {
+        if ($hiddenField) {
             $hidden = $this->hidden($fieldName, [
                 'value' => '',
                 'form' => isset($attributes['form']) ? $attributes['form'] : null,

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

@@ -428,7 +428,11 @@ class PaginatorHelper extends Helper
         if (!empty($this->_config['options']['url'])) {
             $url = array_merge($url, $this->_config['options']['url']);
         }
-        $url = array_merge(array_filter($url), $options);
+
+        $url = array_filter($url, function ($value) {
+            return ($value || is_numeric($value));
+        });
+        $url = array_merge($url, $options);
 
         if (!empty($url['page']) && $url['page'] == 1) {
             $url['page'] = null;

+ 47 - 0
tests/Fixture/FeaturedTagsFixture.php

@@ -0,0 +1,47 @@
+<?php
+/**
+ * CakePHP(tm) Tests <http://book.cakephp.org/3.0/en/development/testing.html>
+ * Copyright (c) Cake Software Foundation, Inc. (http://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. (http://cakefoundation.org)
+ * @link          http://book.cakephp.org/3.0/en/development/testing.html CakePHP(tm) Tests
+ * @since         3.0.7
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\Fixture;
+
+use Cake\TestSuite\Fixture\TestFixture;
+
+/**
+ * Class FeaturedTagsFixture
+ *
+ */
+class FeaturedTagsFixture extends TestFixture
+{
+
+    /**
+     * fields property
+     *
+     * @var array
+     */
+    public $fields = [
+        'tag_id' => ['type' => 'integer', 'null' => false],
+        'priority' => ['type' => 'integer', 'null' => false],
+        '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['tag_id']]]
+    ];
+
+    /**
+     * records property
+     *
+     * @var array
+     */
+    public $records = [
+        ['priority' => 1],
+        ['priority' => 2],
+        ['priority' => 3]
+    ];
+}

+ 2 - 2
tests/Fixture/SpecialTagsFixture.php

@@ -47,7 +47,7 @@ class SpecialTagsFixture extends TestFixture
      * @var array
      */
     public $records = [
-        ['article_id' => 1, 'tag_id' => 3, 'highlighted' => false, 'highlighted_time' => null, 'author_id' => null],
-        ['article_id' => 2, 'tag_id' => 1, 'highlighted' => true, 'highlighted_time' => '2014-06-01 10:10:00', 'author_id' => null]
+        ['article_id' => 1, 'tag_id' => 3, 'highlighted' => false, 'highlighted_time' => null, 'author_id' => 1],
+        ['article_id' => 2, 'tag_id' => 1, 'highlighted' => true, 'highlighted_time' => '2014-06-01 10:10:00', 'author_id' => 2]
     ];
 }

+ 48 - 0
tests/Fixture/TagsTranslationsFixture.php

@@ -0,0 +1,48 @@
+<?php
+/**
+ * CakePHP(tm) Tests <http://book.cakephp.org/3.0/en/development/testing.html>
+ * Copyright (c) Cake Software Foundation, Inc. (http://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. (http://cakefoundation.org)
+ * @link          http://book.cakephp.org/3.0/en/development/testing.html CakePHP(tm) Tests
+ * @since         3.0.7
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\Fixture;
+
+use Cake\TestSuite\Fixture\TestFixture;
+
+/**
+ * Class TagsTranslationsFixture
+ *
+ */
+class TagsTranslationsFixture extends TestFixture
+{
+
+    /**
+     * fields property
+     *
+     * @var array
+     */
+    public $fields = [
+        'id' => ['type' => 'integer', 'null' => false, 'autoIncrement' => false],
+        'locale' => ['type' => 'string', 'null' => false],
+        'name' => ['type' => 'string', 'null' => false],
+        '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]]
+    ];
+
+    /**
+     * records property
+     *
+     * @var array
+     */
+    public $records = [
+        ['locale' => 'en_us', 'name' => 'tag 1 translated into en_us'],
+        ['locale' => 'en_us', 'name' => 'tag 2 translated into en_us'],
+        ['locale' => 'en_us', 'name' => 'tag 3 translated into en_us']
+    ];
+}

+ 0 - 1
tests/TestCase/Database/Expression/QueryExpressionTest.php

@@ -24,7 +24,6 @@ use Cake\TestSuite\TestCase;
  */
 class QueryExpressionTest extends TestCase
 {
-
     /**
      * Test and() and or() calls work transparently
      *

+ 40 - 0
tests/TestCase/Database/QueryTest.php

@@ -521,6 +521,29 @@ class QueryTest extends TestCase
     }
 
     /**
+     * Test that unary expressions in selects are built correctly.
+     *
+     * @return void
+     */
+    public function testSelectWhereUnary()
+    {
+        $query = new Query($this->connection);
+        $result = $query
+            ->select(['id'])
+            ->from('articles')
+            ->where([
+                'title is not' => null,
+                'user_id is' => null
+            ])
+            ->sql();
+        $this->assertQuotedQuery(
+            'SELECT <id> FROM <articles> WHERE \(\(<title>\) IS NOT NULL AND \(<user_id>\) IS NULL\)',
+            $result,
+            true
+        );
+    }
+
+    /**
      * Tests selecting with conditions and specifying types for those
      *
      * @return void
@@ -1413,6 +1436,23 @@ class QueryTest extends TestCase
     }
 
     /**
+     * Test that order() being a string works.
+     *
+     * @return void
+     */
+    public function testSelectOrderByString()
+    {
+        $query = new Query($this->connection);
+        $query->select(['id'])
+            ->from('articles')
+            ->order('id asc');
+        $result = $query->execute();
+        $this->assertEquals(['id' => 1], $result->fetch('assoc'));
+        $this->assertEquals(['id' => 2], $result->fetch('assoc'));
+        $this->assertEquals(['id' => 3], $result->fetch('assoc'));
+    }
+
+    /**
      * Tests that group by fields can be passed similar to select fields
      * and that it sends the correct query to the database
      *

+ 26 - 0
tests/TestCase/I18n/I18nTest.php

@@ -514,6 +514,32 @@ class I18nTest extends TestCase
     }
 
     /**
+     * Test that the translation fallback can be disabled
+     *
+     * @return void
+     */
+    public function testFallbackTranslatorDisabled()
+    {
+        I18n::useFallback(false);
+
+        I18n::translator('default', 'fr_FR', function () {
+            $package = new Package('default');
+            $package->setMessages(['Dog' => 'Le bark']);
+            return $package;
+        });
+
+        I18n::translator('custom', 'fr_FR', function () {
+            $package = new Package('default');
+            $package->setMessages(['Cow' => 'Le moo']);
+            return $package;
+        });
+
+        $translator = I18n::translator('custom', 'fr_FR');
+        $this->assertEquals('Le moo', $translator->translate('Cow'));
+        $this->assertEquals('Dog', $translator->translate('Dog'));
+    }
+
+    /**
      * Tests that it is possible to register a generic translators factory for a domain
      * instead of having to create them manually
      *

+ 24 - 0
tests/TestCase/ORM/EntityTest.php

@@ -812,6 +812,30 @@ class EntityTest extends TestCase
     }
 
     /**
+     * Tests that an entity with entities and other misc types can be properly toArray'd
+     *
+     * @return void
+     */
+    public function testToArrayMixed()
+    {
+        $test = new Entity([
+            'id' => 1,
+            'foo' => [
+                new Entity(['hi' => 'test']),
+                'notentity' => 1
+            ]
+        ]);
+        $expected = [
+            'id' => 1,
+            'foo' => [
+                ['hi' => 'test'],
+                'notentity' => 1
+            ]
+        ];
+        $this->assertEquals($expected, $test->toArray());
+    }
+
+    /**
      * Test that get accessors are called when converting to arrays.
      *
      * @return void

+ 15 - 1
tests/TestCase/ORM/Locator/TableLocatorTest.php

@@ -60,7 +60,7 @@ class TableLocatorTest extends TestCase
     {
         parent::setUp();
         Configure::write('App.namespace', 'TestApp');
-        
+
         $this->_locator = new TableLocator;
     }
 
@@ -222,6 +222,20 @@ class TableLocatorTest extends TestCase
     }
 
     /**
+     * Test that get() uses config data `className` set with config()
+     *
+     * @return void
+     */
+    public function testGetWithConfigClassName()
+    {
+        $this->_locator->config('MyUsersTableAlias', [
+            'className' => '\Cake\Test\TestCase\ORM\Locator\MyUsersTable',
+        ]);
+        $result = $this->_locator->get('MyUsersTableAlias');
+        $this->assertInstanceOf('\Cake\Test\TestCase\ORM\Locator\MyUsersTable', $result, 'Should use config() data className option.');
+    }
+
+    /**
      * Test get with config throws an exception if the alias exists already.
      *
      * @expectedException \RuntimeException

+ 68 - 0
tests/TestCase/ORM/QueryRegressionTest.php

@@ -43,6 +43,8 @@ class QueryRegressionTest extends TestCase
         'core.special_tags',
         'core.translates',
         'core.authors_tags',
+        'core.featured_tags',
+        'core.tags_translations',
     ];
 
     /**
@@ -524,6 +526,43 @@ class QueryRegressionTest extends TestCase
     }
 
     /**
+     * Tests that finding on a table with a primary key other than `id` will work
+     * seamlessly with either select or subquery.
+     *
+     * @see https://github.com/cakephp/cakephp/issues/6781
+     * @return void
+     */
+    public function testDeepHasManyEitherStrategy()
+    {
+        $tags = TableRegistry::get('Tags');
+        $featuredTags = TableRegistry::get('FeaturedTags');
+        $featuredTags->belongsTo('Tags');
+
+        $tags->hasMany('TagsTranslations', [
+            'foreignKey' => 'id',
+            'strategy' => 'select'
+        ]);
+        $findViaSelect = $featuredTags
+            ->find()
+            ->where(['FeaturedTags.tag_id' => 2])
+            ->contain('Tags.TagsTranslations');
+
+        $tags->hasMany('TagsTranslations', [
+            'foreignKey' => 'id',
+            'strategy' => 'subquery'
+        ]);
+        $findViaSubquery = $featuredTags
+            ->find()
+            ->where(['FeaturedTags.tag_id' => 2])
+            ->contain('Tags.TagsTranslations');
+
+        $expected = [2 => 'tag 2 translated into en_us'];
+
+        $this->assertEquals($expected, $findViaSelect->combine('tag_id', 'tag.tags_translations.0.name')->toArray());
+        $this->assertEquals($expected, $findViaSubquery->combine('tag_id', 'tag.tags_translations.0.name')->toArray());
+    }
+
+    /**
      * Tests that getting the count of a query having containments return
      * the correct results
      *
@@ -929,4 +968,33 @@ class QueryRegressionTest extends TestCase
         $this->assertNotEmpty($result->tags, 'Missing tags');
         $this->assertNotEmpty($result->tags[0]->_joinData, 'Missing join data');
     }
+
+    /**
+     * Tests that it is possible to use matching with dot notation
+     * even when part of the part of the path in the dot notation is
+     * shared for two different calls
+     *
+     * @return void
+     */
+    public function testDotNotationNotOverride()
+    {
+        $table = TableRegistry::get('Comments');
+        $articles = $table->belongsTo('Articles');
+        $specialTags = $articles->hasMany('SpecialTags');
+        $specialTags->belongsTo('Authors');
+        $specialTags->belongsTo('Tags');
+
+        $results = $table
+            ->find()
+            ->select(['name' => 'Authors.name', 'tag' => 'Tags.name'])
+            ->matching('Articles.SpecialTags.Tags')
+            ->matching('Articles.SpecialTags.Authors', function ($q) {
+                return $q->where(['Authors.id' => 2]);
+            })
+            ->distinct()
+            ->hydrate(false)
+            ->toArray();
+
+        $this->assertEquals([['name' => 'nate', 'tag' => 'tag1']], $results);
+    }
 }

+ 7 - 5
tests/TestCase/Validation/ValidationTest.php

@@ -2141,8 +2141,8 @@ class ValidationTest extends TestCase
         $this->assertFalse(Validation::multiple(''));
         $this->assertFalse(Validation::multiple(null));
         $this->assertFalse(Validation::multiple([]));
-        $this->assertFalse(Validation::multiple([0]));
-        $this->assertFalse(Validation::multiple(['0']));
+        $this->assertTrue(Validation::multiple([0]));
+        $this->assertTrue(Validation::multiple(['0']));
 
         $this->assertTrue(Validation::multiple([0, 3, 4, 5], ['in' => range(0, 10)]));
         $this->assertFalse(Validation::multiple([0, 15, 20, 5], ['in' => range(0, 10)]));
@@ -2150,8 +2150,9 @@ class ValidationTest extends TestCase
         $this->assertFalse(Validation::multiple(['boo', 'foo', 'bar'], ['in' => ['foo', 'bar', 'baz']]));
         $this->assertFalse(Validation::multiple(['foo', '1bar'], ['in' => range(0, 10)]));
 
-        $this->assertTrue(Validation::multiple([0, 5, 10, 11], ['max' => 3]));
-        $this->assertFalse(Validation::multiple([0, 5, 10, 11, 55], ['max' => 3]));
+        $this->assertFalse(Validation::multiple([1, 5, 10, 11], ['max' => 3]));
+        $this->assertTrue(Validation::multiple([0, 5, 10, 11], ['max' => 4]));
+        $this->assertFalse(Validation::multiple([0, 5, 10, 11, 55], ['max' => 4]));
         $this->assertTrue(Validation::multiple(['foo', 'bar', 'baz'], ['max' => 3]));
         $this->assertFalse(Validation::multiple(['foo', 'bar', 'baz', 'squirrel'], ['max' => 3]));
 
@@ -2166,7 +2167,8 @@ class ValidationTest extends TestCase
         $this->assertFalse(Validation::multiple([0, 5, 9, 8, 6, 2, 1], ['in' => range(0, 10), 'max' => 5]));
         $this->assertFalse(Validation::multiple([0, 5, 9, 8, 11], ['in' => range(0, 10), 'max' => 5]));
 
-        $this->assertFalse(Validation::multiple([0, 5, 9], ['in' => range(0, 10), 'max' => 5, 'min' => 3]));
+        $this->assertTrue(Validation::multiple([0, 5, 9], ['in' => range(0, 10), 'max' => 5, 'min' => 3]));
+        $this->assertFalse(Validation::multiple(['', '5', '9'], ['max' => 5, 'min' => 3]));
         $this->assertFalse(Validation::multiple([0, 5, 9, 8, 6, 2, 1], ['in' => range(0, 10), 'max' => 5, 'min' => 2]));
         $this->assertFalse(Validation::multiple([0, 5, 9, 8, 11], ['in' => range(0, 10), 'max' => 5, 'min' => 2]));
 

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

@@ -3802,6 +3802,29 @@ class FormHelperTest extends TestCase
         $result = $this->Form->input('test', [
             'type' => 'radio',
             'options' => ['A', 'B'],
+            'value' => '0'
+        ]);
+        $expected = [
+            ['div' => ['class' => 'input radio']],
+                '<label',
+                'Test',
+                '/label',
+                ['input' => ['type' => 'hidden', 'name' => 'test', 'value' => '']],
+                ['label' => ['for' => 'test-0']],
+                    ['input' => ['type' => 'radio', 'checked' => 'checked', 'name' => 'test', 'value' => '0', 'id' => 'test-0']],
+                    'A',
+                '/label',
+                ['label' => ['for' => 'test-1']],
+                    ['input' => ['type' => 'radio', 'name' => 'test', 'value' => '1', 'id' => 'test-1']],
+                    'B',
+                '/label',
+            '/div',
+        ];
+        $this->assertHtml($expected, $result);
+
+        $result = $this->Form->input('test', [
+            'type' => 'radio',
+            'options' => ['A', 'B'],
             'label' => false
         ]);
         $expected = [
@@ -3897,6 +3920,26 @@ class FormHelperTest extends TestCase
     }
 
     /**
+     * testRadio method
+     *
+     * Test radio element set generation
+     *
+     * @return void
+     */
+    public function testRadioOutOfRange()
+    {
+        $result = $this->Form->radio('Model.field', ['v' => 'value'], ['value' => 'nope']);
+        $expected = [
+            'input' => ['type' => 'hidden', 'name' => 'Model[field]', 'value' => ''],
+            'label' => ['for' => 'model-field-v'],
+            ['input' => ['type' => 'radio', 'name' => 'Model[field]', 'value' => 'v', 'id' => 'model-field-v']],
+            'value',
+            '/label'
+        ];
+        $this->assertHtml($expected, $result);
+    }
+
+    /**
      * testSelect method
      *
      * Test select element generation.

+ 9 - 9
tests/TestCase/View/Helper/PaginatorHelperTest.php

@@ -799,13 +799,13 @@ class PaginatorHelperTest extends TestCase
         ];
 
         $this->Paginator->request->params['pass'] = [2];
-        $this->Paginator->request->query = ['page' => 1, 'foo' => 'bar', 'x' => 'y'];
+        $this->Paginator->request->query = ['page' => 1, 'foo' => 'bar', 'x' => 'y', 'num' => 0, 'empty' => ''];
         $this->View->request = $this->Paginator->request;
         $this->Paginator = new PaginatorHelper($this->View);
 
         $result = $this->Paginator->sort('title');
         $expected = [
-            'a' => ['href' => '/articles/index/2?foo=bar&amp;x=y&amp;sort=title&amp;direction=asc'],
+            'a' => ['href' => '/articles/index/2?foo=bar&amp;x=y&amp;num=0&amp;sort=title&amp;direction=asc'],
             'Title',
             '/a'
         ];
@@ -814,19 +814,19 @@ class PaginatorHelperTest extends TestCase
         $result = $this->Paginator->numbers();
         $expected = [
             ['li' => ['class' => 'active']], '<a href=""', '1', '/a', '/li',
-            ['li' => []], ['a' => ['href' => '/articles/index/2?page=2&amp;foo=bar&amp;x=y']], '2', '/a', '/li',
-            ['li' => []], ['a' => ['href' => '/articles/index/2?page=3&amp;foo=bar&amp;x=y']], '3', '/a', '/li',
-            ['li' => []], ['a' => ['href' => '/articles/index/2?page=4&amp;foo=bar&amp;x=y']], '4', '/a', '/li',
-            ['li' => []], ['a' => ['href' => '/articles/index/2?page=5&amp;foo=bar&amp;x=y']], '5', '/a', '/li',
-            ['li' => []], ['a' => ['href' => '/articles/index/2?page=6&amp;foo=bar&amp;x=y']], '6', '/a', '/li',
-            ['li' => []], ['a' => ['href' => '/articles/index/2?page=7&amp;foo=bar&amp;x=y']], '7', '/a', '/li',
+            ['li' => []], ['a' => ['href' => '/articles/index/2?page=2&amp;foo=bar&amp;x=y&amp;num=0']], '2', '/a', '/li',
+            ['li' => []], ['a' => ['href' => '/articles/index/2?page=3&amp;foo=bar&amp;x=y&amp;num=0']], '3', '/a', '/li',
+            ['li' => []], ['a' => ['href' => '/articles/index/2?page=4&amp;foo=bar&amp;x=y&amp;num=0']], '4', '/a', '/li',
+            ['li' => []], ['a' => ['href' => '/articles/index/2?page=5&amp;foo=bar&amp;x=y&amp;num=0']], '5', '/a', '/li',
+            ['li' => []], ['a' => ['href' => '/articles/index/2?page=6&amp;foo=bar&amp;x=y&amp;num=0']], '6', '/a', '/li',
+            ['li' => []], ['a' => ['href' => '/articles/index/2?page=7&amp;foo=bar&amp;x=y&amp;num=0']], '7', '/a', '/li',
         ];
         $this->assertHtml($expected, $result);
 
         $result = $this->Paginator->next('Next');
         $expected = [
             'li' => ['class' => 'next'],
-            'a' => ['href' => '/articles/index/2?page=2&amp;foo=bar&amp;x=y', 'rel' => 'next'],
+            'a' => ['href' => '/articles/index/2?page=2&amp;foo=bar&amp;x=y&amp;num=0', 'rel' => 'next'],
             'Next',
             '/a',
             '/li'