diff --git a/.codeclimate.yml b/.codeclimate.yml index 614dd1a..08b73bb 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -1,4 +1,3 @@ ---- engines: duplication: enabled: true @@ -12,9 +11,8 @@ engines: enabled: true phpmd: enabled: true - checks: - CleanCode/StaticAccess: - enabled: false + config: + rulesets: "codesize,design,unusedcode,tests/data/codeclimate/phpmd_ruleset.xml" ratings: paths: - "**.js" diff --git a/.gitignore b/.gitignore index 6b729e6..18e2a78 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ phpunit.phar # ignore sub directory for dev installed apps and extensions /apps /extensions + +# NPM packages +/node_modules diff --git a/.travis.yml b/.travis.yml index f78fb6a..755ad1d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -58,6 +58,44 @@ matrix: services: - mysql - postgresql + - php: 5.4 + addons: + apt: + packages: + - php5.4-gd + - php: 5.5 + addons: + apt: + packages: + - php5.5-gd + - php: 5.6 + addons: + apt: + packages: + - php5.6-gd + # have a separate branch for javascript tests + - language: node_js + node_js: 6 + dist: trusty + # overwrite php related settings + php: + services: + addons: + install: + - travis_retry npm install + # disable xdebug for performance in composer + - phpenv config-rm xdebug.ini || echo "xdebug is not installed" + - travis_retry composer self-update && composer --version + - travis_retry composer global require "fxp/composer-asset-plugin:^1.2.0" --no-plugins + - travis_retry composer update --prefer-dist --no-interaction + before_script: + - node --version + - npm --version + - php --version + - composer --version + script: npm test + after_script: + allow_failures: - php: nightly @@ -70,6 +108,7 @@ cache: directories: - vendor - $HOME/.composer/cache + - $HOME/.npm # try running against postgres 9.3 addons: @@ -84,7 +123,7 @@ install: phpenv config-rm xdebug.ini || echo "xdebug is not installed" fi - travis_retry composer self-update && composer --version - - travis_retry composer global require "fxp/composer-asset-plugin:^1.2.0" + - travis_retry composer global require "fxp/composer-asset-plugin:^1.2.0" --no-plugins - export PATH="$HOME/.composer/vendor/bin:$PATH" # core framework: - travis_retry composer update --prefer-dist --no-interaction diff --git a/contrib/completion/bash/yii b/contrib/completion/bash/yii new file mode 100644 index 0000000..084325a --- /dev/null +++ b/contrib/completion/bash/yii @@ -0,0 +1,58 @@ +# This file implements bash completion for the ./yii command file. +# It completes the commands available by the ./yii command. +# See also: +# - https://debian-administration.org/article/317/An_introduction_to_bash_completion_part_2 on how this works. +# - https://www.gnu.org/software/bash/manual/html_node/Programmable-Completion.html +# - http://www.yiiframework.com/doc-2.0/guide-tutorial-console.html#bash-completion +# +# Usage: +# Temporarily you can source this file in you bash by typing: source yii +# For permanent availability, copy or link this file to /etc/bash_completion.d/ +# + +_yii() +{ + local cur opts yii command + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + yii="${COMP_WORDS[0]}" + + # exit if ./yii does not exist + test -f $yii || return 0 + + # lookup for command + for word in ${COMP_WORDS[@]:1}; do + if [[ $word != -* ]]; then + command=$word + break + fi + done + + [[ $cur == $command ]] && state="command" + [[ $cur != $command ]] && state="option" + [[ $cur = *=* ]] && state="value" + + case $state in + command) + # complete command/route if not given + # fetch available commands from ./yii help/list command + opts=$($yii help/list 2> /dev/null) + ;; + option) + # fetch available options from ./yii help/list-action-options command + opts=$($yii help/list-action-options $command 2> /dev/null | grep -o '^--[a-zA-Z0-9]*') + ;; + value) + # TODO allow normal file completion after an option, e.g. --migrationPath=... + ;; + esac + + # generate completion suggestions + COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) + return 0 + +} + +# register completion for the ./yii command +# you may adjust this line if your command file is named differently +complete -F _yii ./yii yii diff --git a/contrib/completion/zsh/_yii b/contrib/completion/zsh/_yii new file mode 100644 index 0000000..e85ebc3 --- /dev/null +++ b/contrib/completion/zsh/_yii @@ -0,0 +1,38 @@ +#compdef yii + +_yii() { + local state command lastArgument commands options executive + lastArgument=${words[${#words[@]}]} + executive=$words[1] + + # lookup for command + for word in ${words[@]:1}; do + if [[ $word != -* ]]; then + command=$word + break + fi + done + + + [[ $lastArgument == $command ]] && state="command" + [[ $lastArgument != $command ]] && state="option" + + case $state in + command) + commands=("${(@f)$(${executive} help/list 2>/dev/null)}") + _describe 'command' commands + ;; + option) + options=("${(@f)$(${executive} help/usage ${command} 2>/dev/null)}") + _message -r "$options" + + suboptions=("${(@f)$(${executive} help/list-action-options ${command} 2>/dev/null)}") + _describe -V -o -t suboption 'action options' suboptions + ;; + *) + esac + +} + +compdef _yii yii + diff --git a/docs/guide-es/security-authorization.md b/docs/guide-es/security-authorization.md index 669912f..11d01a9 100644 --- a/docs/guide-es/security-authorization.md +++ b/docs/guide-es/security-authorization.md @@ -304,7 +304,7 @@ class RbacController extends Controller ``` > Note: Si estas utilizando el template avanzado, necesitas poner tu `RbacController` dentro del directorio `console/controllers` - y cambiar el espacio de nombres a `console/controllers`. + y cambiar el espacio de nombres a `console\controllers`. Después de ejecutar el comando `yii rbac/init`, obtendremos la siguiente jerarquía: diff --git a/docs/guide-fr/security-authorization.md b/docs/guide-fr/security-authorization.md index 609ebe7..a6a06c9 100644 --- a/docs/guide-fr/security-authorization.md +++ b/docs/guide-fr/security-authorization.md @@ -258,7 +258,7 @@ class RbacController extends Controller } ``` -> Note: si vous utilisez le modèle avancé, vous devez mettre votre `RbacController` dans le dossier `console/controllers` et changer l'espace de noms en `console/controllers`. +> Note: si vous utilisez le modèle avancé, vous devez mettre votre `RbacController` dans le dossier `console/controllers` et changer l'espace de noms en `console\controllers`. Après avoir exécuté la commande `yii rbac/init` vous vous retrouverez avec la hiérarchie suivante : diff --git a/docs/guide-ja/security-authorization.md b/docs/guide-ja/security-authorization.md index 340e453..e3849d5 100644 --- a/docs/guide-ja/security-authorization.md +++ b/docs/guide-ja/security-authorization.md @@ -306,7 +306,7 @@ class RbacController extends Controller ``` > Note: アドバンストテンプレートを使おうとするときは、`RbacController` を `console/controllers` -ディレクトリの中に置いて、名前空間を `console/controllers` に変更する必要があります。 +ディレクトリの中に置いて、名前空間を `console\controllers` に変更する必要があります。 `yii rbac/init` によってコマンドを実行した後には、次の権限階層が得られます。 diff --git a/docs/guide-ru/caching-data.md b/docs/guide-ru/caching-data.md index d89b9dc..60ce0ab 100644 --- a/docs/guide-ru/caching-data.md +++ b/docs/guide-ru/caching-data.md @@ -161,6 +161,10 @@ if ($data === false) { } ``` +Начиная с версии 2.0.11 вы можете изменить значение по умолчанию (бесконечность) для длительности кеширования задав +[[yii\caching\Cache::$defaultDuration|defaultDuration]] в конфигурации компонента кеша. Таким образом, можно будет +не передавать значение `duration` в [[yii\caching\Cache::set()|set()]] каждый раз. + ### Зависимости кэша В добавок к изменению срока действия ключа элемент может быть признан недействительным из-за *изменения зависимостей*. К примеру, [[yii\caching\FileDependency]] представляет собой зависимость от времени изменения файла. Когда это время изменяется, любые устаревшие данные, найденные в кэше, должны быть признаны недействительным, а [[yii\caching\Cache::get()|get()]] в этом случае должен вернуть `false`. diff --git a/docs/guide-ru/concept-configurations.md b/docs/guide-ru/concept-configurations.md index c0ac69b..2a75538 100644 --- a/docs/guide-ru/concept-configurations.md +++ b/docs/guide-ru/concept-configurations.md @@ -135,6 +135,29 @@ $config = [ За более подробной документацией о настройках свойства `components` в конфигурации приложения обратитесь к главам [приложения](structure-applications.md) и [Service Locator](concept-service-locator.md). +Начиная с версии 2.0.11, можно настраивать [контейнер зависимостей](concept-di-container.md) через конфигурацию +приложения. Для этого используется свойство `container`: + +```php +$config = [ + 'id' => 'basic', + 'basePath' => dirname(__DIR__), + 'extensions' => require(__DIR__ . '/../vendor/yiisoft/extensions.php'), + 'container' => [ + 'definitions' => [ + 'yii\widgets\LinkPager' => ['maxButtonCount' => 5] + ], + 'singletons' => [ + // Конфигурация для единожды создающихся объектов + ] + ] +]; +``` + +Чтобы узнать о возможных значениях `definitions` и `singletons`, а также о реальных примерах использования, +прочитайте подраздел [более сложное практическое применение](concept-di-container.md#advanced-practical-usage) раздела +[Dependency Injection Container](concept-di-container.md). + ### Конфигурации виджетов diff --git a/docs/guide-ru/concept-di-container.md b/docs/guide-ru/concept-di-container.md index 0ede428..c9864f2 100644 --- a/docs/guide-ru/concept-di-container.md +++ b/docs/guide-ru/concept-di-container.md @@ -104,6 +104,138 @@ $container->get('Foo', [], [ > Info: Метод [[yii\di\Container::get()]] третьим аргументом принимает массив конфигурации, которым инициализируется создаваемый объект. Если класс реализует интерфейс [[yii\base\Configurable]] (например, [[yii\base\Object]]), то массив конфигурации передается в последний параметр конструктора класса. Иначе конфигурация применяется уже *после* создания объекта. +Более сложное практическое применение +--------------- + +Допустим, мы работаем над API и у нас есть: + +- `app\components\Request`, наследуемый от `yii\web\Request` и реализующий дополнительные возможности. +- `app\components\Response`, наследуемый от `yii\web\Response` с свойством `format`, по умолчанию инициализируемом как `json`. +- `app\storage\FileStorage` и `app\storage\DocumentsReader`, где реализована некая логика для работы с документами в + неком файловом хранилище: + + ```php + class FileStorage + { + public function __contruct($root) { + // делаем что-то + } + } + + class DocumentsReader + { + public function __contruct(FileStorage $fs) { + // делаем что-то + } + } + ``` + +Возможно настроить несколько компонентов сразу передав массив конфигурации в метод +[[yii\di\Container::setDefinitions()|setDefinitions()]] или [[yii\di\Container::setSingletons()|setSingletons()]]. +Внутри метода фреймворк обойдёт массив конфигурации и вызовет для каждого элемента [[yii\di\Container::set()|set()]] или +[[yii\di\Container::setSingleton()|setSingleton()]] соответственно. + +Формат массива конфигурации следующий: + + - Ключ: имя класса, интерфейса или псевдонима. Ключ передаётся в первый аргумент `$class` метода + [[yii\di\Container::set()|set()]]. + - Значение: конфигурация для класса. Возможные значения описаны в документации параметра `$definition` метода + [[yii\di\Container::set()|set()]]. Значение передаётся в аргумент `$definition` метода [[set()]]. + +Для примера, давайте настроим наш контейнер: + +```php +$container->setDefinitions([ + 'yii\web\Request' => 'app\components\Request', + 'yii\web\Response' => [ + 'class' => 'app\components\Response', + 'format' => 'json' + ], + 'app\storage\DocumentsReader' => function () { + $fs = new app\storage\FileStorage('/var/tempfiles'); + return new app\storage\DocumentsReader($fs); + } +]); + +$reader = $container->get('app\storage\DocumentsReader); +// Создаст объект DocumentReader со всеми зависимостями +``` + +> Tip: Начиная с версии 2.0.11 контейнер может быть настроен в декларативном стиле через конфигурацию приложения. +Как это сделать ищите в подразделе [Конфигурация приложения](concept-service-locator.md#application-configurations) +раздела [Конфигурации](concept-configurations.md). + +Вроде всё работает, но если нам необходимо создать экземпляр класса `DocumentWriter`, придётся скопировать код, +создающий экземпляр`FileStorage`, что, очевидно, не является оптимальным. + +Как описано в подразделе [Разрешение зависимостей](#resolving-dependencies), [[yii\di\Container::set()|set()]] +и [[yii\di\Container::setSingleton()|setSingleton()]] могут опционально принимать третьим аргументов параметры +для конструктора. Формат таков: + + - Ключ: имя класса, интерфейса или псевдонима. Ключ передаётся в первый аргумент `$class` метода [[yii\di\Container::set()|set()]]. + - Значение: массив из двух элементов. Первый элемент передаётся в метод [[yii\di\Container::set()|set()]] вторым + аргументом `$definition`, второй элемент — аргументом `$params`. + +Исправим наш пример: + +```php +$container->setDefinitions([ + 'tempFileStorage' => [ // для удобства мы задали псевдоним + ['class' => 'app\storage\FileStorage'], + ['/var/tempfiles'] + ], + 'app\storage\DocumentsReader' => [ + ['class' => 'app\storage\DocumentsReader'], + [Instance::of('tempFileStorage')] + ], + 'app\storage\DocumentsWriter' => [ + ['class' => 'app\storage\DocumentsWriter'], + [Instance::of('tempFileStorage')] + ] +]); + +$reader = $container->get('app\storage\DocumentsReader); +// Код будет работать ровно так же, как и в предыдущем примере. +``` + +Вы могли заметить вызов `Instance::of('tempFileStorage')`. Он означает, что [[yii\di\Container|Container]] +наявно предоставит зависимость, зарегистрированную с именем `tempFileStorage` и передаст её первым аргументом +в конструктор `app\storage\DocumentsWriter`. + +> Note: Методы [[yii\di\Container::setDefinitions()|setDefinitions()]] и [[yii\di\Container::setSingletons()|setSingletons()]] + доступны с версии 2.0.11. + +Ещё один шаг по оптимизации конфигурации — регистрировать некоторые зависимости как синглтоны. Зависимость, регистрируемая +через метод [[yii\di\Container::set()|set()]] будет созаваться каждый раз при обращении к ней. Некоторые классы не меняют +своего состояния на протяжении всей работы приложения, поэтому могут быть зарегистрированы как синглтоны. Это увеличит +производительность приложения. + +Хорошим примером может быть класс `app\storage\FileStorage`, который выполняет некие операции над файловой системой +через простой API: `$fs->read()`, `$fs->write()`. Обе операции не меняют внутреннее состояние класса, поэтому мы можем +создать класс один раз и далее использовать его. + +```php +$container->setSingletons([ + 'tempFileStorage' => [ + ['class' => 'app\storage\FileStorage'], + ['/var/tempfiles'] + ], +]); + +$container->setDefinitions([ + 'app\storage\DocumentsReader' => [ + ['class' => 'app\storage\DocumentsReader'], + [Instance::of('tempFileStorage')] + ], + 'app\storage\DocumentsWriter' => [ + ['class' => 'app\storage\DocumentsWriter'], + [Instance::of('tempFileStorage')] + ] +]); + +$reader = $container->get('app\storage\DocumentsReader); +``` + ### Внедрение зависимости через PHP callback В данном случае, контейнер будет использовать зарегистрированный PHP callback для создания новых экземпляров класса. @@ -211,13 +343,16 @@ $container->setSingleton('yii\db\Connection', [ Разрешение зависимостей ---------------------- После регистрации зависимостей, вы можете использовать контейнер внедрения зависимостей для создания новых объектов, -и контейнер автоматически разрешит зависимости их экземпляра и их внедрений во вновь создаваемых объектах. Разрешение зависимостей рекурсивно, то есть -если зависимость имеет другие зависимости, эти зависимости также будут автоматически разрешены. +и контейнер автоматически разрешит зависимости их экземпляра и их внедрений во вновь создаваемых объектах. Разрешение +зависимостей рекурсивно, то есть если зависимость имеет другие зависимости, эти зависимости также будут автоматически +разрешены. -Вы можете использовать [[yii\di\Container::get()]] для создания новых объектов. Метод принимает имя зависимости, которым может быть имя класса, имя интерфейса или псевдоним. -Имя зависимости может быть или не может быть зарегистрировано через `set()` или `setSingleton()`. -Вы можете опционально предоставить список параметров конструктора класса и [конфигурацию](concept-configurations.md) для настройки созданного объекта. -Например, +Вы можете использовать [[yii\di\Container::get()]] для создания или получения объектов. Метод принимает имя зависимости, +которым может быть имя класса, имя интерфейса или псевдоним. Имя зависимости может быть зарегистрировано через +`set()` или `setSingleton()`. Вы можете опционально предоставить список параметров конструктора класса и +[конфигурацию](concept-configurations.md) для настройки созданного объекта. + +Например: ```php // "db" ранее зарегистрированный псевдоним @@ -228,11 +363,14 @@ $engine = $container->get('app\components\SearchEngine', [$apiKey], ['type' => 1 ``` За кулисами, контейнер внедрения зависимостей делает гораздо больше работы, чем просто создание нового объекта. -Прежде всего, контейнер, осмотрит конструктор класса, чтобы узнать имя зависимого класса или интерфейса, а затем автоматически разрешит эти зависимости рекурсивно. +Прежде всего, контейнер, осмотрит конструктор класса, чтобы узнать имя зависимого класса или интерфейса, а затем +автоматически разрешит эти зависимости рекурсивно. -Следующий код демонстрирует более сложный пример. Класс `UserLister` зависит от объекта, реализующего интерфейс `UserFinderInterface`; класс `UserFinder` реализует этот интерфейс и зависит от - объекта `Connection`. Все эти зависимости были объявлены через тип подсказки параметров конструктора класса. -При регистрации зависимости через свойство, контейнер внедрения зависимостей позволяет автоматически разрешить эти зависимости и создаёт новый экземпляр `UserLister` простым вызовом `get('userLister')`. +Следующий код демонстрирует более сложный пример. Класс `UserLister` зависит от объекта, реализующего интерфейс +`UserFinderInterface`; класс `UserFinder` реализует этот интерфейс и зависит от объекта `Connection`. Все эти зависимости +были объявлены через тип подсказки параметров конструктора класса. При регистрации зависимости через свойство, контейнер +внедрения зависимостей позволяет автоматически разрешить эти зависимости и создаёт новый экземпляр `UserLister` простым +вызовом `get('userLister')`. ```php namespace app\models; @@ -291,17 +429,17 @@ $lister = new UserLister($finder); ``` -Практическое использование +Практическое применение --------------- Yii создаёт контейнер внедрения зависимостей когда вы подключаете файл `Yii.php` во [входном скрипте](structure-entry-scripts.md) вашего приложения. Контейнер внедрения зависимостей доступен через [[Yii::$container]]. При вызове [[Yii::createObject()]], метод на самом деле вызовет метод контейнера [[yii\di\Container::get()|get()]], чтобы создать новый объект. -Как упомянуто выше, контейнер внедрения зависимостей автоматически разрешит зависимости (если таковые имеются) и внедрит их в только что созданный объект. -Поскольку Yii использует [[Yii::createObject()]] в большей части кода своего ядра для создания новых объектов, это означает, -что вы можете настроить глобальные объекты, имея дело с [[Yii::$container]]. +Как упомянуто выше, контейнер внедрения зависимостей автоматически разрешит зависимости (если таковые имеются) и внедрит их +получаемый объект. Поскольку Yii использует [[Yii::createObject()]] в большей части кода своего ядра для создания новых +объектов, это означает, что вы можете настроить глобальные объекты, имея дело с [[Yii::$container]]. -Например, вы можете настроить по умолчанию глобальное количество кнопок в пейджере [[yii\widgets\LinkPager]]: +Например, давайте настроим количество кнопок в пейджере [[yii\widgets\LinkPager]] по умолчанию глобально: ```php \Yii::$container->set('yii\widgets\LinkPager', ['maxButtonCount' => 5]); @@ -356,8 +494,11 @@ class HotelController extends Controller Поскольку зависимости необходимы тогда, когда создаются новые объекты, то их регистрация должна быть сделана как можно раньше. Ниже приведены рекомендуемые практики: -* Если вы разработчик приложения, то вы можете зарегистрировать зависимости во [входном скрипте](structure-entry-scripts.md) вашего приложения или в скрипте, подключённого во входном скрипте. -* Если вы разработчик распространяемого [расширения](structure-extensions.md), то вы можете зарегистрировать зависимости в загрузочном классе расширения. +* Если вы разработчик приложения, то вы можете зарегистрировать зависимости в конфигурации вашего приложения. + Как это сделать описано в подразделе [Конфигурация приложения](concept-service-locator.md#application-configurations) + раздела [Конфигурации](concept-configurations.md). +* Если вы разработчик распространяемого [расширения](structure-extensions.md), то вы можете зарегистрировать зависимости + в загрузочном классе расширения. Итог diff --git a/docs/guide-ru/db-active-record.md b/docs/guide-ru/db-active-record.md index ef0418a..e023e37 100644 --- a/docs/guide-ru/db-active-record.md +++ b/docs/guide-ru/db-active-record.md @@ -49,9 +49,20 @@ Yii поддерживает работу с Active Record для следующ ## Объявление классов Active Record -Для начала объявите свой собственный класс, унаследовав класс [[yii\db\ActiveRecord]]. Поскольку каждый класс -Active Record сопоставлен с таблицей в базе данных, в своём классе вы должны переопределить метод -[[yii\db\ActiveRecord::tableName()|tableName()]], чтобы указать с какой именно таблицей связан ваш класс. +Для начала объявите свой собственный класс, унаследовав класс [[yii\db\ActiveRecord]]. + +### Настройка имени таблицы + +По умолчанию каждый класс Active Record ассоциирован с таблицей в базе данных. Метод +[[yii\db\ActiveRecord::tableName()|tableName()]] получает имя таблицы из имени класса с помощью [[yii\helpers\Inflector::camel2id()]]. +Если таблица не названа соответственно, вы можете переопределить данный метод. + +Также может быть применён [[yii\db\Connection::$tablePrefix|tablePrefix]] по умолчанию. Например, если +[[yii\db\Connection::$tablePrefix|tablePrefix]] задан как `tbl_`, `Customer` преобразуется в `tbl_customer`, а +`OrderItem` в `tbl_order_item`. + +Если имя таблицы указано в формате `{{%TableName}}`, символ `%` заменяется префиксом. Например, , `{{%post}}` становится +`{{tbl_post}}`. Фигуриные скобки используются для [экранирования в SQL-запросах](db-dao.md#quoting-table-and-column-names). В нижеследующем примере мы объявляем класс Active Record с названием `Customer` для таблицы `customer`. @@ -70,11 +81,13 @@ class Customer extends ActiveRecord */ public static function tableName() { - return 'customer'; + return '{{customer}}'; } } ``` +### Классы Active record называются "моделями" + Объекты Active Record являются [моделями](structure-models.md). Именно поэтому мы обычно задаём классам Active Record пространство имён `app\models` (или другое пространство имён, предназначенное для моделей). @@ -1134,6 +1147,37 @@ $customers = Customer::find()->joinWith([ [[yii\db\ActiveQuery::onCondition()|onCondition()]], это условие будет размещено в конструкции `ON`, если запрос содержит оператор JOIN. Если же запрос не содержит оператор JOIN, такое условие будет автоматически размещено в конструкции `WHERE`. + +#### Псевдонимы связанных таблиц + +Как уже было отмечено, при использовании в запросе JOIN-ов, приходится явно решать конфликты имён. Поэтому часто таблицам +дают псевдонимы. Задать псевдоним для реляционного запроса можно следующим образом: + +```php +$query->joinWith([ + 'orders' => function ($q) { + $q->from(['o' => Order::tableName()]); + }, +]) +``` + +Выглядит это довольно сложно. Либо приходится задавать явно имена таблиц, либо вызывать `Order::tableName()`. +Начиная с версии 2.0.7 вы можете задать и использовать псевдоним для связанной таблицы следующим образом: + +```php +// join the orders relation and sort the result by orders.id +$query->joinWith(['orders o'])->orderBy('o.id'); +``` + +Этот синтаксис работает для простых связей. Если же необходимо использовать связующую таблицу, например +`$query->joinWith(['orders.product'])`, то вызовы joinWith вкладываются друг в друга: + +```php +$query->joinWith(['orders o' => function($q) { + $q->joinWith('product p'); + }]) + ->where('o.amount > 100'); +``` ### Обратные связи diff --git a/docs/guide-ru/db-query-builder.md b/docs/guide-ru/db-query-builder.md index 6d70dcc..b606f83 100644 --- a/docs/guide-ru/db-query-builder.md +++ b/docs/guide-ru/db-query-builder.md @@ -146,6 +146,10 @@ $subQuery = (new Query())->select('id')->from('user')->where('status=1'); $query->from(['u' => $subQuery]); ``` +#### Префиксы + +Также может применяться [[yii\db\Connection::$tablePrefix|tablePrefix]] по умолчанию. Подробное описание смотрите +в подразделе [«Экранирование имён таблиц и столбцов» раздела «Объекты доступа к данным (DAO)»](guide-db-dao.html#quoting-table-and-column-names). ### [[yii\db\Query::where()|where()]] diff --git a/docs/guide-ru/helper-array.md b/docs/guide-ru/helper-array.md index 3dc3bac..2b4be97 100644 --- a/docs/guide-ru/helper-array.md +++ b/docs/guide-ru/helper-array.md @@ -81,7 +81,7 @@ if (!ArrayHelper::keyExists('username', $data1, false) || !ArrayHelper::keyExist Часто нужно извлечь столбец значений из многомерного массива или объекта. Например, список ID. ```php -$data = [ +$array = [ ['id' => '123', 'data' => 'abc'], ['id' => '345', 'data' => 'def'], ]; diff --git a/docs/guide-ru/security-authorization.md b/docs/guide-ru/security-authorization.md index 51a2ae1..15e8013 100644 --- a/docs/guide-ru/security-authorization.md +++ b/docs/guide-ru/security-authorization.md @@ -298,7 +298,7 @@ class RbacController extends Controller ``` > Note: Если вы используете шаблон проекта advanced, `RbacController` необходимо создать в директории `console/controllers` - и сменить пространство имён на `console/controllers`. + и сменить пространство имён на `console\controllers`. После выполнения команды `yii rbac/init` мы получим следующую иерархию: diff --git a/docs/guide/README.md b/docs/guide/README.md index 44389c6..f420465 100644 --- a/docs/guide/README.md +++ b/docs/guide/README.md @@ -147,7 +147,7 @@ Development Tools * [Debug Toolbar and Debugger](https://github.com/yiisoft/yii2-debug/blob/master/docs/guide/README.md) * [Generating Code using Gii](https://github.com/yiisoft/yii2-gii/blob/master/docs/guide/README.md) -* **TBD** [Generating API Documentation](https://github.com/yiisoft/yii2-apidoc) +* [Generating API Documentation](https://github.com/yiisoft/yii2-apidoc) Testing @@ -179,14 +179,14 @@ Special Topics Widgets ------- -* GridView: **TBD** link to demo page -* ListView: **TBD** link to demo page -* DetailView: **TBD** link to demo page -* ActiveForm: **TBD** link to demo page -* Pjax: **TBD** link to demo page -* Menu: **TBD** link to demo page -* LinkPager: **TBD** link to demo page -* LinkSorter: **TBD** link to demo page +* [GridView](http://www.yiiframework.com/doc-2.0/yii-grid-gridview.html) +* [ListView](http://www.yiiframework.com/doc-2.0/yii-widgets-listview.html) +* [DetailView](http://www.yiiframework.com/doc-2.0/yii-widgets-detailview.html) +* [ActiveForm](http://www.yiiframework.com/doc-2.0/guide-input-forms.html#activerecord-based-forms-activeform) +* [Pjax](http://www.yiiframework.com/doc-2.0/yii-widgets-pjax.html) +* [Menu](http://www.yiiframework.com/doc-2.0/yii-widgets-menu.html) +* [LinkPager](http://www.yiiframework.com/doc-2.0/yii-widgets-linkpager.html) +* [LinkSorter](http://www.yiiframework.com/doc-2.0/yii-widgets-linksorter.html) * [Bootstrap Widgets](https://github.com/yiisoft/yii2-bootstrap/blob/master/docs/guide/README.md) * [jQuery UI Widgets](https://github.com/yiisoft/yii2-jui/blob/master/docs/guide/README.md) diff --git a/docs/guide/concept-configurations.md b/docs/guide/concept-configurations.md index a5f7905..0afefb2 100644 --- a/docs/guide/concept-configurations.md +++ b/docs/guide/concept-configurations.md @@ -135,6 +135,28 @@ an [entry script](structure-entry-scripts.md), where the class name is already g More details about configuring the `components` property of an application can be found in the [Applications](structure-applications.md) section and the [Service Locator](concept-service-locator.md) section. +Since version 2.0.11, the application configuration supports [Dependency Injection Container](concept-di-container.md) +configuration using `container` property. For example: + +```php +$config = [ + 'id' => 'basic', + 'basePath' => dirname(__DIR__), + 'extensions' => require(__DIR__ . '/../vendor/yiisoft/extensions.php'), + 'container' => [ + 'definitions' => [ + 'yii\widgets\LinkPager' => ['maxButtonCount' => 5] + ], + 'singletons' => [ + // Dependency Injection Container singletons configuration + ] + ] +]; +``` + +To know more about the possible values of `definitions` and `singletons` configuration arrays and real-life examples, +please read [Advanced Practical Usage](concept-di-container.md#advanced-practical-usage) subsection of the +[Dependency Injection Container](concept-di-container.md) article. ### Widget Configurations diff --git a/docs/guide/concept-di-container.md b/docs/guide/concept-di-container.md index 976730b..1f74678 100644 --- a/docs/guide/concept-di-container.md +++ b/docs/guide/concept-di-container.md @@ -224,11 +224,13 @@ and the container will automatically resolve dependencies by instantiating them them into the newly created objects. The dependency resolution is recursive, meaning that if a dependency has other dependencies, those dependencies will also be resolved automatically. -You can use [[yii\di\Container::get()]] to create new objects. The method takes a dependency name, -which can be a class name, an interface name or an alias name. The dependency name may or may -not be registered via `set()` or `setSingleton()`. You may optionally provide a list of class -constructor parameters and a [configuration](concept-configurations.md) to configure the newly created object. -For example, +You can use [[yii\di\Container::get()|get()]] to either create or get object instance. +The method takes a dependency name, which can be a class name, an interface name or an alias name. +The dependency name may be registered via [[yii\di\Container::set()|set()]] +or [[yii\di\Container::setSingleton()|setSingleton()]]. You may optionally provide a list of class +constructor parameters and a [configuration](concept-configurations.md) to configure the newly created object. + +For example: ```php // "db" is a previously registered alias name @@ -312,10 +314,10 @@ Yii creates a DI container when you include the `Yii.php` file in the [entry scr of your application. The DI container is accessible via [[Yii::$container]]. When you call [[Yii::createObject()]], the method will actually call the container's [[yii\di\Container::get()|get()]] method to create a new object. As aforementioned, the DI container will automatically resolve the dependencies (if any) and inject them -into the newly created object. Because Yii uses [[Yii::createObject()]] in most of its core code to create +into obtained object. Because Yii uses [[Yii::createObject()]] in most of its core code to create new objects, this means you can customize the objects globally by dealing with [[Yii::$container]]. -For example, you can customize globally the default number of pagination buttons of [[yii\widgets\LinkPager]]: +For example, let's customize globally the default number of pagination buttons of [[yii\widgets\LinkPager]]. ```php \Yii::$container->set('yii\widgets\LinkPager', ['maxButtonCount' => 5]); @@ -368,6 +370,140 @@ cannot be instantiated. This is because you need to tell the DI container how to Now if you access the controller again, an instance of `app\components\BookingService` will be created and injected as the 3rd parameter to the controller's constructor. +Advanced Practical Usage +--------------- + +Say we work on API application and have: + +- `app\components\Request` class that extends `yii\web\Request` and provides additional functionality +- `app\components\Response` class that extends `yii\web\Response` and should have `format` property + set to `json` on creation +- `app\storage\FileStorage` and `app\storage\DocumentsReader` classes the implement some logic on + working with documents that are located in some file storage: + + ```php + class FileStorage + { + public function __contruct($root) { + // whatever + } + } + + class DocumentsReader + { + public function __contruct(FileStorage $fs) { + // whatever + } + } + ``` + +It is possible to configure multiple definitions at once, passing configuration array to +[[yii\di\Container::setDefinitions()|setDefinitions()]] or [[yii\di\Container::setSingletons()|setSingletons()]] method. +Iterating over the configuration array, the methods will call [[yii\di\Container::set()|set()]] +or [[yii\di\Container::setSingleton()|setSingleton()]] respectively for each item. + +The configuration array format is: + + - `key`: class name, interface name or alias name. The key will be passed to the + [[yii\di\Container::set()|set()]] method as a first argument `$class`. + - `value`: the definition associated with `$class`. Possible values are described in [[yii\di\Container::set()|set()]] + documentation for the `$definition` parameter. Will be passed to the [[set()]] method as + the second argument `$definition`. + +For example, let's configure our container to follow the aforementioned requirements: + +```php +$container->setDefinitions([ + 'yii\web\Request' => 'app\components\Request', + 'yii\web\Response' => [ + 'class' => 'app\components\Response', + 'format' => 'json' + ], + 'app\storage\DocumentsReader' => function () { + $fs = new app\storage\FileStorage('/var/tempfiles'); + return new app\storage\DocumentsReader($fs); + } +]); + +$reader = $container->get('app\storage\DocumentsReader); +// Will create DocumentReader object with its dependencies as described in the config +``` + +> Tip: Container may be configured in declarative style using application configuration since version 2.0.11. +Check out the [Application Configurations](concept-service-locator.md#application-configurations) subsection of +the [Configurations](concept-configurations.md) guide article. + +Everything works, but in case we need to create create `DocumentWriter` class, +we shall copy-paste the line that creates `FileStorage` object, that is not the smartest way, obviously. + +As described in the [Resolving Dependencies](#resolving-dependencies) subsection, [[yii\di\Container::set()|set()]] +and [[yii\di\Container::setSingleton()|setSingleton()]] can optionally take dependency's constructor parameters as +a third argument. To set the constructor parameters, you may use the following configuration array format: + + - `key`: class name, interface name or alias name. The key will be passed to the + [[yii\di\Container::set()|set()]] method as a first argument `$class`. + - `value`: array of two elements. The first element will be passed the [[yii\di\Container::set()|set()]] method as the + second argument `$definition`, the second one — as `$params`. + +Let's modify our example: + +```php +$container->setDefinitions([ + 'tempFileStorage' => [ // we've created an alias for convenience + ['class' => 'app\storage\FileStorage'], + ['/var/tempfiles'] // could be extracted from some config files + ], + 'app\storage\DocumentsReader' => [ + ['class' => 'app\storage\DocumentsReader'], + [Instance::of('tempFileStorage')] + ], + 'app\storage\DocumentsWriter' => [ + ['class' => 'app\storage\DocumentsWriter'], + [Instance::of('tempFileStorage')] + ] +]); + +$reader = $container->get('app\storage\DocumentsReader); +// Will behave exactly the same as in the previous example. +``` + +You might notice `Instance::of('tempFileStorage')` notation. It means, that the [[yii\di\Container|Container]] +will implicitly provide dependency, registered with `tempFileStorage` name and pass it as the first argument +of `app\storage\DocumentsWriter` constructor. + +> Note: [[yii\di\Container::setDefinitions()|setDefinitions()]] and [[yii\di\Container::setSingletons()|setSingletons()]] + methods are available since version 2.0.11. + +Another step on configuration optimization is to register some dependencies as singletons. +A dependency registered via [[yii\di\Container::set()|set()]] will be instantiated each time it is needed. +Some classes do not change the state during runtime, therefore they may be registered as singletons +in order to increase the application performance. + +A good example could be `app\storage\FileStorage` class, that executes some operations on file system with a simple +API (e.g. `$fs->read()`, `$fs->write()`). These operations do not change the internal class state, so we can +create its instance once and use it multiple times. + +```php +$container->setSingletons([ + 'tempFileStorage' => [ + ['class' => 'app\storage\FileStorage'], + ['/var/tempfiles'] + ], +]); + +$container->setDefinitions([ + 'app\storage\DocumentsReader' => [ + ['class' => 'app\storage\DocumentsReader'], + [Instance::of('tempFileStorage')] + ], + 'app\storage\DocumentsWriter' => [ + ['class' => 'app\storage\DocumentsWriter'], + [Instance::of('tempFileStorage')] + ] +]); + +$reader = $container->get('app\storage\DocumentsReader); +``` When to Register Dependencies ----------------------------- @@ -375,8 +511,9 @@ When to Register Dependencies Because dependencies are needed when new objects are being created, their registration should be done as early as possible. The following are the recommended practices: -* If you are the developer of an application, you can register dependencies in your - application's [entry script](structure-entry-scripts.md) or in a script that is included by the entry script. +* If you are the developer of an application, you can register your dependencies using application configuration. + Please, read the [Application Configurations](concept-service-locator.md#application-configurations) subsection of + the [Configurations](concept-configurations.md) guide article. * If you are the developer of a redistributable [extension](structure-extensions.md), you can register dependencies in the bootstrapping class of the extension. diff --git a/docs/guide/db-active-record.md b/docs/guide/db-active-record.md index 8083a3f..e93a0f4 100644 --- a/docs/guide/db-active-record.md +++ b/docs/guide/db-active-record.md @@ -50,9 +50,20 @@ However, most content described here are also applicable to Active Record for No ## Declaring Active Record Classes -To get started, declare an Active Record class by extending [[yii\db\ActiveRecord]]. Because each Active Record -class is associated with a database table, in this class you should override the [[yii\db\ActiveRecord::tableName()|tableName()]] -method to specify which table the class is associated with. +To get started, declare an Active Record class by extending [[yii\db\ActiveRecord]]. + +### Setting a table name + +By default each Active Record class is associated with its database table. +The [[yii\db\ActiveRecord::tableName()|tableName()]] method returns the table name by converting the class name via [[yii\helpers\Inflector::camel2id()]]. +You may override this method if the table is not named after this convention. + +Also a default [[yii\db\Connection::$tablePrefix|tablePrefix]] can be applied. For example if + [[yii\db\Connection::$tablePrefix|tablePrefix]] is `tbl_`, `Customer` becomes `tbl_customer` and `OrderItem` becomes `tbl_order_item`. + +If a table name is given as `{{%TableName}}`, then the percentage character `%` will be replaced with the table prefix. +For example, `{{%post}}` becomes `{{tbl_post}}`. The brackets around the table name are used for +[quoting in an SQL query](db-dao.md#quoting-table-and-column-names). In the following example, we declare an Active Record class named `Customer` for the `customer` database table. @@ -71,11 +82,12 @@ class Customer extends ActiveRecord */ public static function tableName() { - return 'customer'; + return '{{customer}}'; } } ``` +### Active records are called "models" Active Record instances are considered as [models](structure-models.md). For this reason, we usually put Active Record classes under the `app\models` namespace (or other namespaces for keeping model classes). diff --git a/docs/guide/db-migrations.md b/docs/guide/db-migrations.md index 7453d4f..5e070c6 100644 --- a/docs/guide/db-migrations.md +++ b/docs/guide/db-migrations.md @@ -891,6 +891,65 @@ will be used to record the migration history. You no longer need to specify it v command-line option. +### Separated Migrations + +Sometimes you may need to use migrations from a different namespace. It can be some extension or module in your own +project. One of such examples is migrations for [RBAC component](security-authorization.md#configuring-rbac). +Since version 2.0.10 you can use [[yii\console\controllers\MigrateController::migrationNamespaces|migrationNamespaces]] +to solve this task: + +```php +return [ + 'controllerMap' => [ + 'migrate' => [ + 'class' => 'yii\console\controllers\MigrateController', + 'migrationNamespaces' => [ + 'app\migrations', // Common migrations for the whole application + 'module\migrations', // Migrations for the specific project's module +                'yii\rbac\migrations', // Migrations for the specific extension + ], + ], + ], +]; +``` + +If you want them to be applied and tracked down completely separated from each other, you can configure multiple +migration commands which will use different namespaces and migration history tables: + +```php +return [ + 'controllerMap' => [ + // Common migrations for the whole application + 'migrate-app' => [ + 'class' => 'yii\console\controllers\MigrateController', + 'migrationNamespaces' => ['app\migrations'], + 'migrationTable' => 'migration_app', + ], + // Migrations for the specific project's module + 'migrate-module' => [ + 'class' => 'yii\console\controllers\MigrateController', + 'migrationNamespaces' => ['module\migrations'], + 'migrationTable' => 'migration_module', + ], + // Migrations for the specific extension + 'migrate-rbac' => [ + 'class' => 'yii\console\controllers\MigrateController', +            'migrationNamespaces' => ['yii\rbac\migrations'], + 'migrationTable' => 'migration_rbac', + ], + ], +]; +``` + +Note that to synchronize database you now need to run multiple commands instead of one: + +``` +yii migrate-app +yii migrate-module +yii migrate-rbac +``` + + ## Migrating Multiple Databases By default, migrations are applied to the same database specified by the `db` [application component](structure-application-components.md). diff --git a/docs/guide/db-query-builder.md b/docs/guide/db-query-builder.md index c7c7de2..de863a2 100644 --- a/docs/guide/db-query-builder.md +++ b/docs/guide/db-query-builder.md @@ -153,6 +153,9 @@ $subQuery = (new Query())->select('id')->from('user')->where('status=1'); $query->from(['u' => $subQuery]); ``` +#### Prefixes +Also a default [[yii\db\Connection::$tablePrefix|tablePrefix]] can be applied. Implementation instructions +are in the ["Quoting Tables" section of the "Database Access Objects" guide](guide-db-dao.html#quoting-table-and-column-names). ### [[yii\db\Query::where()|where()]] @@ -366,6 +369,12 @@ You can also specify operator explicitly: $query->andFilterCompare('name', 'Doe', 'like'); ``` +Since Yii 2.0.11 there are similar methods for `HAVING` condition: + +- [[yii\db\Query::filterHaving()|filterHaving()]] +- [[yii\db\Query::andFilterHaving()|andFilterHaving()]] +- [[yii\db\Query::orFilterHaving()|orFilterHaving()]] + ### [[yii\db\Query::orderBy()|orderBy()]] The [[yii\db\Query::orderBy()|orderBy()]] method specifies the `ORDER BY` fragment of a SQL query. For example, diff --git a/docs/guide/input-validation.md b/docs/guide/input-validation.md index 9af27d6..b02078b 100644 --- a/docs/guide/input-validation.md +++ b/docs/guide/input-validation.md @@ -331,8 +331,9 @@ the method/function is: /** * @param string $attribute the attribute currently being validated * @param mixed $params the value of the "params" given in the rule + * @param \yii\validators\InlineValidator related InlineValidator instance */ -function ($attribute, $params) +function ($attribute, $params, $validator) ``` If an attribute fails the validation, the method/function should call [[yii\base\Model::addError()]] to save @@ -355,7 +356,7 @@ class MyForm extends Model ['country', 'validateCountry'], // an inline validator defined as an anonymous function - ['token', function ($attribute, $params) { + ['token', function ($attribute, $params, $validator) { if (!ctype_alnum($this->$attribute)) { $this->addError($attribute, 'The token must contain letters or digits.'); } @@ -363,7 +364,7 @@ class MyForm extends Model ]; } - public function validateCountry($attribute, $params) + public function validateCountry($attribute, $params, $validator) { if (!in_array($this->$attribute, ['USA', 'Web'])) { $this->addError($attribute, 'The country must be either "USA" or "Web".'); @@ -372,6 +373,14 @@ class MyForm extends Model } ``` +> Note: Since version 2.0.11 you can use [[yii\validators\InlineValidator::addError()]] for adding errors instead. That way the error +> message can be formatted using [[yii\i18n\I18N::format()]] right away. Use `{attribute}` and `{value}` in the error +> message to refer to an attribute label (no need to get it manually) and attribute value accordingly: +> +> ```php +> $validator->addError($this, $attribute, 'The value "{value}" is not acceptable for {attribute}.'); +> ``` + > Note: By default, inline validators will not be applied if their associated attributes receive empty inputs or if they have already failed some validation rules. If you want to make sure a rule is always applied, you may configure the [[yii\validators\Validator::skipOnEmpty|skipOnEmpty]] and/or [[yii\validators\Validator::skipOnError|skipOnError]] @@ -889,6 +898,22 @@ validation of individual input fields by configuring their [[yii\widgets\ActiveF property to be false. When `enableClientValidation` is configured at both the input field level and the form level, the former will take precedence. +> Info: Since version 2.0.11 all validators extending from [[yii\validators\Validator]] receive client-side options +> from separate method - [[yii\validators\Validator::getClientOptions()]]. You can use it: +> +> - if you want to implement your own custom client-side validation but leave the synchronization with server-side +> validator options; +> - to extend or customize to fit your specific needs: +> +> ```php +> public function getClientOptions($model, $attribute) +> { +> $options = parent::getClientOptions($model, $attribute); +> // Modify $options here +> +> return $options; +> } +> ``` ### Implementing Client-Side Validation diff --git a/docs/guide/security-authorization.md b/docs/guide/security-authorization.md index c37576d..1c40765 100644 --- a/docs/guide/security-authorization.md +++ b/docs/guide/security-authorization.md @@ -242,6 +242,9 @@ Before you can go on you need to create those tables in the database. To do this `yii migrate --migrationPath=@yii/rbac/migrations` +Read more about working with migrations from different namespaces in +[Separated Migrations](db-migrations.md#separated-migrations) section. + The `authManager` can now be accessed via `\Yii::$app->authManager`. @@ -304,7 +307,7 @@ class RbacController extends Controller ``` > Note: If you are using advanced template, you need to put your `RbacController` inside `console/controllers` directory - and change namespace to `console/controllers`. + and change namespace to `console\controllers`. After executing the command with `yii rbac/init` we'll get the following hierarchy: @@ -328,7 +331,7 @@ public function signup() $user->save(false); // the following three lines were added: - $auth = Yii::$app->authManager; + $auth = \Yii::$app->authManager; $authorRole = $auth->getRole('author'); $auth->assign($authorRole, $user->getId()); diff --git a/docs/guide/security-best-practices.md b/docs/guide/security-best-practices.md index 318aba9..f6d3786 100644 --- a/docs/guide/security-best-practices.md +++ b/docs/guide/security-best-practices.md @@ -256,6 +256,7 @@ return [ 'example.com', '*.example.com', ], + 'fallbackHostInfo' => 'https://example.com', ], // ... ]; diff --git a/docs/guide/tutorial-console.md b/docs/guide/tutorial-console.md index 0ab694e..0f2bf86 100644 --- a/docs/guide/tutorial-console.md +++ b/docs/guide/tutorial-console.md @@ -107,6 +107,53 @@ You can see an example of this in the advanced project template. > ``` +Console command completion +--------------- + +Auto-completion of command arguments is a useful thing when working with the shell. +Since version 2.0.11, the `./yii` command provides auto completion for the bash out of the box. + +### Bash completion + +Make sure bash completion is installed. For most of installations it is available by default. + +Place the completion script in `/etc/bash_completion.d/`: + + curl -L https://raw.githubusercontent.com/yiisoft/yii2/master/contrib/completion/bash/yii -o /etc/bash_completion.d/yii + +For temporary usage you can put the file into the current directory and include it in the current session via `source yii`. +If globally installed you may need to restart the terminal or `source ~/.bashrc` to activate it. + +Check the [Bash Manual](https://www.gnu.org/software/bash/manual/html_node/Programmable-Completion.html) for +other ways of including completion script to your environment. + +### ZSH completion + +Put the completion script in directory for completions, using e.g. `~/.zsh/completion/` + +``` +mkdir -p ~/.zsh/completion +curl -L https://raw.githubusercontent.com/yiisoft/yii2/master/contrib/completion/zsh/_yii -o ~/.zsh/completion/_yii +``` + +Include the directory in the `$fpath`, e.g. by adding it to `~/.zshrc` + +``` +fpath=(~/.zsh/completion $fpath) +``` + +Make sure `compinit` is loaded or do it by adding in `~/.zshrc` + +``` +autoload -Uz compinit && compinit -i +``` + +Then reload your shell + +``` +exec $SHELL -l +``` + Creating your own console commands ---------------------------------- diff --git a/docs/internals/git-workflow.md b/docs/internals/git-workflow.md index ce9cbe8..1aa9e1f 100644 --- a/docs/internals/git-workflow.md +++ b/docs/internals/git-workflow.md @@ -39,6 +39,14 @@ The following steps are not necessary if you want to work only on translations o > Note: If you see errors like `Problem 1 The requested package bower-asset/jquery could not be found in any version, there may be a typo in the package name.`, you will need to run `composer global require "fxp/composer-asset-plugin:^1.2.0"` +If you are going to work with JavaScript: + +- run `npm install` to install JavaScript testing tools and dependencies (assuming you have [Node.js and NPM installed] +(https://nodejs.org/en/download/package-manager/)). + +> Note: JavaScript tests depend on [jsdom](https://github.com/tmpvar/jsdom) library which requires Node.js 4 or newer. +Using of Node.js 6 or 7 is more preferable. + - run `php build/build dev/app basic` to clone the basic app and install composer dependencies for the basic app. This command will install foreign composer packages as normal but will link the yii2 repo to the currently checked out repo, so you have one instance of all the code installed. @@ -63,7 +71,13 @@ Some tests require additional databases to be set up and configured. You can cre settings that are configured in `tests/data/config.php`. You may limit the tests to a group of tests you are working on e.g. to run only tests for the validators and redis -`phpunit --group=validators,redis`. You get the list of available groups by running `phpunit --list-groups`. +`phpunit --group=validators,redis`. You get the list of available groups by running `phpunit --list-groups`. + +You can execute JavaScript unit tests by running `npm test` in the repo root directory. + +> Note: If you get timeout errors like `Error: timeout of 2000ms exceeded. Ensure the done() callback is being called +in this test.`, you can increase timeout: `npm test -- --timeout 30000` (don't miss `--`, it's needed for passing +additional arguments). ### Extensions diff --git a/framework/BaseYii.php b/framework/BaseYii.php index 1f19990..cc9acc9 100644 --- a/framework/BaseYii.php +++ b/framework/BaseYii.php @@ -139,20 +139,20 @@ class BaseYii if (isset(static::$aliases[$root])) { if (is_string(static::$aliases[$root])) { return $pos === false ? static::$aliases[$root] : static::$aliases[$root] . substr($alias, $pos); - } else { - foreach (static::$aliases[$root] as $name => $path) { - if (strpos($alias . '/', $name . '/') === 0) { - return $path . substr($alias, strlen($name)); - } + } + + foreach (static::$aliases[$root] as $name => $path) { + if (strpos($alias . '/', $name . '/') === 0) { + return $path . substr($alias, strlen($name)); } } } if ($throwException) { throw new InvalidParamException("Invalid path alias: $alias"); - } else { - return false; } + + return false; } /** @@ -170,11 +170,11 @@ class BaseYii if (isset(static::$aliases[$root])) { if (is_string(static::$aliases[$root])) { return $root; - } else { - foreach (static::$aliases[$root] as $name => $path) { - if (strpos($alias . '/', $name . '/') === 0) { - return $name; - } + } + + foreach (static::$aliases[$root] as $name => $path) { + if (strpos($alias . '/', $name . '/') === 0) { + return $name; } } } @@ -346,9 +346,9 @@ class BaseYii return static::$container->invoke($type, $params); } elseif (is_array($type)) { throw new InvalidConfigException('Object configuration must be an array containing a "class" element.'); - } else { - throw new InvalidConfigException('Unsupported configuration type: ' . gettype($type)); } + + throw new InvalidConfigException('Unsupported configuration type: ' . gettype($type)); } private static $_logger; @@ -360,9 +360,9 @@ class BaseYii { if (self::$_logger !== null) { return self::$_logger; - } else { - return self::$_logger = static::createObject('yii\log\Logger'); } + + return self::$_logger = static::createObject('yii\log\Logger'); } /** @@ -499,14 +499,14 @@ class BaseYii { if (static::$app !== null) { return static::$app->getI18n()->translate($category, $message, $params, $language ?: static::$app->language); - } else { - $p = []; - foreach ((array) $params as $name => $value) { - $p['{' . $name . '}'] = $value; - } + } - return ($p === []) ? $message : strtr($message, $p); + $placeholders = []; + foreach ((array) $params as $name => $value) { + $placeholders['{' . $name . '}'] = $value; } + + return ($placeholders === []) ? $message : strtr($message, $placeholders); } /** diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index ee40661..b1a99f2 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -5,13 +5,18 @@ Yii Framework 2 Change Log ------------------------ - Bug #4113: Error page stacktrace was generating links to private methods which are not part of the API docs (samdark) +- Bug #7727: Fixed truncateHtml leaving extra tags (developeruz) - Bug #9305: Fixed MSSQL `Schema::TYPE_TIMESTAMP` to be 'datetime' instead of 'timestamp', which is just an incremental number (nkovacs) - Bug #9616: Fixed mysql\Schema::loadColumnSchema to set enumValues attribute correctly if enum definition contains commas (fphammerle) - Bug #9796: Initialization of not existing `yii\grid\ActionColumn` default buttons (arogachev) - Bug #10488: Fixed incorrect behavior of `yii\validation\NumberValidator` when used with locales where decimal separator is comma (quantum13, samdark) +- Bug #11771: Fixed semantics of `yii\di\ServiceLocator::__isset()` to match the behavior of `__get()` which fixes inconsistent behavior on newer PHP versions (cebe) +- Bug #12213: Fixed `yii\db\ActiveRecord::unlinkAll()` to respect `onCondition()` of the relational query (silverfire) - Bug #12681: Changed `data` column type from `text` to `blob` to handle null-byte (`\0`) in serialized RBAC rule properly (silverfire) - Bug #12714: Fixed `yii\validation\EmailValidator` to prevent false-positives checks when property `checkDns` is set to `true` (silverfire) +- Bug #12735: Fixed `yii\console\controllers\MigrateController` creating multiple primary keys for field `bigPrimaryKey:unsigned` (SG5) - Bug #12791: Fixed `yii\behaviors\AttributeTypecastBehavior` unable to automatically detect `attributeTypes`, triggering PHP Fatal Error (klimov-paul) +- Bug #12795: Fixed inconsistency, `Yii::$app->controller` is available after handling the request since 2.0.10, this is now also the case for `Yii::$app->controller->action` (cebe) - Bug #12803, #12921: Fixed BC break in `yii.activeForm.js` introduced in #11999. Reverted commit 3ba72da (silverfire) - Bug #12810: Fixed `yii\rbac\DbManager::getChildRoles()` and `yii\rbac\PhpManager::getChildRoles()` throws an exception when role has no child roles (mysterydragon) - Bug #12822: Fixed `yii\i18n\Formatter::asTimestamp()` to process timestamp with miliseconds correctly (h311ion) @@ -26,36 +31,59 @@ Yii Framework 2 Change Log - Bug #12974: Fixed incorrect order of migrations history in case `yii\console\controllers\MigrateController::$migrationNamespaces` is in use (evgen-d, klimov-paul) - Bug #13071: Help option for commands was not working in modules (arogachev, haimanman) - Bug #13089: Fixed `yii\console\controllers\AssetController::adjustCssUrl()` breaks URL reference specification (`url(#id)`) (vitalyzhakov) -- Bug #7727: Fixed truncateHtml leaving extra tags (developeruz) -- Enh #6809: Added `\yii\caching\Cache::$defaultDuration` property, allowing to set custom default cache duration (sdkiller) +- Bug #13105: Fixed `validate()` method in `yii.activeForm.js` to prevent unexpected form submit when `forceValidate` set to `true` (silverfire) +- Bug #13118: Fixed `handleAction()` function in `yii.js` to handle attribute `data-pjax=0` as disabled PJAX (silverfire, arisk) +- Bug #13128: Fixed incorrect position of {pos} string in ColumnSchemaBuilder `__toString` (df2) +- Bug #13159: Fixed `destroy` method in `yii.captcha.js` which did not work as expected (arogachev) +- Bug #13198: Fixed order of checks in `yii\validators\IpValidator` that sometimes caused wrong error message (silverfire) +- Bug #13200: Creating Urls for routes specified in `yii\rest\UrlRule::$extraPatterns` did not work if no HTTP verb was specified (cebe) +- Enh #475: Added Bash and Zsh completion support for the `./yii` command (cebe, silverfire) +- Enh #6242: Access to validator in inline validation (arogachev) +- Enh #6373: Introduce `yii\db\Query::emulateExecution()` to force returning an empty result for a query (klimov-paul) +- Enh #6809: Added `yii\caching\Cache::$defaultDuration` property, allowing to set custom default cache duration (sdkiller) - Enh #7333: Improved error message for `yii\di\Instance::ensure()` when a component does not exist (cebe) - Enh #7420: Attributes for prompt generated with `renderSelectOptions` of `\yii\helpers\Html` helper (arogachev) +- Enh #9053: Added`yii\grid\RadioButtonColumn` (darwinisgod) - Enh #9162: Added support of closures in `value` for attributes in `yii\widgets\DetailView` (arogachev) - Enh #10896: Select only primary key when counting records in UniqueValidator (developeruz) - Enh #11037: `yii.js` and `yii.validation.js` use `Regexp.test()` instead of `String.match()` (arogachev, nkovacs) +- Enh #11163: Added separate method for client-side validation options `yii\validators\Validator::getClientOptions()` (arogachev) +- Enh #11697: Added `filterHaving()`, `andFilterHaving()` and `orFilterHaving()` to `yii\db\Query` (nicdnepr, samdark) - Enh #11756: Added type mapping for `varbinary` data type in MySQL DBMS (silverfire) +- Enh #11758: Implemented Dependency Injection Container configuration using Application configuration array (silverfire) - Enh #11929: Changed `type` column type from `int` to `smallInt` in RBAC migrations (silverfire) - Enh #12015: Changed visibility `yii\db\ActiveQueryTrait::createModels()` from private to protected (ArekX, dynasource) +- Enh #12145: Added `beforeCacheResponse` and `afterRestoreResponse` to `yii\filters\PageCache` to be more easily extendable (sergeymakinen) +- Enh #12390: Avoid creating queries with false where condition (`0=1`) when fetching relational data (klimov-paul) +- Enh #12399: Added `ActiveField::addAriaAttributes` property for `aria-required` and `aria-invalid` attributes rendering (Oxyaction, samdark) - Enh #12619: Added catch `Throwable` in `yii\base\ErrorHandler::handleException()` (rob006) +- Enh #12659: Suggest alternatives when console command was not found (mdmunir, cebe) - Enh #12726: `yii\base\Application::$version` converted to `yii\base\Module::$version` virtual property, allowing to specify version as a PHP callback (klimov-paul) +- Enh #12732: Added `is_dir()` validation to `yii\helpers\BaseFileHelper::findFiles()` method (zalatov, silverfire) - Enh #12738: Added support for creating protocol-relative URLs in `UrlManager::createAbsoluteUrl()` and `Url` helper methods (rob006) - Enh #12748: Added Migration tool automatic generation reference column for foreignKey (MKiselev) - Enh #12748: Migration generator now tries to fetch reference column name for foreignKey from schema if it's not set explicitly (MKiselev) - Enh #12750: `yii\widgets\ListView::itemOptions` can be a closure now (webdevsega, silverfire) +- Enh #12771: Skip \yii\rbac\PhpManager::checkAccessRecursive and \yii\rbac\DbManager::checkAccessRecursive if role assignments are empty (Ni-san) - Enh #12790: Added `scrollToErrorOffset` option for `yii\widgets\ActiveForm` which adds ability to specify offset in pixels when scrolling to error (mg-code) - Enh #12798: Changed `yii\cache\Dependency::getHasChanged()` (deprecated, to be removed in 2.1) to `yii\cache\Dependency::isChanged()` (dynasource) - Enh #12807: Added console controller checks for `yii\console\controllers\HelpController` (schmunk42) - Enh #12816: Added `columnSchemaClass` option for `yii\db\Schema` which adds ability to specify custom `yii\db\ColumnSchema` class (nanodesu88) +- Enh #12854: Added `RangeNotSatisfiableHttpException` to cover HTTP error 416 file request exceptions (zalatov) - Enh #12881: Added `removeValue` method to `yii\helpers\BaseArrayHelper` (nilsburg) - Enh #12901: Added `getDefaultHelpHeader` method to the `yii\console\controllers\HelpController` class to be able to override default help header in a class heir (diezztsk) +- Enh #12988: Changed `textarea` method within the `yii\helpers\BaseHtml` class to allow users to control whether html entities found within `$value` will be double-encoded or not (cyphix333) +- Enh #13020: Added `disabledListItemSubTagOptions` attribute for `yii\widgets\LinkPager` in order to customize the disabled list item sub tag element (nadar) - Enh #13035: Use ArrayHelper::getValue() in SluggableBehavior::getValue() (thyseus) - Enh #13036: Added shortcut methods `asJson()` and `asXml()` for returning JSON and XML data in web controller actions (cebe) -- Enh #13020: Added `disabledListItemSubTagOptions` attribute for `yii\widgets\LinkPager` in order to customize the disabled list item sub tag element (nadar) -- Enh #12988: Changed `textarea` method within the `yii\helpers\BaseHtml` class to allow users to control whether html entities found within `$value` will be double-encoded or not (cyphix333) -- Enh #13074: Improved `\yii\log\SyslogTarget` with `$options` to be able to change the default `openlog` options. (timbeks) -- Enh #13050: Added `yii\filters\HostControl` allowing protection against 'host header' attacks (klimov-paul) +- Enh #13050: Added `yii\filters\HostControl` allowing protection against 'host header' attacks (klimov-paul, rob006) +- Enh #13074: Improved `yii\log\SyslogTarget` with `$options` to be able to change the default `openlog` options. (timbeks) +- Bug: #12969: Improved unique ID generation for `yii\widgets\Pjax` widgets (dynasource, samdark, rob006) +- Enh #13122: Optimized query for information about foreign keys in `yii\db\oci` (zlakomanoff) +- Enh #13202: Refactor validateAttribute method in UniqueValidator (developeruz) - Enh: Added constants for specifying `yii\validators\CompareValidator::$type` (cebe) + 2.0.10 October 20, 2016 ----------------------- @@ -106,6 +134,7 @@ Yii Framework 2 Change Log - Bug #12605: Make 'safe' validator work on write-only properties (arthibald, CeBe) - Bug #12629: Fixed `yii\widgets\ActiveField::widget()` to call `adjustLabelFor()` for `InputWidget` descendants (coderlex) - Bug #12649: Fixed consistency of `indexBy` handling for `yii\db\Query::column()` (silverfire) +- Bug #12713: Fixed `yii\caching\FileDependency` to clear stat cache before reading filemtime (SG5) - Enh #384: Added ability to run migration from several locations via `yii\console\controllers\BaseMigrateController::$migrationNamespaces` (klimov-paul) - Enh #6996: Added `yii\web\MultipartFormDataParser`, which allows proper processing of 'multipart/form-data' encoded non POST requests (klimov-paul) - Enh #8719: Add support for HTML5 attributes on submitbutton (formaction/formmethod...) for ActiveForm (VirtualRJ) @@ -140,11 +169,11 @@ Yii Framework 2 Change Log - Enh #12440: Added `yii\base\Event::offAll()` method allowing clear all registered class-level event handlers (klimov-paul) - Enh #12499: When AJAX validation in enabled, `yii.activeForm.js` will run it forcefully on form submit to display all possible errors (silverfire) - Enh #12580: Make `yii.js` comply with strict and non-strict javascript mode to allow concatenation with external code (mikehaertl) +- Enh #12612: Query conditions added with `yii\db\Query::andWhere()` now get appended to the existing conditions if they were already being joined with the `and` operator (brandonkelly) - Enh #12664: Added support for wildcards for `optional` at `yii\filters\auth\AuthMethod` (mg-code) - Enh #12744: Added `afterInit` event to `yii.activeForm.js` (werew01f) - Enh: Method `yii\console\controllers\AssetController::getAssetManager()` automatically enables `yii\web\AssetManager::forceCopy` in case it is not explicitly specified (pana1990, klimov-paul) - 2.0.9 July 11, 2016 ------------------- diff --git a/framework/UPGRADE.md b/framework/UPGRADE.md index d91e00d..3db7d17 100644 --- a/framework/UPGRADE.md +++ b/framework/UPGRADE.md @@ -29,14 +29,14 @@ The simple way to upgrade Yii, for example to version 2.0.10 (replace this with composer require "yiisoft/yii2:~2.0.10" This however may fail due to changes in the dependencies of yiisoft/yii2, which may change due to security updates -in other libraries or by adding support for newer versions. `composer require` will not update any other packages +in other libraries or by adding support for newer versions. `composer require` will not update any other packages as a safety feature. The better way to upgrade is to change the `composer.json` file to require the new Yii version and then run `composer update` by specifying all packages that are allowed to be updated. composer update yiisoft/yii2 yiisoft/yii2-composer bower-asset/jquery.inputmask - + The above command will only update the specified packages and leave the versions of all other dependencies intact. This helps to update packages step by step without causing a lot of package version changes that might break in some way. If you feel lucky you can of course update everything to the latest version by running `composer update` without @@ -50,6 +50,22 @@ if you want to upgrade from version A to version C and there is version B between A and C, you need to follow the instructions for both A and B. +Upgrade from Yii 2.0.10 +----------------------- + +* A new method `public function emulateExecution($value = true);` has been added to the `yii\db\QueryInterace`. + This method is implemented in the `yii\db\QueryTrait`, so this only affects your code if you implement QueryInterface + in a class that does not use the trait. + +* `yii\validators\FileValidator::getClientOptions()` and `yii\validators\ImageValidator::getClientOptions()` are now public. + If you extend from these classes and override these methods, you must make them public as well. + +* PJAX: Auto generated IDs of the Pjax widget have been changed to use their own prefix to avoid conflicts. + Auto generated IDs are now prefixed with `p` instead of `w`. This is defined by the `$autoIdPrefix` + property of `yii\widgets\Pjax`. If you have any PHP or Javascript code that depends on autogenerated IDs + you should update these to match this new value. It is not a good idea to rely on auto generated values anyway, so + you better fix these cases by specifying an explicit ID. + Upgrade from Yii 2.0.9 ---------------------- diff --git a/framework/assets/yii.activeForm.js b/framework/assets/yii.activeForm.js index 795d18e..bb39f85 100644 --- a/framework/assets/yii.activeForm.js +++ b/framework/assets/yii.activeForm.js @@ -168,7 +168,9 @@ // whether the validation is cancelled by beforeValidateAttribute event handler cancelled: false, // the value of the input - value: undefined + value: undefined, + // whether to update aria-invalid attribute after validation + updateAriaInvalid: true }; @@ -304,9 +306,9 @@ needAjaxValidation = false, messages = {}, deferreds = deferredArray(), - submitting = data.submitting; + submitting = data.submitting && !forceValidate; - if (submitting) { + if (data.submitting) { var event = $.Event(events.beforeValidate); $form.trigger(event, [messages, deferreds]); @@ -707,6 +709,7 @@ hasError = messages[attribute.id].length > 0; var $container = $form.find(attribute.container); var $error = $container.find(attribute.error); + updateAriaInvalid($form, attribute, hasError); if (hasError) { if (attribute.encodeError) { $error.text(messages[attribute.id][0]); @@ -775,4 +778,9 @@ } }; + var updateAriaInvalid = function ($form, attribute, hasError) { + if (attribute.updateAriaInvalid) { + $form.find(attribute.input).attr('aria-invalid', hasError ? 'true' : 'false'); + } + } })(window.jQuery); diff --git a/framework/assets/yii.captcha.js b/framework/assets/yii.captcha.js index b5c01c5..7293183 100644 --- a/framework/assets/yii.captcha.js +++ b/framework/assets/yii.captcha.js @@ -16,7 +16,7 @@ } else if (typeof method === 'object' || !method) { return methods.init.apply(this, arguments); } else { - $.error('Method ' + method + ' does not exist on jQuery.yiiCaptcha'); + $.error('Method ' + method + ' does not exist in jQuery.yiiCaptcha'); return false; } }; @@ -39,7 +39,6 @@ methods.refresh.apply($e); return false; }); - }); }, @@ -58,10 +57,9 @@ }, destroy: function () { - return this.each(function () { - $(window).unbind('.yiiCaptcha'); - $(this).removeData('yiiCaptcha'); - }); + this.off('.yiiCaptcha'); + this.removeData('yiiCaptcha'); + return this; }, data: function () { @@ -69,4 +67,3 @@ } }; })(window.jQuery); - diff --git a/framework/assets/yii.js b/framework/assets/yii.js index e2db617..73484ef 100644 --- a/framework/assets/yii.js +++ b/framework/assets/yii.js @@ -153,7 +153,8 @@ window.yii = (function ($) { method = !$e.data('method') && $form ? $form.attr('method') : $e.data('method'), action = $e.attr('href'), params = $e.data('params'), - pjax = $e.data('pjax'), + pjax = $e.data('pjax') || 0, + usePjax = pjax !== 0 && $.support.pjax, pjaxPushState = !!$e.data('pjax-push-state'), pjaxReplaceState = !!$e.data('pjax-replace-state'), pjaxTimeout = $e.data('pjax-timeout'), @@ -164,7 +165,7 @@ window.yii = (function ($) { pjaxContainer, pjaxOptions = {}; - if (pjax !== undefined && $.support.pjax) { + if (usePjax) { if ($e.data('pjax-container')) { pjaxContainer = $e.data('pjax-container'); } else { @@ -190,13 +191,13 @@ window.yii = (function ($) { if (method === undefined) { if (action && action != '#') { - if (pjax !== undefined && $.support.pjax) { + if (usePjax) { $.pjax.click(event, pjaxOptions); } else { window.location = action; } } else if ($e.is(':submit') && $form.length) { - if (pjax !== undefined && $.support.pjax) { + if (usePjax) { $form.on('submit',function(e){ $.pjax.submit(e, pjaxOptions); }) @@ -249,7 +250,7 @@ window.yii = (function ($) { oldAction = $form.attr('action'); $form.attr('action', action); } - if (pjax !== undefined && $.support.pjax) { + if (usePjax) { $form.on('submit',function(e){ $.pjax.submit(e, pjaxOptions); }) diff --git a/framework/assets/yii.validation.js b/framework/assets/yii.validation.js index c49be40..984690d 100644 --- a/framework/assets/yii.validation.js +++ b/framework/assets/yii.validation.js @@ -13,7 +13,7 @@ yii.validation = (function ($) { var pub = { isEmpty: function (value) { - return value === null || value === undefined || value == [] || value === ''; + return value === null || value === undefined || ($.isArray(value) && value.length === 0) || value === ''; }, addMessage: function (messages, message, value) { @@ -36,6 +36,7 @@ yii.validation = (function ($) { } }, + // "boolean" is a reserved keyword in older versions of ES so it's quoted for IE < 9 support 'boolean': function (value, messages, options) { if (options.skipOnEmpty && pub.isEmpty(value)) { return; @@ -58,15 +59,16 @@ yii.validation = (function ($) { return; } + if (options.is !== undefined && value.length != options.is) { + pub.addMessage(messages, options.notEqual, value); + return; + } if (options.min !== undefined && value.length < options.min) { pub.addMessage(messages, options.tooShort, value); } if (options.max !== undefined && value.length > options.max) { pub.addMessage(messages, options.tooLong, value); } - if (options.is !== undefined && value.length != options.is) { - pub.addMessage(messages, options.notEqual, value); - } }, file: function (attribute, messages, options) { @@ -76,9 +78,8 @@ yii.validation = (function ($) { }); }, - image: function (attribute, messages, options, deferred) { + image: function (attribute, messages, options, deferredList) { var files = getUploadedFiles(attribute, messages, options); - $.each(files, function (i, file) { validateFile(file, messages, options); @@ -87,48 +88,33 @@ yii.validation = (function ($) { return; } - var def = $.Deferred(), - fr = new FileReader(), - img = new Image(); - - img.onload = function () { - if (options.minWidth && this.width < options.minWidth) { - messages.push(options.underWidth.replace(/\{file\}/g, file.name)); - } - - if (options.maxWidth && this.width > options.maxWidth) { - messages.push(options.overWidth.replace(/\{file\}/g, file.name)); - } - - if (options.minHeight && this.height < options.minHeight) { - messages.push(options.underHeight.replace(/\{file\}/g, file.name)); - } - - if (options.maxHeight && this.height > options.maxHeight) { - messages.push(options.overHeight.replace(/\{file\}/g, file.name)); - } - def.resolve(); - }; - - img.onerror = function () { - messages.push(options.notImage.replace(/\{file\}/g, file.name)); - def.resolve(); - }; + var deferred = $.Deferred(); + pub.validateImage(file, messages, options, deferred, new FileReader(), new Image()); + deferredList.push(deferred); + }); + }, - fr.onload = function () { - img.src = fr.result; - }; + validateImage: function (file, messages, options, deferred, fileReader, image) { + image.onload = function() { + validateImageSize(file, image, messages, options); + deferred.resolve(); + }; - // Resolve deferred if there was error while reading data - fr.onerror = function () { - def.resolve(); - }; + image.onerror = function () { + messages.push(options.notImage.replace(/\{file\}/g, file.name)); + deferred.resolve(); + }; - fr.readAsDataURL(file); + fileReader.onload = function () { + image.src = this.result; + }; - deferred.push(def); - }); + // Resolve deferred if there was error while reading data + fileReader.onerror = function () { + deferred.resolve(); + }; + fileReader.readAsDataURL(file); }, number: function (value, messages, options) { @@ -170,6 +156,10 @@ yii.validation = (function ($) { } }); + if (options.not === undefined) { + options.not = false; + } + if (options.not === inArray) { pub.addMessage(messages, options.message, value); } @@ -190,25 +180,26 @@ yii.validation = (function ($) { return; } - var valid = true; - - - var regexp = /^((?:"?([^"]*)"?\s)?)(?:\s+)?(?:(]+))(>?))$/, + var valid = true, + regexp = /^((?:"?([^"]*)"?\s)?)(?:\s+)?(?:(]+))(>?))$/, matches = regexp.exec(value); if (matches === null) { - valid = false + valid = false; } else { + var localPart = matches[5], + domain = matches[6]; + if (options.enableIDN) { - matches[5] = punycode.toASCII(matches[5]); - matches[6] = punycode.toASCII(matches[6]); + localPart = punycode.toASCII(localPart); + domain = punycode.toASCII(domain); - value = matches[1] + matches[3] + matches[5] + '@' + matches[6] + matches[7]; + value = matches[1] + matches[3] + localPart + '@' + domain + matches[7]; } - if (matches[5].length > 64) { + if (localPart.length > 64) { valid = false; - } else if ((matches[5] + '@' + matches[6]).length > 254) { + } else if ((localPart + '@' + domain).length > 254) { valid = false; } else { valid = options.pattern.test(value) || (options.allowName && options.fullPattern.test(value)); @@ -262,11 +253,7 @@ yii.validation = (function ($) { // CAPTCHA may be updated via AJAX and the updated hash is stored in body data var hash = $('body').data(options.hashKey); - if (hash == null) { - hash = options.hash; - } else { - hash = hash[options.caseSensitive ? 0 : 1]; - } + hash = hash == null ? options.hash : hash[options.caseSensitive ? 0 : 1]; var v = options.caseSensitive ? value : value.toLowerCase(); for (var i = v.length - 1, h = 0; i >= 0; --i) { h += v.charCodeAt(i); @@ -281,7 +268,8 @@ yii.validation = (function ($) { return; } - var compareValue, valid = true; + var compareValue, + valid = true; if (options.compareAttribute === undefined) { compareValue = options.compareValue; } else { @@ -328,17 +316,13 @@ yii.validation = (function ($) { }, ip: function (value, messages, options) { - var getIpVersion = function (value) { - return value.indexOf(':') === -1 ? 4 : 6; - }; - - var negation = null, cidr = null; - if (options.skipOnEmpty && pub.isEmpty(value)) { return; } - var matches = new RegExp(options.ipParsePattern).exec(value); + var negation = null, + cidr = null, + matches = new RegExp(options.ipParsePattern).exec(value); if (matches) { negation = matches[1] || null; value = matches[2]; @@ -358,20 +342,21 @@ yii.validation = (function ($) { return; } - if (getIpVersion(value) == 6) { + var ipVersion = value.indexOf(':') === -1 ? 4 : 6; + if (ipVersion == 6) { + if (!(new RegExp(options.ipv6Pattern)).test(value)) { + pub.addMessage(messages, options.messages.message, value); + } if (!options.ipv6) { pub.addMessage(messages, options.messages.ipv6NotAllowed, value); } - if (!(new RegExp(options.ipv6Pattern)).test(value)) { + } else { + if (!(new RegExp(options.ipv4Pattern)).test(value)) { pub.addMessage(messages, options.messages.message, value); } - } else { if (!options.ipv4) { pub.addMessage(messages, options.messages.ipv4NotAllowed, value); } - if (!(new RegExp(options.ipv4Pattern)).test(value)) { - pub.addMessage(messages, options.messages.message, value); - } } } }; @@ -405,15 +390,8 @@ yii.validation = (function ($) { function validateFile(file, messages, options) { if (options.extensions && options.extensions.length > 0) { - var index, ext; - - index = file.name.lastIndexOf('.'); - - if (!~index) { - ext = ''; - } else { - ext = file.name.substr(index + 1, file.name.length).toLowerCase(); - } + var index = file.name.lastIndexOf('.'); + var ext = !~index ? '' : file.name.substr(index + 1, file.name.length).toLowerCase(); if (!~options.extensions.indexOf(ext)) { messages.push(options.wrongExtension.replace(/\{file\}/g, file.name)); @@ -445,5 +423,23 @@ yii.validation = (function ($) { return false; } + function validateImageSize(file, image, messages, options) { + if (options.minWidth && image.width < options.minWidth) { + messages.push(options.underWidth.replace(/\{file\}/g, file.name)); + } + + if (options.maxWidth && image.width > options.maxWidth) { + messages.push(options.overWidth.replace(/\{file\}/g, file.name)); + } + + if (options.minHeight && image.height < options.minHeight) { + messages.push(options.underHeight.replace(/\{file\}/g, file.name)); + } + + if (options.maxHeight && image.height > options.maxHeight) { + messages.push(options.overHeight.replace(/\{file\}/g, file.name)); + } + } + return pub; })(jQuery); diff --git a/framework/base/Application.php b/framework/base/Application.php index f31bfd1d..0368880 100644 --- a/framework/base/Application.php +++ b/framework/base/Application.php @@ -246,6 +246,12 @@ abstract class Application extends Module $this->setTimeZone('UTC'); } + if (isset($config['container'])) { + $this->setContainer($config['container']); + + unset($config['container']); + } + // merge core components with custom components foreach ($this->coreComponents() as $id => $component) { if (!isset($config['components'][$id])) { @@ -652,4 +658,15 @@ abstract class Application extends Module exit($status); } } + + /** + * Configures [[Yii::$container]] with the $config + * + * @param array $config values given in terms of name-value pairs + * @since 2.0.11 + */ + public function setContainer($config) + { + Yii::configure(Yii::$container, $config); + } } diff --git a/framework/base/Component.php b/framework/base/Component.php index f116ce9..83568f6 100644 --- a/framework/base/Component.php +++ b/framework/base/Component.php @@ -130,20 +130,21 @@ class Component extends Object if (method_exists($this, $getter)) { // read property, e.g. getName() return $this->$getter(); - } else { - // behavior property - $this->ensureBehaviors(); - foreach ($this->_behaviors as $behavior) { - if ($behavior->canGetProperty($name)) { - return $behavior->$name; - } + } + + // behavior property + $this->ensureBehaviors(); + foreach ($this->_behaviors as $behavior) { + if ($behavior->canGetProperty($name)) { + return $behavior->$name; } } + if (method_exists($this, 'set' . $name)) { throw new InvalidCallException('Getting write-only property: ' . get_class($this) . '::' . $name); - } else { - throw new UnknownPropertyException('Getting unknown property: ' . get_class($this) . '::' . $name); } + + throw new UnknownPropertyException('Getting unknown property: ' . get_class($this) . '::' . $name); } /** @@ -182,22 +183,22 @@ class Component extends Object $this->attachBehavior($name, $value instanceof Behavior ? $value : Yii::createObject($value)); return; - } else { - // behavior property - $this->ensureBehaviors(); - foreach ($this->_behaviors as $behavior) { - if ($behavior->canSetProperty($name)) { - $behavior->$name = $value; + } - return; - } + // behavior property + $this->ensureBehaviors(); + foreach ($this->_behaviors as $behavior) { + if ($behavior->canSetProperty($name)) { + $behavior->$name = $value; + return; } } + if (method_exists($this, 'get' . $name)) { throw new InvalidCallException('Setting read-only property: ' . get_class($this) . '::' . $name); - } else { - throw new UnknownPropertyException('Setting unknown property: ' . get_class($this) . '::' . $name); } + + throw new UnknownPropertyException('Setting unknown property: ' . get_class($this) . '::' . $name); } /** @@ -219,15 +220,16 @@ class Component extends Object $getter = 'get' . $name; if (method_exists($this, $getter)) { return $this->$getter() !== null; - } else { - // behavior property - $this->ensureBehaviors(); - foreach ($this->_behaviors as $behavior) { - if ($behavior->canGetProperty($name)) { - return $behavior->$name !== null; - } + } + + // behavior property + $this->ensureBehaviors(); + foreach ($this->_behaviors as $behavior) { + if ($behavior->canGetProperty($name)) { + return $behavior->$name !== null; } } + return false; } @@ -250,16 +252,17 @@ class Component extends Object if (method_exists($this, $setter)) { $this->$setter(null); return; - } else { - // behavior property - $this->ensureBehaviors(); - foreach ($this->_behaviors as $behavior) { - if ($behavior->canSetProperty($name)) { - $behavior->$name = null; - return; - } + } + + // behavior property + $this->ensureBehaviors(); + foreach ($this->_behaviors as $behavior) { + if ($behavior->canSetProperty($name)) { + $behavior->$name = null; + return; } } + throw new InvalidCallException('Unsetting an unknown or read-only property: ' . get_class($this) . '::' . $name); } @@ -503,19 +506,19 @@ class Component extends Object if ($handler === null) { unset($this->_events[$name]); return true; - } else { - $removed = false; - foreach ($this->_events[$name] as $i => $event) { - if ($event[0] === $handler) { - unset($this->_events[$name][$i]); - $removed = true; - } - } - if ($removed) { - $this->_events[$name] = array_values($this->_events[$name]); + } + + $removed = false; + foreach ($this->_events[$name] as $i => $event) { + if ($event[0] === $handler) { + unset($this->_events[$name][$i]); + $removed = true; } - return $removed; } + if ($removed) { + $this->_events[$name] = array_values($this->_events[$name]); + } + return $removed; } /** @@ -621,9 +624,9 @@ class Component extends Object unset($this->_behaviors[$name]); $behavior->detach(); return $behavior; - } else { - return null; } + + return null; } /** @@ -666,13 +669,14 @@ class Component extends Object if (is_int($name)) { $behavior->attach($this); $this->_behaviors[] = $behavior; - } else { - if (isset($this->_behaviors[$name])) { - $this->_behaviors[$name]->detach(); - } - $behavior->attach($this); - $this->_behaviors[$name] = $behavior; } + + if (isset($this->_behaviors[$name])) { + $this->_behaviors[$name]->detach(); + } + $behavior->attach($this); + $this->_behaviors[$name] = $behavior; + return $behavior; } } diff --git a/framework/base/Controller.php b/framework/base/Controller.php index 690c3cd..41e98cb 100644 --- a/framework/base/Controller.php +++ b/framework/base/Controller.php @@ -164,7 +164,9 @@ class Controller extends Component implements ViewContextInterface } } - $this->action = $oldAction; + if ($oldAction !== null) { + $this->action = $oldAction; + } return $result; } @@ -186,9 +188,8 @@ class Controller extends Component implements ViewContextInterface return $this->runAction($route, $params); } elseif ($pos > 0) { return $this->module->runAction($route, $params); - } else { - return Yii::$app->runAction(ltrim($route, '/'), $params); } + return Yii::$app->runAction(ltrim($route, '/'), $params); } /** @@ -393,9 +394,8 @@ class Controller extends Component implements ViewContextInterface $layoutFile = $this->findLayoutFile($this->getView()); if ($layoutFile !== false) { return $this->getView()->renderFile($layoutFile, ['content' => $content], $this); - } else { - return $content; } + return $content; } /** diff --git a/framework/base/Event.php b/framework/base/Event.php index 498607a..d5cca0c 100644 --- a/framework/base/Event.php +++ b/framework/base/Event.php @@ -116,20 +116,19 @@ class Event extends Object if ($handler === null) { unset(self::$_events[$name][$class]); return true; - } else { - $removed = false; - foreach (self::$_events[$name][$class] as $i => $event) { - if ($event[0] === $handler) { - unset(self::$_events[$name][$class][$i]); - $removed = true; - } - } - if ($removed) { - self::$_events[$name][$class] = array_values(self::$_events[$name][$class]); - } + } - return $removed; + $removed = false; + foreach (self::$_events[$name][$class] as $i => $event) { + if ($event[0] === $handler) { + unset(self::$_events[$name][$class][$i]); + $removed = true; + } + } + if ($removed) { + self::$_events[$name][$class] = array_values(self::$_events[$name][$class]); } + return $removed; } /** diff --git a/framework/base/Model.php b/framework/base/Model.php index 05fecdd..91b7d0f 100644 --- a/framework/base/Model.php +++ b/framework/base/Model.php @@ -562,9 +562,8 @@ class Model extends Component implements IteratorAggregate, ArrayAccess, Arrayab { if ($attribute === null) { return $this->_errors === null ? [] : $this->_errors; - } else { - return isset($this->_errors[$attribute]) ? $this->_errors[$attribute] : []; } + return isset($this->_errors[$attribute]) ? $this->_errors[$attribute] : []; } /** @@ -578,16 +577,15 @@ class Model extends Component implements IteratorAggregate, ArrayAccess, Arrayab { if (empty($this->_errors)) { return []; - } else { - $errors = []; - foreach ($this->_errors as $name => $es) { - if (!empty($es)) { - $errors[$name] = reset($es); - } - } + } - return $errors; + $errors = []; + foreach ($this->_errors as $name => $es) { + if (!empty($es)) { + $errors[$name] = reset($es); + } } + return $errors; } /** @@ -829,9 +827,8 @@ class Model extends Component implements IteratorAggregate, ArrayAccess, Arrayab $this->setAttributes($data[$scope]); return true; - } else { - return false; } + return false; } /** diff --git a/framework/base/Module.php b/framework/base/Module.php index bd347ea..670d157 100644 --- a/framework/base/Module.php +++ b/framework/base/Module.php @@ -391,9 +391,8 @@ class Module extends ServiceLocator $module = $this->getModule(substr($id, 0, $pos)); return $module === null ? false : $module->hasModule(substr($id, $pos + 1)); - } else { - return isset($this->_modules[$id]); } + return isset($this->_modules[$id]); } /** @@ -467,9 +466,8 @@ class Module extends ServiceLocator } return $modules; - } else { - return $this->_modules; } + return $this->_modules; } /** @@ -527,10 +525,10 @@ class Module extends ServiceLocator } return $result; - } else { - $id = $this->getUniqueId(); - throw new InvalidRouteException('Unable to resolve the request "' . ($id === '' ? $route : $id . '/' . $route) . '".'); } + + $id = $this->getUniqueId(); + throw new InvalidRouteException('Unable to resolve the request "' . ($id === '' ? $route : $id . '/' . $route) . '".'); } /** @@ -640,9 +638,8 @@ class Module extends ServiceLocator return get_class($controller) === $className ? $controller : null; } elseif (YII_DEBUG) { throw new InvalidConfigException("Controller class must extend from \\yii\\base\\Controller."); - } else { - return null; } + return null; } /** diff --git a/framework/base/View.php b/framework/base/View.php index 4d3ff52..f085475 100644 --- a/framework/base/View.php +++ b/framework/base/View.php @@ -347,9 +347,8 @@ class View extends Component $this->addDynamicPlaceholder($placeholder, $statements); return $placeholder; - } else { - return $this->evaluateDynamicContent($statements); } + return $this->evaluateDynamicContent($statements); } /** @@ -465,9 +464,8 @@ class View extends Component $this->endCache(); return false; - } else { - return true; } + return true; } /** diff --git a/framework/caching/ArrayCache.php b/framework/caching/ArrayCache.php index 752ae54..3c6efb5 100644 --- a/framework/caching/ArrayCache.php +++ b/framework/caching/ArrayCache.php @@ -15,6 +15,8 @@ namespace yii\caching; * Unlike the [[Cache]], ArrayCache allows the expire parameter of [[set]], [[add]], [[multiSet]] and [[multiAdd]] to * be a floating point number, so you may specify the time in milliseconds (e.g. 0.1 will be 100 milliseconds). * + * For enhanced performance of ArrayCache, you can disable serialization of the stored data by setting [[$serializer]] to `false`. + * * For more details and usage information on Cache, see the [guide article on caching](guide:caching-overview). * * @author Carsten Brandt diff --git a/framework/caching/Cache.php b/framework/caching/Cache.php index 2acdf1c..4cc32d0 100644 --- a/framework/caching/Cache.php +++ b/framework/caching/Cache.php @@ -209,7 +209,7 @@ abstract class Cache extends Component implements \ArrayAccess * a complex data structure consisting of factors representing the key. * @param mixed $value the value to be cached * @param int $duration default duration in seconds before the cache will expire. If not set, - * default [[ttl]] value is used. + * default [[defaultDuration]] value is used. * @param Dependency $dependency dependency of the cached item. If the dependency changes, * the corresponding value in the cache will be invalidated when it is fetched via [[get()]]. * This parameter is ignored if [[serializer]] is false. diff --git a/framework/caching/FileDependency.php b/framework/caching/FileDependency.php index 177734e..49c4e1e 100644 --- a/framework/caching/FileDependency.php +++ b/framework/caching/FileDependency.php @@ -43,6 +43,8 @@ class FileDependency extends Dependency throw new InvalidConfigException('FileDependency::fileName must be set'); } - return @filemtime(Yii::getAlias($this->fileName)); + $fileName = Yii::getAlias($this->fileName); + clearstatcache(false, $fileName); + return @filemtime($fileName); } } diff --git a/framework/captcha/CaptchaValidator.php b/framework/captcha/CaptchaValidator.php index 83eac47..357a81b 100644 --- a/framework/captcha/CaptchaValidator.php +++ b/framework/captcha/CaptchaValidator.php @@ -86,6 +86,17 @@ class CaptchaValidator extends Validator */ public function clientValidateAttribute($model, $attribute, $view) { + ValidationAsset::register($view); + $options = $this->getClientOptions($model, $attribute); + + return 'yii.validation.captcha(value, messages, ' . json_encode($options, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ');'; + } + + /** + * @inheritdoc + */ + public function getClientOptions($model, $attribute) + { $captcha = $this->createCaptchaAction(); $code = $captcha->getVerifyCode(false); $hash = $captcha->generateValidationHash($this->caseSensitive ? $code : strtolower($code)); @@ -101,8 +112,6 @@ class CaptchaValidator extends Validator $options['skipOnEmpty'] = 1; } - ValidationAsset::register($view); - - return 'yii.validation.captcha(value, messages, ' . json_encode($options, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ');'; + return $options; } } diff --git a/framework/console/Application.php b/framework/console/Application.php index e550bfc..ab096f5 100644 --- a/framework/console/Application.php +++ b/framework/console/Application.php @@ -180,7 +180,7 @@ class Application extends \yii\base\Application $res = parent::runAction($route, $params); return is_object($res) ? $res : (int)$res; } catch (InvalidRouteException $e) { - throw new Exception("Unknown command \"$route\".", 0, $e); + throw new UnknownCommandException($route, $this, 0, $e); } } diff --git a/framework/console/ErrorHandler.php b/framework/console/ErrorHandler.php index c2ef846..cf62613 100644 --- a/framework/console/ErrorHandler.php +++ b/framework/console/ErrorHandler.php @@ -29,7 +29,16 @@ class ErrorHandler extends \yii\base\ErrorHandler */ protected function renderException($exception) { - if ($exception instanceof Exception && ($exception instanceof UserException || !YII_DEBUG)) { + if ($exception instanceof UnknownCommandException) { + // display message and suggest alternatives in case of unknown command + $message = $this->formatMessage($exception->getName() . ': ') . $exception->command; + $alternatives = $exception->getSuggestedAlternatives(); + if (count($alternatives) === 1) { + $message .= "\n\nDid you mean \"" . reset($alternatives) . "\"?"; + } elseif (count($alternatives) > 1) { + $message .= "\n\nDid you mean one of these?\n - " . implode("\n - ", $alternatives); + } + } elseif ($exception instanceof Exception && ($exception instanceof UserException || !YII_DEBUG)) { $message = $this->formatMessage($exception->getName() . ': ') . $exception->getMessage(); } elseif (YII_DEBUG) { if ($exception instanceof Exception) { diff --git a/framework/console/UnknownCommandException.php b/framework/console/UnknownCommandException.php new file mode 100644 index 0000000..51c48c1 --- /dev/null +++ b/framework/console/UnknownCommandException.php @@ -0,0 +1,141 @@ + + * @since 2.0.11 + */ +class UnknownCommandException extends Exception +{ + /** + * @var string the name of the command that could not be recognized. + */ + public $command; + /** + * @var Application + */ + protected $application; + + + /** + * Construct the exception. + * + * @param string $route the route of the command that could not be found. + * @param Application $application the console application instance involved. + * @param int $code the Exception code. + * @param \Exception $previous the previous exception used for the exception chaining. + */ + public function __construct($route, $application, $code = 0, \Exception $previous = null) + { + $this->command = $route; + $this->application = $application; + parent::__construct("Unknown command \"$route\".", $code, $previous); + } + + /** + * @return string the user-friendly name of this exception + */ + public function getName() + { + return 'Unknown command'; + } + + /** + * Suggest alternative commands for [[$command]] based on string similarity. + * + * Alternatives are searched using the following steps: + * + * - suggest alternatives that begin with `$command` + * - find typos by calculating the Levenshtein distance between the unknown command and all + * available commands. The Levenshtein distance is defined as the minimal number of + * characters you have to replace, insert or delete to transform str1 into str2. + * + * @see http://php.net/manual/en/function.levenshtein.php + * @return array a list of suggested alternatives sorted by similarity. + */ + public function getSuggestedAlternatives() + { + $help = $this->application->createController('help'); + if ($help === false) { + return []; + } + /** @var $helpController HelpController */ + list($helpController, $actionID) = $help; + + $availableActions = []; + $commands = $helpController->getCommands(); + foreach ($commands as $command) { + $result = $this->application->createController($command); + if ($result === false) { + continue; + } + // add the command itself (default action) + $availableActions[] = $command; + + // add all actions of this controller + /** @var $controller Controller */ + list($controller, $actionID) = $result; + $actions = $helpController->getActions($controller); + if (!empty($actions)) { + $prefix = $controller->getUniqueId(); + foreach ($actions as $action) { + $availableActions[] = $prefix . '/' . $action; + } + } + } + return $this->filterBySimilarity($availableActions, $this->command); + } + + /** + * Find suggest alternative commands based on string similarity. + * + * Alternatives are searched using the following steps: + * + * - suggest alternatives that begin with `$command` + * - find typos by calculating the Levenshtein distance between the unknown command and all + * available commands. The Levenshtein distance is defined as the minimal number of + * characters you have to replace, insert or delete to transform str1 into str2. + * + * @see http://php.net/manual/en/function.levenshtein.php + * @param array $actions available command names. + * @param string $command the command to compare to. + * @return array a list of suggested alternatives sorted by similarity. + */ + private function filterBySimilarity($actions, $command) + { + $alternatives = []; + + // suggest alternatives that begin with $command first + foreach ($actions as $action) { + if (strpos($action, $command) === 0) { + $alternatives[] = $action; + } + } + + // calculate the Levenshtein distance between the unknown command and all available commands. + $distances = array_map(function($action) use ($command) { + $action = strlen($action) > 255 ? substr($action, 0, 255) : $action; + $command = strlen($command) > 255 ? substr($command, 0, 255) : $command; + return levenshtein($action, $command); + }, array_combine($actions, $actions)); + + // we assume a typo if the levensthein distance is no more than 3, i.e. 3 replacements needed + $relevantTypos = array_filter($distances, function($distance) { + return $distance <= 3; + }); + asort($relevantTypos); + $alternatives = array_merge($alternatives, array_flip($relevantTypos)); + + return array_unique($alternatives); + } +} diff --git a/framework/console/controllers/HelpController.php b/framework/console/controllers/HelpController.php index 27fbcd6..6e8f6fb 100644 --- a/framework/console/controllers/HelpController.php +++ b/framework/console/controllers/HelpController.php @@ -69,6 +69,110 @@ class HelpController extends Controller } /** + * List all available controllers and actions in machine readable format. + * This is used for shell completion. + * @since 2.0.11 + */ + public function actionList() + { + $commands = $this->getCommandDescriptions(); + foreach ($commands as $command => $description) { + $result = Yii::$app->createController($command); + if ($result === false || !($result[0] instanceof Controller)) { + continue; + } + /** @var $controller Controller */ + list($controller, $actionID) = $result; + $actions = $this->getActions($controller); + if (!empty($actions)) { + $prefix = $controller->getUniqueId(); + $this->stdout("$prefix\n"); + foreach ($actions as $action) { + $this->stdout("$prefix/$action\n"); + } + } + } + } + + /** + * List all available options for the $action in machine readable format. + * This is used for shell completion. + * + * @param string $action route to action + * @since 2.0.11 + */ + public function actionListActionOptions($action) + { + $result = Yii::$app->createController($action); + + if ($result === false || !($result[0] instanceof Controller)) { + return; + } + + /** @var Controller $controller */ + list($controller, $actionID) = $result; + $action = $controller->createAction($actionID); + if ($action === null) { + return; + } + + $arguments = $controller->getActionArgsHelp($action); + foreach ($arguments as $argument => $help) { + $description = str_replace("\n", '', addcslashes($help['comment'], ':')) ?: $argument; + $this->stdout($argument . ':' . $description . "\n"); + } + + $this->stdout("\n"); + $options = $controller->getActionOptionsHelp($action); + foreach ($options as $argument => $help) { + $description = str_replace("\n", '', addcslashes($help['comment'], ':')) ?: $argument; + $this->stdout('--' . $argument . ':' . $description . "\n"); + } + } + + /** + * Displays usage information for $action + * + * @param string $action route to action + * @since 2.0.11 + */ + public function actionUsage($action) + { + $result = Yii::$app->createController($action); + + if ($result === false || !($result[0] instanceof Controller)) { + return; + } + + /** @var Controller $controller */ + list($controller, $actionID) = $result; + $action = $controller->createAction($actionID); + if ($action === null) { + return; + } + + $scriptName = $this->getScriptName(); + if ($action->id === $controller->defaultAction) { + $this->stdout($scriptName . ' ' . $this->ansiFormat($controller->getUniqueId(), Console::FG_YELLOW)); + } else { + $this->stdout($scriptName . ' ' . $this->ansiFormat($action->getUniqueId(), Console::FG_YELLOW)); + } + + $args = $controller->getActionArgsHelp($action); + foreach ($args as $name => $arg) { + if ($arg['required']) { + $this->stdout(' <' . $name . '>', Console::FG_CYAN); + } else { + $this->stdout(' [' . $name . ']', Console::FG_CYAN); + } + } + + $this->stdout("\n"); + + return; + } + + /** * Returns all available command names. * @return array all available command names */ diff --git a/framework/console/controllers/MigrateController.php b/framework/console/controllers/MigrateController.php index a91c622..2b9866a 100644 --- a/framework/console/controllers/MigrateController.php +++ b/framework/console/controllers/MigrateController.php @@ -462,7 +462,7 @@ class MigrateController extends BaseMigrateController protected function addDefaultPrimaryKey(&$fields) { foreach ($fields as $field) { - if ($field['decorators'] === 'primaryKey()' || $field['decorators'] === 'bigPrimaryKey()') { + if (false !== strripos($field['decorators'], 'primarykey()')) { return; } } diff --git a/framework/db/ActiveQuery.php b/framework/db/ActiveQuery.php index 9ad09c1..c25ea8d 100644 --- a/framework/db/ActiveQuery.php +++ b/framework/db/ActiveQuery.php @@ -155,7 +155,7 @@ class ActiveQuery extends Query implements ActiveQueryInterface } if (empty($this->select) && !empty($this->join)) { - list(, $alias) = $this->getQueryTableName($this); + list(, $alias) = $this->getTableNameAndAlias(); $this->select = ["$alias.*"]; } @@ -551,18 +551,18 @@ class ActiveQuery extends Query implements ActiveQueryInterface /** * Returns the table name and the table alias for [[modelClass]]. - * @param ActiveQuery $query * @return array the table name and the table alias. + * @internal */ - private function getQueryTableName($query) + private function getTableNameAndAlias() { - if (empty($query->from)) { + if (empty($this->from)) { /* @var $modelClass ActiveRecord */ - $modelClass = $query->modelClass; + $modelClass = $this->modelClass; $tableName = $modelClass::tableName(); } else { $tableName = ''; - foreach ($query->from as $alias => $tableName) { + foreach ($this->from as $alias => $tableName) { if (is_string($alias)) { return [$tableName, $alias]; } else { @@ -603,8 +603,8 @@ class ActiveQuery extends Query implements ActiveQueryInterface return; } - list ($parentTable, $parentAlias) = $this->getQueryTableName($parent); - list ($childTable, $childAlias) = $this->getQueryTableName($child); + list ($parentTable, $parentAlias) = $parent->getTableNameAndAlias(); + list ($childTable, $childAlias) = $child->getTableNameAndAlias(); if (!empty($child->link)) { @@ -778,7 +778,7 @@ class ActiveQuery extends Query implements ActiveQueryInterface public function alias($alias) { if (empty($this->from) || count($this->from) < 2) { - list($tableName, ) = $this->getQueryTableName($this); + list($tableName, ) = $this->getTableNameAndAlias(); $this->from = [$alias => $tableName]; } else { /* @var $modelClass ActiveRecord */ diff --git a/framework/db/ActiveRelationTrait.php b/framework/db/ActiveRelationTrait.php index b8c5241..4d9caf3 100644 --- a/framework/db/ActiveRelationTrait.php +++ b/framework/db/ActiveRelationTrait.php @@ -464,6 +464,9 @@ trait ActiveRelationTrait } } } + if (empty($values)) { + $this->emulateExecution(); + } } else { // composite keys @@ -478,6 +481,9 @@ trait ActiveRelationTrait $v[$attribute] = $model[$link]; } $values[] = $v; + if (empty($v)) { + $this->emulateExecution(); + } } } $this->andWhere(['in', $attributes, array_unique($values, SORT_REGULAR)]); diff --git a/framework/db/BaseActiveRecord.php b/framework/db/BaseActiveRecord.php index 7c18f14..b914cad 100644 --- a/framework/db/BaseActiveRecord.php +++ b/framework/db/BaseActiveRecord.php @@ -1443,6 +1443,9 @@ abstract class BaseActiveRecord extends Model implements ActiveRecordInterface if (!empty($viaRelation->where)) { $condition = ['and', $condition, $viaRelation->where]; } + if (!empty($viaRelation->on)) { + $condition = ['and', $condition, $viaRelation->on]; + } if (is_array($relation->via)) { /* @var $viaClass ActiveRecordInterface */ if ($delete) { @@ -1477,6 +1480,9 @@ abstract class BaseActiveRecord extends Model implements ActiveRecordInterface if (!empty($relation->where)) { $condition = ['and', $condition, $relation->where]; } + if (!empty($relation->on)) { + $condition = ['and', $condition, $relation->on]; + } if ($delete) { $relatedModel::deleteAll($condition); } else { diff --git a/framework/db/ColumnSchemaBuilder.php b/framework/db/ColumnSchemaBuilder.php index 024a9e8..34aae2e 100644 --- a/framework/db/ColumnSchemaBuilder.php +++ b/framework/db/ColumnSchemaBuilder.php @@ -258,7 +258,8 @@ class ColumnSchemaBuilder extends Object } /** - * Specify additional SQL to be appended to schema string. + * Specify additional SQL to be appended to column definition. + * Position modifiers will be appended after column definition in databases that support them. * @param string $sql the SQL string to be appended. * @return $this * @since 2.0.9 diff --git a/framework/db/Query.php b/framework/db/Query.php index 6fe9bfc..5b4e249 100644 --- a/framework/db/Query.php +++ b/framework/db/Query.php @@ -207,6 +207,9 @@ class Query extends Component implements QueryInterface */ public function all($db = null) { + if ($this->emulateExecution) { + return []; + } $rows = $this->createCommand($db)->queryAll(); return $this->populate($rows); } @@ -244,6 +247,9 @@ class Query extends Component implements QueryInterface */ public function one($db = null) { + if ($this->emulateExecution) { + return false; + } return $this->createCommand($db)->queryOne(); } @@ -257,6 +263,9 @@ class Query extends Component implements QueryInterface */ public function scalar($db = null) { + if ($this->emulateExecution) { + return null; + } return $this->createCommand($db)->queryScalar(); } @@ -268,6 +277,10 @@ class Query extends Component implements QueryInterface */ public function column($db = null) { + if ($this->emulateExecution) { + return []; + } + if ($this->indexBy === null) { return $this->createCommand($db)->queryColumn(); } @@ -300,6 +313,9 @@ class Query extends Component implements QueryInterface */ public function count($q = '*', $db = null) { + if ($this->emulateExecution) { + return 0; + } return $this->queryScalar("COUNT($q)", $db); } @@ -313,6 +329,9 @@ class Query extends Component implements QueryInterface */ public function sum($q, $db = null) { + if ($this->emulateExecution) { + return 0; + } return $this->queryScalar("SUM($q)", $db); } @@ -326,6 +345,9 @@ class Query extends Component implements QueryInterface */ public function average($q, $db = null) { + if ($this->emulateExecution) { + return 0; + } return $this->queryScalar("AVG($q)", $db); } @@ -363,6 +385,9 @@ class Query extends Component implements QueryInterface */ public function exists($db = null) { + if ($this->emulateExecution) { + return false; + } $command = $this->createCommand($db); $params = $command->params; $command->setSql($command->db->getQueryBuilder()->selectExists($command->getSql())); @@ -379,6 +404,10 @@ class Query extends Component implements QueryInterface */ protected function queryScalar($selectExpression, $db) { + if ($this->emulateExecution) { + return null; + } + $select = $this->select; $limit = $this->limit; $offset = $this->offset; @@ -544,7 +573,7 @@ class Query extends Component implements QueryInterface /** * Adds an additional WHERE condition to the existing one. - * The new condition and the existing one will be joined using the 'AND' operator. + * The new condition and the existing one will be joined using the `AND` operator. * @param string|array|Expression $condition the new WHERE condition. Please refer to [[where()]] * on how to specify this parameter. * @param array $params the parameters (name => value) to be bound to the query. @@ -556,6 +585,8 @@ class Query extends Component implements QueryInterface { if ($this->where === null) { $this->where = $condition; + } elseif (is_array($this->where) && isset($this->where[0]) && strcasecmp($this->where[0], 'and') === 0) { + $this->where[] = $condition; } else { $this->where = ['and', $this->where, $condition]; } @@ -565,7 +596,7 @@ class Query extends Component implements QueryInterface /** * Adds an additional WHERE condition to the existing one. - * The new condition and the existing one will be joined using the 'OR' operator. + * The new condition and the existing one will be joined using the `OR` operator. * @param string|array|Expression $condition the new WHERE condition. Please refer to [[where()]] * on how to specify this parameter. * @param array $params the parameters (name => value) to be bound to the query. @@ -590,7 +621,7 @@ class Query extends Component implements QueryInterface * It adds an additional WHERE condition for the given field and determines the comparison operator * based on the first few characters of the given value. * The condition is added in the same way as in [[andFilterWhere]] so [[isEmpty()|empty values]] are ignored. - * The new condition and the existing one will be joined using the 'AND' operator. + * The new condition and the existing one will be joined using the `AND` operator. * * The comparison operator is intelligently determined based on the first few characters in the given value. * In particular, it recognizes the following operators if they appear as the leading characters in the given value: @@ -803,7 +834,7 @@ class Query extends Component implements QueryInterface /** * Adds an additional HAVING condition to the existing one. - * The new condition and the existing one will be joined using the 'AND' operator. + * The new condition and the existing one will be joined using the `AND` operator. * @param string|array|Expression $condition the new HAVING condition. Please refer to [[where()]] * on how to specify this parameter. * @param array $params the parameters (name => value) to be bound to the query. @@ -824,7 +855,7 @@ class Query extends Component implements QueryInterface /** * Adds an additional HAVING condition to the existing one. - * The new condition and the existing one will be joined using the 'OR' operator. + * The new condition and the existing one will be joined using the `OR` operator. * @param string|array|Expression $condition the new HAVING condition. Please refer to [[where()]] * on how to specify this parameter. * @param array $params the parameters (name => value) to be bound to the query. @@ -844,6 +875,91 @@ class Query extends Component implements QueryInterface } /** + * Sets the HAVING part of the query but ignores [[isEmpty()|empty operands]]. + * + * This method is similar to [[having()]]. The main difference is that this method will + * remove [[isEmpty()|empty query operands]]. As a result, this method is best suited + * for building query conditions based on filter values entered by users. + * + * The following code shows the difference between this method and [[having()]]: + * + * ```php + * // HAVING `age`=:age + * $query->filterHaving(['name' => null, 'age' => 20]); + * // HAVING `age`=:age + * $query->having(['age' => 20]); + * // HAVING `name` IS NULL AND `age`=:age + * $query->having(['name' => null, 'age' => 20]); + * ``` + * + * Note that unlike [[having()]], you cannot pass binding parameters to this method. + * + * @param array $condition the conditions that should be put in the HAVING part. + * See [[having()]] on how to specify this parameter. + * @return $this the query object itself + * @see having() + * @see andFilterHaving() + * @see orFilterHaving() + * @since 2.0.11 + */ + public function filterHaving(array $condition) + { + $condition = $this->filterCondition($condition); + if ($condition !== []) { + $this->having($condition); + } + return $this; + } + + /** + * Adds an additional HAVING condition to the existing one but ignores [[isEmpty()|empty operands]]. + * The new condition and the existing one will be joined using the `AND` operator. + * + * This method is similar to [[andHaving()]]. The main difference is that this method will + * remove [[isEmpty()|empty query operands]]. As a result, this method is best suited + * for building query conditions based on filter values entered by users. + * + * @param array $condition the new HAVING condition. Please refer to [[having()]] + * on how to specify this parameter. + * @return $this the query object itself + * @see filterHaving() + * @see orFilterHaving() + * @since 2.0.11 + */ + public function andFilterHaving(array $condition) + { + $condition = $this->filterCondition($condition); + if ($condition !== []) { + $this->andHaving($condition); + } + return $this; + } + + /** + * Adds an additional HAVING condition to the existing one but ignores [[isEmpty()|empty operands]]. + * The new condition and the existing one will be joined using the `OR` operator. + * + * This method is similar to [[orHaving()]]. The main difference is that this method will + * remove [[isEmpty()|empty query operands]]. As a result, this method is best suited + * for building query conditions based on filter values entered by users. + * + * @param array $condition the new HAVING condition. Please refer to [[having()]] + * on how to specify this parameter. + * @return $this the query object itself + * @see filterHaving() + * @see andFilterHaving() + * @since 2.0.11 + */ + public function orFilterHaving(array $condition) + { + $condition = $this->filterCondition($condition); + if ($condition !== []) { + $this->orHaving($condition); + } + return $this; + } + + /** * Appends a SQL statement using UNION operator. * @param string|Query $sql the SQL statement to be appended using UNION * @param bool $all TRUE if using UNION ALL and FALSE if using UNION diff --git a/framework/db/QueryInterface.php b/framework/db/QueryInterface.php index 28d16c9..7503fa3 100644 --- a/framework/db/QueryInterface.php +++ b/framework/db/QueryInterface.php @@ -252,4 +252,16 @@ interface QueryInterface * @return $this the query object itself */ public function offset($offset); + + /** + * Sets whether to emulate query execution, preventing any interaction with data storage. + * After this mode is enabled, methods, returning query results like [[one()]], [[all()]], [[exists()]] + * and so on, will return empty or false values. + * You should use this method in case your program logic indicates query should not return any results, like + * in case you set false where condition like `0=1`. + * @param boolean $value whether to prevent query execution. + * @return $this the query object itself. + * @since 2.0.11 + */ + public function emulateExecution($value = true); } diff --git a/framework/db/QueryTrait.php b/framework/db/QueryTrait.php index 4a64903..67cd2bf 100644 --- a/framework/db/QueryTrait.php +++ b/framework/db/QueryTrait.php @@ -50,6 +50,12 @@ trait QueryTrait * row data. For more details, see [[indexBy()]]. This property is only used by [[QueryInterface::all()|all()]]. */ public $indexBy; + /** + * @var boolean whether to emulate the actual query execution, returning empty or false results. + * @see emulateExecution() + * @since 2.0.11 + */ + public $emulateExecution = false; /** @@ -388,4 +394,20 @@ trait QueryTrait $this->offset = $offset; return $this; } + + /** + * Sets whether to emulate query execution, preventing any interaction with data storage. + * After this mode is enabled, methods, returning query results like [[one()]], [[all()]], [[exists()]] + * and so on, will return empty or false values. + * You should use this method in case your program logic indicates query should not return any results, like + * in case you set false where condition like `0=1`. + * @param boolean $value whether to prevent query execution. + * @return $this the query object itself. + * @since 2.0.11 + */ + public function emulateExecution($value = true) + { + $this->emulateExecution = $value; + return $this; + } } diff --git a/framework/db/cubrid/ColumnSchemaBuilder.php b/framework/db/cubrid/ColumnSchemaBuilder.php index 5ca5745..39502ca 100644 --- a/framework/db/cubrid/ColumnSchemaBuilder.php +++ b/framework/db/cubrid/ColumnSchemaBuilder.php @@ -58,13 +58,13 @@ class ColumnSchemaBuilder extends AbstractColumnSchemaBuilder { switch ($this->getTypeCategory()) { case self::CATEGORY_PK: - $format = '{type}{check}{pos}{comment}{append}'; + $format = '{type}{check}{comment}{append}{pos}'; break; case self::CATEGORY_NUMERIC: - $format = '{type}{length}{unsigned}{notnull}{unique}{default}{check}{comment}{pos}{append}'; + $format = '{type}{length}{unsigned}{notnull}{unique}{default}{check}{comment}{append}{pos}'; break; default: - $format = '{type}{length}{notnull}{unique}{default}{check}{comment}{pos}{append}'; + $format = '{type}{length}{notnull}{unique}{default}{check}{comment}{append}{pos}'; } return $this->buildCompleteString($format); } diff --git a/framework/db/mysql/ColumnSchemaBuilder.php b/framework/db/mysql/ColumnSchemaBuilder.php index bdd6a0a..eca93e0 100644 --- a/framework/db/mysql/ColumnSchemaBuilder.php +++ b/framework/db/mysql/ColumnSchemaBuilder.php @@ -58,13 +58,13 @@ class ColumnSchemaBuilder extends AbstractColumnSchemaBuilder { switch ($this->getTypeCategory()) { case self::CATEGORY_PK: - $format = '{type}{length}{check}{comment}{pos}{append}'; + $format = '{type}{length}{check}{comment}{append}{pos}'; break; case self::CATEGORY_NUMERIC: - $format = '{type}{length}{unsigned}{notnull}{unique}{default}{check}{comment}{pos}{append}'; + $format = '{type}{length}{unsigned}{notnull}{unique}{default}{check}{comment}{append}{pos}'; break; default: - $format = '{type}{length}{notnull}{unique}{default}{check}{comment}{pos}{append}'; + $format = '{type}{length}{notnull}{unique}{default}{check}{comment}{append}{pos}'; } return $this->buildCompleteString($format); } diff --git a/framework/db/oci/ColumnSchemaBuilder.php b/framework/db/oci/ColumnSchemaBuilder.php index 6893d4c..8629de8 100644 --- a/framework/db/oci/ColumnSchemaBuilder.php +++ b/framework/db/oci/ColumnSchemaBuilder.php @@ -29,35 +29,17 @@ class ColumnSchemaBuilder extends AbstractColumnSchemaBuilder /** * @inheritdoc */ - protected function buildAfterString() - { - return $this->after !== null ? - ' AFTER ' . $this->db->quoteColumnName($this->after) : - ''; - } - - /** - * @inheritdoc - */ - protected function buildFirstString() - { - return $this->isFirst ? ' FIRST' : ''; - } - - /** - * @inheritdoc - */ public function __toString() { switch ($this->getTypeCategory()) { case self::CATEGORY_PK: - $format = '{type}{length}{check}{pos}{append}'; + $format = '{type}{length}{check}{append}'; break; case self::CATEGORY_NUMERIC: - $format = '{type}{length}{unsigned}{default}{notnull}{check}{pos}{append}'; + $format = '{type}{length}{unsigned}{default}{notnull}{check}{append}'; break; default: - $format = '{type}{length}{default}{notnull}{check}{pos}{append}'; + $format = '{type}{length}{default}{notnull}{check}{append}'; } return $this->buildCompleteString($format); } diff --git a/framework/db/oci/Schema.php b/framework/db/oci/Schema.php index 1f47207..4231ba5 100644 --- a/framework/db/oci/Schema.php +++ b/framework/db/oci/Schema.php @@ -121,17 +121,23 @@ class Schema extends \yii\db\Schema protected function findColumns($table) { $sql = <<db->createCommand($seq_name_sql, [':tableName' => $tableName])->queryScalar(); + $sequenceName = $this->db->createCommand($sequenceNameSql, [':tableName' => $tableName])->queryScalar(); return $sequenceName === false ? null : $sequenceName; } @@ -251,15 +259,23 @@ SQL; protected function findConstraints($table) { $sql = <<db->createCommand($sql, [ @@ -306,13 +322,12 @@ SQL; protected function findSchemaNames() { $sql = <<db->createCommand($sql)->queryColumn(); } @@ -324,20 +339,29 @@ SQL; { if ($schema === '') { $sql = <<db->createCommand($sql); } else { $sql = <<db->createCommand($sql, [':schema' => $schema]); } @@ -371,13 +395,16 @@ SQL; public function findUniqueIndexes($table) { $query = <<db->createCommand($query, [ diff --git a/framework/di/Container.php b/framework/di/Container.php index 3649d6f..5446126 100644 --- a/framework/di/Container.php +++ b/framework/di/Container.php @@ -566,4 +566,84 @@ class Container extends Component } return $args; } + + /** + * Registers class definitions within this container. + * + * @param array $definitions array of definitions. There are two allowed formats of array. + * The first format: + * - key: class name, interface name or alias name. The key will be passed to the [[set()]] method + * as a first argument `$class`. + * - value: the definition associated with `$class`. Possible values are described in + * [[set()]] documentation for the `$definition` parameter. Will be passed to the [[set()]] method + * as the second argument `$definition`. + * + * Example: + * ```php + * $container->setDefinitions([ + * 'yii\web\Request' => 'app\components\Request', + * 'yii\web\Response' => [ + * 'class' => 'app\components\Response', + * 'format' => 'json' + * ], + * 'foo\Bar' => function () { + * $qux = new Qux; + * $foo = new Foo($qux); + * return new Bar($foo); + * } + * ]); + * ``` + * + * The second format: + * - key: class name, interface name or alias name. The key will be passed to the [[set()]] method + * as a first argument `$class`. + * - value: array of two elements. The first element will be passed the [[set()]] method as the + * second argument `$definition`, the second one — as `$params`. + * + * Example: + * ```php + * $container->setDefinitions([ + * 'foo\Bar' => [ + * ['class' => 'app\Bar'], + * [Instance::of('baz')] + * ] + * ]); + * ``` + * + * @see set() to know more about possible values of definitions + * @since 2.0.11 + */ + public function setDefinitions(array $definitions) + { + foreach ($definitions as $class => $definition) { + if (count($definition) === 2 && array_values($definition) === $definition) { + $this->set($class, $definition[0], $definition[1]); + continue; + } + + $this->set($class, $definition); + } + } + + /** + * Registers class definitions as singletons within this container by calling [[setSingleton()]] + * + * @param array $singletons array of singleton definitions. See [[setDefinitions()]] + * for allowed formats of array. + * + * @see setDefinitions() for allowed formats of $singletons parameter + * @see setSingleton() to know more about possible values of definitions + * @since 2.0.11 + */ + public function setSingletons(array $singletons) + { + foreach ($singletons as $class => $definition) { + if (count($definition) === 2 && array_values($definition) === $definition) { + $this->setSingleton($class, $definition[0], $definition[1]); + continue; + } + + $this->setSingleton($class, $definition); + } + } } diff --git a/framework/di/ServiceLocator.php b/framework/di/ServiceLocator.php index 09485fa..0abf09d 100644 --- a/framework/di/ServiceLocator.php +++ b/framework/di/ServiceLocator.php @@ -84,7 +84,7 @@ class ServiceLocator extends Component */ public function __isset($name) { - if ($this->has($name, true)) { + if ($this->has($name)) { return true; } else { return parent::__isset($name); diff --git a/framework/filters/HostControl.php b/framework/filters/HostControl.php index d89129d..bbc9402 100644 --- a/framework/filters/HostControl.php +++ b/framework/filters/HostControl.php @@ -105,6 +105,14 @@ class HostControl extends ActionFilter * host name, creation of absolute URL links, caching page parts and so on. */ public $denyCallback; + /** + * @var string|null fallback host info (e.g. `http://www.yiiframework.com`) used when [[\yii\web\Request::$hostInfo|Request::$hostInfo]] is invalid. + * This value will replace [[\yii\web\Request::$hostInfo|Request::$hostInfo]] before [[$denyCallback]] is called to make sure that + * an invalid host will not be used for further processing. You can set it to `null` to leave [[\yii\web\Request::$hostInfo|Request::$hostInfo]] untouched. + * Default value is empty string (this will result creating relative URLs instead of absolute). + * @see \yii\web\Request::getHostInfo() + */ + public $fallbackHostInfo = ''; /** @@ -132,6 +140,11 @@ class HostControl extends ActionFilter } } + // replace invalid host info to prevent using it in further processing + if ($this->fallbackHostInfo !== null) { + Yii::$app->getRequest()->setHostInfo($this->fallbackHostInfo); + } + if ($this->denyCallback !== null) { call_user_func($this->denyCallback, $action); } else { @@ -147,18 +160,24 @@ class HostControl extends ActionFilter * You may override this method, creating your own deny access handler. While doing so, make sure you * avoid usage of the current requested host name, creation of absolute URL links, caching page parts and so on. * @param \yii\base\Action $action the action to be executed. + * @throws NotFoundHttpException */ protected function denyAccess($action) { + $exception = new NotFoundHttpException(Yii::t('yii', 'Page not found.')); + + // use regular error handling if $this->fallbackHostInfo was set + if (!empty(Yii::$app->getRequest()->hostName)) { + throw $exception; + } + $response = Yii::$app->getResponse(); $errorHandler = Yii::$app->getErrorHandler(); - $exception = new NotFoundHttpException(Yii::t('yii', 'Page not found.')); - $response->setStatusCode($exception->statusCode, $exception->getMessage()); $response->data = $errorHandler->renderFile($errorHandler->errorView, ['exception' => $exception]); $response->send(); Yii::$app->end(); } -} \ No newline at end of file +} diff --git a/framework/filters/PageCache.php b/framework/filters/PageCache.php index 9b9495e..8b02278 100644 --- a/framework/filters/PageCache.php +++ b/framework/filters/PageCache.php @@ -8,8 +8,8 @@ namespace yii\filters; use Yii; -use yii\base\ActionFilter; use yii\base\Action; +use yii\base\ActionFilter; use yii\caching\Cache; use yii\caching\Dependency; use yii\di\Instance; @@ -46,13 +46,14 @@ use yii\web\Response; * ``` * * @author Qiang Xue + * @author Sergey Makinen * @since 2.0 */ class PageCache extends ActionFilter { /** * @var bool whether the content being cached should be differentiated according to the route. - * A route consists of the requested controller ID and action ID. Defaults to true. + * A route consists of the requested controller ID and action ID. Defaults to `true`. */ public $varyByRoute = true; /** @@ -64,7 +65,7 @@ class PageCache extends ActionFilter public $cache = 'cache'; /** * @var int number of seconds that the data can remain valid in cache. - * Use 0 to indicate that the cached data will never expire. + * Use `0` to indicate that the cached data will never expire. */ public $duration = 60; /** @@ -123,6 +124,13 @@ class PageCache extends ActionFilter * @since 2.0.4 */ public $cacheHeaders = true; + /** + * @var array a list of placeholders for embedding dynamic contents. This property + * is used internally to implement the content caching feature. Do not modify it. + * @internal + * @since 2.0.11 + */ + public $dynamicPlaceholders; /** @@ -154,57 +162,70 @@ class PageCache extends ActionFilter $this->dependency = Yii::createObject($this->dependency); } - $properties = []; - foreach (['cache', 'duration', 'dependency', 'variations'] as $name) { - $properties[$name] = $this->$name; - } - $id = $this->varyByRoute ? $action->getUniqueId() : __CLASS__; $response = Yii::$app->getResponse(); - ob_start(); - ob_implicit_flush(false); - if ($this->view->beginCache($id, $properties)) { + $data = $this->cache->get($this->calculateCacheKey()); + if (!is_array($data) || !isset($data['cacheVersion']) || $data['cacheVersion'] !== 1) { + $this->view->cacheStack[] = $this; + ob_start(); + ob_implicit_flush(false); $response->on(Response::EVENT_AFTER_SEND, [$this, 'cacheResponse']); Yii::trace('Valid page content is not found in the cache.', __METHOD__); return true; } else { - $data = $this->cache->get($this->calculateCacheKey()); - if (is_array($data)) { - $this->restoreResponse($response, $data); - } - $response->content = ob_get_clean(); + $this->restoreResponse($response, $data); Yii::trace('Valid page content is found in the cache.', __METHOD__); return false; } } /** - * Restores response properties from the given data - * @param Response $response the response to be restored - * @param array $data the response property data + * This method is invoked right before the response caching is to be started. + * You may override this method to cancel caching by returning `false` or store an additional data + * in a cache entry by returning an array instead of `true`. + * @return bool|array whether to cache or not, return an array instead of `true` to store an additional data. + * @since 2.0.11 + */ + public function beforeCacheResponse() + { + return true; + } + + /** + * This method is invoked right after the response restoring is finished (but before the response is sent). + * You may override this method to do last-minute preparation before the response is sent. + * @param array|null $data an array of an additional data stored in a cache entry or `null`. + * @since 2.0.11 + */ + public function afterRestoreResponse($data) + { + } + + /** + * Restores response properties from the given data. + * @param Response $response the response to be restored. + * @param array $data the response property data. * @since 2.0.3 */ protected function restoreResponse($response, $data) { - if (isset($data['format'])) { - $response->format = $data['format']; - } - if (isset($data['version'])) { - $response->version = $data['version']; - } - if (isset($data['statusCode'])) { - $response->statusCode = $data['statusCode']; + foreach (['format', 'version', 'statusCode', 'statusText', 'content'] as $name) { + $response->{$name} = $data[$name]; } - if (isset($data['statusText'])) { - $response->statusText = $data['statusText']; - } - if (isset($data['headers']) && is_array($data['headers'])) { - $headers = $response->getHeaders()->toArray(); - $response->getHeaders()->fromArray(array_merge($data['headers'], $headers)); + foreach (['headers', 'cookies'] as $name) { + if (isset($data[$name]) && is_array($data[$name])) { + $response->{$name}->fromArray(array_merge($data[$name], $response->{$name}->toArray())); + } } - if (isset($data['cookies']) && is_array($data['cookies'])) { - $cookies = $response->getCookies()->toArray(); - $response->getCookies()->fromArray(array_merge($data['cookies'], $cookies)); + if (!empty($data['dynamicPlaceholders']) && is_array($data['dynamicPlaceholders'])) { + if (empty($this->view->cacheStack)) { + // outermost cache: replace placeholder with dynamic content + $response->content = $this->updateDynamicContent($response->content, $data['dynamicPlaceholders']); + } + foreach ($data['dynamicPlaceholders'] as $name => $statements) { + $this->view->addDynamicPlaceholder($name, $statements); + } } + $this->afterRestoreResponse(isset($data['cacheData']) ? $data['cacheData'] : null); } /** @@ -213,43 +234,83 @@ class PageCache extends ActionFilter */ public function cacheResponse() { - $this->view->endCache(); + array_pop($this->view->cacheStack); + $beforeCacheResponseResult = $this->beforeCacheResponse(); + if ($beforeCacheResponseResult === false) { + $content = ob_get_clean(); + if (empty($this->view->cacheStack) && !empty($this->dynamicPlaceholders)) { + $content = $this->updateDynamicContent($content, $this->dynamicPlaceholders); + } + echo $content; + return; + } + $response = Yii::$app->getResponse(); $data = [ - 'format' => $response->format, - 'version' => $response->version, - 'statusCode' => $response->statusCode, - 'statusText' => $response->statusText, + 'cacheVersion' => 1, + 'cacheData' => is_array($beforeCacheResponseResult) ? $beforeCacheResponseResult : null, + 'content' => ob_get_clean() ]; - if (!empty($this->cacheHeaders)) { - $headers = $response->getHeaders()->toArray(); - if (is_array($this->cacheHeaders)) { - $filtered = []; - foreach ($this->cacheHeaders as $name) { + if ($data['content'] === false || $data['content'] === '') { + return; + } + + $data['dynamicPlaceholders'] = $this->dynamicPlaceholders; + foreach (['format', 'version', 'statusCode', 'statusText'] as $name) { + $data[$name] = $response->{$name}; + } + $this->insertResponseCollectionIntoData($response, 'headers', $data); + $this->insertResponseCollectionIntoData($response, 'cookies', $data); + $this->cache->set($this->calculateCacheKey(), $data, $this->duration, $this->dependency); + if (empty($this->view->cacheStack) && !empty($this->dynamicPlaceholders)) { + $data['content'] = $this->updateDynamicContent($data['content'], $this->dynamicPlaceholders); + } + echo $data['content']; + } + + /** + * Inserts (or filters/ignores according to config) response headers/cookies into a cache data array. + * @param Response $response the response. + * @param string $collectionName currently it's `headers` or `cookies`. + * @param array $data the cache data. + */ + private function insertResponseCollectionIntoData(Response $response, $collectionName, array &$data) + { + $property = 'cache' . ucfirst($collectionName); + if ($this->{$property} === false) { + return; + } + + $all = $response->{$collectionName}->toArray(); + if (is_array($this->{$property})) { + $filtered = []; + foreach ($this->{$property} as $name) { + if ($collectionName === 'headers') { $name = strtolower($name); - if (isset($headers[$name])) { - $filtered[$name] = $headers[$name]; - } } - $headers = $filtered; - } - $data['headers'] = $headers; - } - if (!empty($this->cacheCookies)) { - $cookies = $response->getCookies()->toArray(); - if (is_array($this->cacheCookies)) { - $filtered = []; - foreach ($this->cacheCookies as $name) { - if (isset($cookies[$name])) { - $filtered[$name] = $cookies[$name]; - } + if (isset($all[$name])) { + $filtered[$name] = $all[$name]; } - $cookies = $filtered; } - $data['cookies'] = $cookies; + $all = $filtered; } - $this->cache->set($this->calculateCacheKey(), $data, $this->duration, $this->dependency); - echo ob_get_clean(); + $data[$collectionName] = $all; + } + + /** + * Replaces placeholders in content by results of evaluated dynamic statements. + * @param string $content content to be parsed. + * @param array $placeholders placeholders and their values. + * @return string final content. + * @since 2.0.11 + */ + protected function updateDynamicContent($content, $placeholders) + { + foreach ($placeholders as $name => $statements) { + $placeholders[$name] = $this->view->evaluateDynamicContent($statements); + } + + return strtr($content, $placeholders); } /** diff --git a/framework/grid/RadioButtonColumn.php b/framework/grid/RadioButtonColumn.php new file mode 100644 index 0000000..e49ac3a --- /dev/null +++ b/framework/grid/RadioButtonColumn.php @@ -0,0 +1,92 @@ + [ + * // ... + * [ + * 'class' => 'yii\grid\RadioButtonColumn', + * 'radioOptions' => function ($model) { + * return [ + * 'value' => $model['value'], + * 'checked' => $model['value'] == 2 + * ]; + * } + * ], + * ] + * ``` + * + * @author Kirk Hansen + * @since 2.0.11 + */ +class RadioButtonColumn extends Column +{ + /** + * @var string the name of the input radio button input fields. + */ + public $name = 'radioButtonSelection'; + /** + * @var array|\Closure the HTML attributes for the radio buttons. This can either be an array of + * attributes or an anonymous function ([[Closure]]) returning such an array. + * + * The signature of the function should be as follows: `function ($model, $key, $index, $column)` + * where `$model`, `$key`, and `$index` refer to the model, key and index of the row currently being rendered + * and `$column` is a reference to the [[RadioButtonColumn]] object. + * + * A function may be used to assign different attributes to different rows based on the data in that row. + * Specifically if you want to set a different value for the radio button you can use this option + * in the following way (in this example using the `name` attribute of the model): + * ```php + * 'radioOptions' => function ($model, $key, $index, $column) { + * return ['value' => $model->attribute]; + * } + * ``` + * @see \yii\helpers\Html::renderTagAttributes() for details on how attributes are being rendered. + */ + public $radioOptions = []; + + + /** + * @inheritdoc + * @throws \yii\base\InvalidConfigException if [[name]] is not set. + */ + public function init() + { + parent::init(); + if (empty($this->name)) { + throw new InvalidConfigException('The "name" property must be set.'); + } + } + + /** + * @inheritdoc + */ + protected function renderDataCellContent($model, $key, $index) + { + if ($this->radioOptions instanceof Closure) { + $options = call_user_func($this->radioOptions, $model, $key, $index, $this); + } else { + $options = $this->radioOptions; + if (!isset($options['value'])) { + $options['value'] = is_array($key) ? json_encode($key, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : $key; + } + } + $checked = isset($options['checked']) ? $options['checked'] : false; + return Html::radio($this->name, $checked, $options); + } +} diff --git a/framework/helpers/BaseFileHelper.php b/framework/helpers/BaseFileHelper.php index d4a3737..583655a 100644 --- a/framework/helpers/BaseFileHelper.php +++ b/framework/helpers/BaseFileHelper.php @@ -411,7 +411,7 @@ class BaseFileHelper if (static::filterPath($path, $options)) { if (is_file($path)) { $list[] = $path; - } elseif (!isset($options['recursive']) || $options['recursive']) { + } elseif (is_dir($path) && (!isset($options['recursive']) || $options['recursive'])) { $list = array_merge($list, static::findFiles($path, $options)); } } diff --git a/framework/helpers/BaseJson.php b/framework/helpers/BaseJson.php index faf6bc8..28b2af3 100644 --- a/framework/helpers/BaseJson.php +++ b/framework/helpers/BaseJson.php @@ -40,9 +40,14 @@ class BaseJson /** * Encodes the given value into a JSON string. + * * The method enhances `json_encode()` by supporting JavaScript expressions. * In particular, the method will not encode a JavaScript expression that is * represented in terms of a [[JsExpression]] object. + * + * Note that data encoded as JSON must be UTF-8 encoded according to the JSON specification. + * You must ensure strings passed to this method have proper encoding before passing them. + * * @param mixed $value the data to be encoded. * @param int $options the encoding options. For more details please refer to * . Default is `JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE`. @@ -65,10 +70,14 @@ class BaseJson /** * Encodes the given value into a JSON string HTML-escaping entities so it is safe to be embedded in HTML code. + * * The method enhances `json_encode()` by supporting JavaScript expressions. * In particular, the method will not encode a JavaScript expression that is * represented in terms of a [[JsExpression]] object. * + * Note that data encoded as JSON must be UTF-8 encoded according to the JSON specification. + * You must ensure strings passed to this method have proper encoding before passing them. + * * @param mixed $value the data to be encoded * @return string the encoding result * @since 2.0.4 diff --git a/framework/helpers/BaseUrl.php b/framework/helpers/BaseUrl.php index 69b2404..5623aec 100644 --- a/framework/helpers/BaseUrl.php +++ b/framework/helpers/BaseUrl.php @@ -223,7 +223,7 @@ class BaseUrl return $url; } - if (($pos = strpos($url, ':')) === false || !ctype_alpha(substr($url, 0, $pos))) { + if (static::isRelative($url)) { // turn relative URL into absolute $url = static::getUrlManager()->getHostInfo() . '/' . ltrim($url, '/'); } diff --git a/framework/messages/bg/yii.php b/framework/messages/bg/yii.php index 9206c9b..be63e9c 100644 --- a/framework/messages/bg/yii.php +++ b/framework/messages/bg/yii.php @@ -71,7 +71,7 @@ return array ( '{attribute} is not a valid email address.' => 'Полето "{attribute}" съдържа невалиден email адрес.', '{attribute} must be "{requiredValue}".' => 'Полето "{attribute}" трябва да съдържа "{requiredValue}".', '{attribute} must be a number.' => 'Полето "{attribute}" съдържа невалиден номер.', - '{attribute} must be a string.' => 'Полето "{attribute}" трябва съдържа текст.', + '{attribute} must be a string.' => 'Полето "{attribute}" трябва да съдържа текст.', '{attribute} must be an integer.' => 'Полето "{attribute}" трябва да съдържа цяло число.', '{attribute} must be either "{true}" or "{false}".' => 'Полето "{attribute}" трябва да бъде "{true}" или "{false}".', '{attribute} must be greater than "{compareValue}".' => 'Полето "{attribute}" трябва да е по-голямо от "{compareValue}".', diff --git a/framework/messages/pl/yii.php b/framework/messages/pl/yii.php index 9ee4afa..62b3f47 100644 --- a/framework/messages/pl/yii.php +++ b/framework/messages/pl/yii.php @@ -89,9 +89,9 @@ return [ '{attribute} must not be an IPv4 address.' => '{attribute} nie może być adresem IPv4.', '{attribute} must not be an IPv6 address.' => '{attribute} nie może być adresem IPv6.', '{attribute} must not be equal to "{compareValueOrAttribute}".' => '{attribute} musi mieć wartość różną od "{compareValueOrAttribute}".', - '{attribute} should contain at least {min, number} {min, plural, one{character} other{characters}}.' => '{attribute} powinien zawierać co najmniej {min, number} {min, plural, one{znak} few{znaki} many{znaków} other{znaku}}.', - '{attribute} should contain at most {max, number} {max, plural, one{character} other{characters}}.' => '{attribute} powinien zawierać nie więcej niż {max, number} {max, plural, one{znak} few{znaki} many{znaków} other{znaku}}.', - '{attribute} should contain {length, number} {length, plural, one{character} other{characters}}.' => '{attribute} powinien zawierać dokładnie {length, number} {length, plural, one{znak} few{znaki} many{znaków} other{znaku}}.', + '{attribute} should contain at least {min, number} {min, plural, one{character} other{characters}}.' => '{attribute} musi zawierać co najmniej {min, number} {min, plural, one{znak} few{znaki} many{znaków} other{znaku}}.', + '{attribute} should contain at most {max, number} {max, plural, one{character} other{characters}}.' => '{attribute} musi zawierać nie więcej niż {max, number} {max, plural, one{znak} few{znaki} many{znaków} other{znaku}}.', + '{attribute} should contain {length, number} {length, plural, one{character} other{characters}}.' => '{attribute} musi zawierać dokładnie {length, number} {length, plural, one{znak} few{znaki} many{znaków} other{znaku}}.', '{delta, plural, =1{1 day} other{# days}}' => '{delta, plural, =1{1 dzień} other{# dni} other{# dnia}}', '{delta, plural, =1{1 hour} other{# hours}}' => '{delta, plural, =1{1 godzina} few{# godziny} many{# godzin} other{# godziny}}', '{delta, plural, =1{1 minute} other{# minutes}}' => '{delta, plural, =1{1 minuta} few{# minuty} many{# minut} other{# minuty}}', diff --git a/framework/rbac/BaseManager.php b/framework/rbac/BaseManager.php index 98460ff..37b54dc 100644 --- a/framework/rbac/BaseManager.php +++ b/framework/rbac/BaseManager.php @@ -222,4 +222,16 @@ abstract class BaseManager extends Component implements ManagerInterface throw new InvalidConfigException("Rule not found: {$item->ruleName}"); } } + + /** + * Checks whether array of $assignments is empty and [[defaultRoles]] property is empty as well + * + * @param Assignment[] $assignments array of user's assignments + * @return bool whether array of $assignments is empty and [[defaultRoles]] property is empty as well + * @since 2.0.11 + */ + protected function hasNoAssignments(array $assignments) + { + return empty($assignments) && empty($this->defaultRoles); + } } diff --git a/framework/rbac/DbManager.php b/framework/rbac/DbManager.php index b38d5fe..1935bdf 100644 --- a/framework/rbac/DbManager.php +++ b/framework/rbac/DbManager.php @@ -121,6 +121,11 @@ class DbManager extends BaseManager public function checkAccess($userId, $permissionName, $params = []) { $assignments = $this->getAssignments($userId); + + if ($this->hasNoAssignments($assignments)) { + return false; + } + $this->loadFromCache(); if ($this->items !== null) { return $this->checkAccessFromCache($userId, $permissionName, $params, $assignments); diff --git a/framework/rbac/PhpManager.php b/framework/rbac/PhpManager.php index 432653d..71cce73 100644 --- a/framework/rbac/PhpManager.php +++ b/framework/rbac/PhpManager.php @@ -99,6 +99,11 @@ class PhpManager extends BaseManager public function checkAccess($userId, $permissionName, $params = []) { $assignments = $this->getAssignments($userId); + + if ($this->hasNoAssignments($assignments)) { + return false; + } + return $this->checkAccessRecursive($userId, $permissionName, $params, $assignments); } diff --git a/framework/rest/UrlRule.php b/framework/rest/UrlRule.php index d83b062..a4464c8 100644 --- a/framework/rest/UrlRule.php +++ b/framework/rest/UrlRule.php @@ -203,7 +203,7 @@ class UrlRule extends CompositeUrlRule $config['verb'] = $verbs; $config['pattern'] = rtrim($prefix . '/' . strtr($pattern, $this->tokens), '/'); $config['route'] = $action; - if (!in_array('GET', $verbs)) { + if (!empty($verbs) && !in_array('GET', $verbs)) { $config['mode'] = \yii\web\UrlRule::PARSING_ONLY; } $config['suffix'] = $this->suffix; diff --git a/framework/validators/BooleanValidator.php b/framework/validators/BooleanValidator.php index 52c6dfc..cff9fc9 100644 --- a/framework/validators/BooleanValidator.php +++ b/framework/validators/BooleanValidator.php @@ -70,6 +70,17 @@ class BooleanValidator extends Validator */ public function clientValidateAttribute($model, $attribute, $view) { + ValidationAsset::register($view); + $options = $this->getClientOptions($model, $attribute); + + return 'yii.validation.boolean(value, messages, ' . json_encode($options, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ');'; + } + + /** + * @inheritdoc + */ + public function getClientOptions($model, $attribute) + { $options = [ 'trueValue' => $this->trueValue, 'falseValue' => $this->falseValue, @@ -86,8 +97,6 @@ class BooleanValidator extends Validator $options['strict'] = 1; } - ValidationAsset::register($view); - - return 'yii.validation.boolean(value, messages, ' . json_encode($options, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ');'; + return $options; } } diff --git a/framework/validators/CompareValidator.php b/framework/validators/CompareValidator.php index b2ee65c..c9284f0 100644 --- a/framework/validators/CompareValidator.php +++ b/framework/validators/CompareValidator.php @@ -225,6 +225,17 @@ class CompareValidator extends Validator */ public function clientValidateAttribute($model, $attribute, $view) { + ValidationAsset::register($view); + $options = $this->getClientOptions($model, $attribute); + + return 'yii.validation.compare(value, messages, ' . json_encode($options, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ');'; + } + + /** + * @inheritdoc + */ + public function getClientOptions($model, $attribute) + { $options = [ 'operator' => $this->operator, 'type' => $this->type, @@ -251,8 +262,6 @@ class CompareValidator extends Validator 'compareValueOrAttribute' => $compareValueOrAttribute, ], Yii::$app->language); - ValidationAsset::register($view); - - return 'yii.validation.compare(value, messages, ' . json_encode($options, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ');'; + return $options; } } diff --git a/framework/validators/EmailValidator.php b/framework/validators/EmailValidator.php index 2bf5737..9262b56 100644 --- a/framework/validators/EmailValidator.php +++ b/framework/validators/EmailValidator.php @@ -109,6 +109,20 @@ class EmailValidator extends Validator */ public function clientValidateAttribute($model, $attribute, $view) { + ValidationAsset::register($view); + if ($this->enableIDN) { + PunycodeAsset::register($view); + } + $options = $this->getClientOptions($model, $attribute); + + return 'yii.validation.email(value, messages, ' . Json::htmlEncode($options) . ');'; + } + + /** + * @inheritdoc + */ + public function getClientOptions($model, $attribute) + { $options = [ 'pattern' => new JsExpression($this->pattern), 'fullPattern' => new JsExpression($this->fullPattern), @@ -122,11 +136,6 @@ class EmailValidator extends Validator $options['skipOnEmpty'] = 1; } - ValidationAsset::register($view); - if ($this->enableIDN) { - PunycodeAsset::register($view); - } - - return 'yii.validation.email(value, messages, ' . Json::htmlEncode($options) . ');'; + return $options; } } diff --git a/framework/validators/FileValidator.php b/framework/validators/FileValidator.php index 5156243..64774b3 100644 --- a/framework/validators/FileValidator.php +++ b/framework/validators/FileValidator.php @@ -384,12 +384,9 @@ class FileValidator extends Validator } /** - * Returns the client-side validation options. - * @param \yii\base\Model $model the model being validated - * @param string $attribute the attribute name being validated - * @return array the client-side validation options + * @inheritdoc */ - protected function getClientOptions($model, $attribute) + public function getClientOptions($model, $attribute) { $label = $model->getAttributeLabel($attribute); diff --git a/framework/validators/FilterValidator.php b/framework/validators/FilterValidator.php index 5b01102..cedf241 100644 --- a/framework/validators/FilterValidator.php +++ b/framework/validators/FilterValidator.php @@ -88,13 +88,22 @@ class FilterValidator extends Validator return null; } + ValidationAsset::register($view); + $options = $this->getClientOptions($model, $attribute); + + return 'value = yii.validation.trim($form, attribute, ' . json_encode($options, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ');'; + } + + /** + * @inheritdoc + */ + public function getClientOptions($model, $attribute) + { $options = []; if ($this->skipOnEmpty) { $options['skipOnEmpty'] = 1; } - ValidationAsset::register($view); - - return 'value = yii.validation.trim($form, attribute, ' . json_encode($options, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ');'; + return $options; } } diff --git a/framework/validators/ImageValidator.php b/framework/validators/ImageValidator.php index 769cad9..6d72812 100644 --- a/framework/validators/ImageValidator.php +++ b/framework/validators/ImageValidator.php @@ -172,7 +172,7 @@ class ImageValidator extends FileValidator /** * @inheritdoc */ - protected function getClientOptions($model, $attribute) + public function getClientOptions($model, $attribute) { $options = parent::getClientOptions($model, $attribute); diff --git a/framework/validators/InlineValidator.php b/framework/validators/InlineValidator.php index 8423e8b..7392dc7 100644 --- a/framework/validators/InlineValidator.php +++ b/framework/validators/InlineValidator.php @@ -13,11 +13,12 @@ namespace yii\validators; * The validation method must have the following signature: * * ```php - * function foo($attribute, $params) + * function foo($attribute, $params, $validator) * ``` * - * where `$attribute` refers to the name of the attribute being validated, while `$params` - * is an array representing the additional parameters supplied in the validation rule. + * where `$attribute` refers to the name of the attribute being validated, while `$params` is an array representing the + * additional parameters supplied in the validation rule. Parameter `$validator` refers to the related + * [[InlineValidator]] object and is available since version 2.0.11. * * @author Qiang Xue * @since 2.0 @@ -26,13 +27,15 @@ class InlineValidator extends Validator { /** * @var string|\Closure an anonymous function or the name of a model class method that will be - * called to perform the actual validation. The signature of the method should be like the following, - * where `$attribute` is the name of the attribute to be validated, and `$params` contains the value - * of [[params]] that you specify when declaring the inline validation rule: + * called to perform the actual validation. The signature of the method should be like the following: * * ```php - * function foo($attribute, $params) + * function foo($attribute, $params, $validator) * ``` + * + * - `$attribute` is the name of the attribute to be validated; + * - `$params` contains the value of [[params]] that you specify when declaring the inline validation rule; + * - `$validator` is a reference to related [[InlineValidator]] object. */ public $method; /** @@ -44,7 +47,7 @@ class InlineValidator extends Validator * The signature of the method should be like the following: * * ```php - * function foo($attribute, $params) + * function foo($attribute, $params, $validator) * { * return "javascript"; * } @@ -66,7 +69,7 @@ class InlineValidator extends Validator if (is_string($method)) { $method = [$model, $method]; } - call_user_func($method, $attribute, $this->params); + call_user_func($method, $attribute, $this->params, $this); } /** @@ -80,7 +83,7 @@ class InlineValidator extends Validator $method = [$model, $method]; } - return call_user_func($method, $attribute, $this->params); + return call_user_func($method, $attribute, $this->params, $this); } else { return null; } diff --git a/framework/validators/IpValidator.php b/framework/validators/IpValidator.php index 3e9b97d..55f7620 100644 --- a/framework/validators/IpValidator.php +++ b/framework/validators/IpValidator.php @@ -369,12 +369,12 @@ class IpValidator extends Validator $cidr = static::IPV6_ADDRESS_LENGTH; } - if (!$this->ipv6) { - return [$this->ipv6NotAllowed, []]; - } if (!$this->validateIPv6($ip)) { return [$this->message, []]; } + if (!$this->ipv6) { + return [$this->ipv6NotAllowed, []]; + } if ($this->expandIPv6) { $ip = $this->expandIPv6($ip); @@ -388,13 +388,12 @@ class IpValidator extends Validator $isCidrDefault = true; $cidr = static::IPV4_ADDRESS_LENGTH; } - - if (!$this->ipv4) { - return [$this->ipv4NotAllowed, []]; - } if (!$this->validateIPv4($ip)) { return [$this->message, []]; } + if (!$this->ipv4) { + return [$this->ipv4NotAllowed, []]; + } } if (!$this->isAllowed($ip, $cidr)) { @@ -588,6 +587,17 @@ class IpValidator extends Validator */ public function clientValidateAttribute($model, $attribute, $view) { + ValidationAsset::register($view); + $options = $this->getClientOptions($model, $attribute); + + return 'yii.validation.ip(value, messages, ' . Json::htmlEncode($options) . ');'; + } + + /** + * @inheritdoc + */ + public function getClientOptions($model, $attribute) + { $messages = [ 'ipv6NotAllowed' => $this->ipv6NotAllowed, 'ipv4NotAllowed' => $this->ipv4NotAllowed, @@ -615,8 +625,6 @@ class IpValidator extends Validator $options['skipOnEmpty'] = 1; } - ValidationAsset::register($view); - - return 'yii.validation.ip(value, messages, ' . Json::htmlEncode($options) . ');'; + return $options; } } diff --git a/framework/validators/NumberValidator.php b/framework/validators/NumberValidator.php index 3c45134..261b192 100644 --- a/framework/validators/NumberValidator.php +++ b/framework/validators/NumberValidator.php @@ -142,6 +142,17 @@ class NumberValidator extends Validator */ public function clientValidateAttribute($model, $attribute, $view) { + ValidationAsset::register($view); + $options = $this->getClientOptions($model, $attribute); + + return 'yii.validation.number(value, messages, ' . Json::htmlEncode($options) . ');'; + } + + /** + * @inheritdoc + */ + public function getClientOptions($model, $attribute) + { $label = $model->getAttributeLabel($attribute); $options = [ @@ -173,8 +184,6 @@ class NumberValidator extends Validator $options['skipOnEmpty'] = 1; } - ValidationAsset::register($view); - - return 'yii.validation.number(value, messages, ' . Json::htmlEncode($options) . ');'; + return $options; } } diff --git a/framework/validators/RangeValidator.php b/framework/validators/RangeValidator.php index 4e1c89a..63be6ea 100644 --- a/framework/validators/RangeValidator.php +++ b/framework/validators/RangeValidator.php @@ -108,6 +108,17 @@ class RangeValidator extends Validator $this->range = call_user_func($this->range, $model, $attribute); } + ValidationAsset::register($view); + $options = $this->getClientOptions($model, $attribute); + + return 'yii.validation.range(value, messages, ' . json_encode($options, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ');'; + } + + /** + * @inheritdoc + */ + public function getClientOptions($model, $attribute) + { $range = []; foreach ($this->range as $value) { $range[] = (string) $value; @@ -126,8 +137,6 @@ class RangeValidator extends Validator $options['allowArray'] = 1; } - ValidationAsset::register($view); - - return 'yii.validation.range(value, messages, ' . json_encode($options, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ');'; + return $options; } } diff --git a/framework/validators/RegularExpressionValidator.php b/framework/validators/RegularExpressionValidator.php index 2180519..60130e2 100644 --- a/framework/validators/RegularExpressionValidator.php +++ b/framework/validators/RegularExpressionValidator.php @@ -65,6 +65,17 @@ class RegularExpressionValidator extends Validator */ public function clientValidateAttribute($model, $attribute, $view) { + ValidationAsset::register($view); + $options = $this->getClientOptions($model, $attribute); + + return 'yii.validation.regularExpression(value, messages, ' . Json::htmlEncode($options) . ');'; + } + + /** + * @inheritdoc + */ + public function getClientOptions($model, $attribute) + { $pattern = Html::escapeJsRegularExpression($this->pattern); $options = [ @@ -78,8 +89,6 @@ class RegularExpressionValidator extends Validator $options['skipOnEmpty'] = 1; } - ValidationAsset::register($view); - - return 'yii.validation.regularExpression(value, messages, ' . Json::htmlEncode($options) . ');'; + return $options; } } diff --git a/framework/validators/RequiredValidator.php b/framework/validators/RequiredValidator.php index dd99b99..21966c5 100644 --- a/framework/validators/RequiredValidator.php +++ b/framework/validators/RequiredValidator.php @@ -88,6 +88,17 @@ class RequiredValidator extends Validator */ public function clientValidateAttribute($model, $attribute, $view) { + ValidationAsset::register($view); + $options = $this->getClientOptions($model, $attribute); + + return 'yii.validation.required(value, messages, ' . json_encode($options, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ');'; + } + + /** + * @inheritdoc + */ + public function getClientOptions($model, $attribute) + { $options = []; if ($this->requiredValue !== null) { $options['message'] = Yii::$app->getI18n()->format($this->message, [ @@ -105,8 +116,6 @@ class RequiredValidator extends Validator 'attribute' => $model->getAttributeLabel($attribute), ], Yii::$app->language); - ValidationAsset::register($view); - - return 'yii.validation.required(value, messages, ' . json_encode($options, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ');'; + return $options; } } diff --git a/framework/validators/StringValidator.php b/framework/validators/StringValidator.php index 3c93b57..b41ae55 100644 --- a/framework/validators/StringValidator.php +++ b/framework/validators/StringValidator.php @@ -153,6 +153,14 @@ class StringValidator extends Validator */ public function clientValidateAttribute($model, $attribute, $view) { + ValidationAsset::register($view); + $options = $this->getClientOptions($model, $attribute); + + return 'yii.validation.string(value, messages, ' . json_encode($options, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ');'; + } + + public function getClientOptions($model, $attribute) + { $label = $model->getAttributeLabel($attribute); $options = [ @@ -186,8 +194,6 @@ class StringValidator extends Validator $options['skipOnEmpty'] = 1; } - ValidationAsset::register($view); - - return 'yii.validation.string(value, messages, ' . json_encode($options, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ');'; + return $options; } } diff --git a/framework/validators/UniqueValidator.php b/framework/validators/UniqueValidator.php index 5fd5be1..03c29c0 100644 --- a/framework/validators/UniqueValidator.php +++ b/framework/validators/UniqueValidator.php @@ -8,7 +8,11 @@ namespace yii\validators; use Yii; +use yii\base\Model; +use yii\db\ActiveQuery; +use yii\db\ActiveQueryInterface; use yii\db\ActiveRecordInterface; +use yii\db\Query; use yii\helpers\Inflector; /** @@ -44,7 +48,7 @@ class UniqueValidator extends Validator */ public $targetClass; /** - * @var string|array the name of the ActiveRecord attribute that should be used to + * @var string|array the name of the [[\yii\db\ActiveRecord|ActiveRecord]] attribute that should be used to * validate the uniqueness of the current attribute value. If not set, it will use the name * of the attribute currently being validated. You may use an array to validate the uniqueness * of multiple columns at the same time. The array values are the attributes that will be @@ -111,32 +115,38 @@ class UniqueValidator extends Validator /* @var $targetClass ActiveRecordInterface */ $targetClass = $this->targetClass === null ? get_class($model) : $this->targetClass; $targetAttribute = $this->targetAttribute === null ? $attribute : $this->targetAttribute; + $conditions = $this->prepareConditions($targetAttribute, $model, $attribute); - if (is_array($targetAttribute)) { - $params = []; - foreach ($targetAttribute as $k => $v) { - $params[$v] = is_int($k) ? $model->$v : $model->$k; - } - } else { - $params = [$targetAttribute => $model->$attribute]; - } - - foreach ($params as $value) { + foreach ($conditions as $value) { if (is_array($value)) { $this->addError($model, $attribute, Yii::t('yii', '{attribute} is invalid.')); - return; } } - $query = $targetClass::find(); - $query->andWhere($params); - - if ($this->filter instanceof \Closure) { - call_user_func($this->filter, $query); - } elseif ($this->filter !== null) { - $query->andWhere($this->filter); + if ($this->modelExists($targetClass, $conditions, $model)) { + if (count($targetAttribute) > 1) { + $this->addComboNotUniqueError($model, $attribute); + } else { + $this->addError($model, $attribute, $this->message); + } } + } + + /** + * Checks whether the $model exists in the database. + * + * @param string $targetClass the name of the ActiveRecord class that should be used to validate the uniqueness + * of the current attribute value. + * @param array $conditions conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format. + * @param Model $model the data model to be validated + * + * @return bool whether the model already exists + */ + private function modelExists($targetClass, $conditions, $model) + { + /** @var ActiveRecordInterface $targetClass $query */ + $query = $this->prepareQuery($targetClass, $conditions); if (!$model instanceof ActiveRecordInterface || $model->getIsNewRecord() || $model->className() !== $targetClass::className()) { // if current $model isn't in the database yet then it's OK just to call exists() @@ -144,11 +154,11 @@ class UniqueValidator extends Validator $exists = $query->exists(); } else { // if current $model is in the database already we can't use exists() - /* @var $models ActiveRecordInterface[] */ + /** @var $models ActiveRecordInterface[] */ $models = $query->select($targetClass::primaryKey())->limit(2)->all(); $n = count($models); if ($n === 1) { - $keys = array_keys($params); + $keys = array_keys($conditions); $pks = $targetClass::primaryKey(); sort($keys); sort($pks); @@ -164,13 +174,59 @@ class UniqueValidator extends Validator } } - if ($exists) { - if (count($targetAttribute) > 1) { - $this->addComboNotUniqueError($model, $attribute); - } else { - $this->addError($model, $attribute, $this->message); + return $exists; + } + + /** + * Prepares a query by applying filtering conditions defined in $conditions method property + * and [[filter]] class property. + * + * @param ActiveRecordInterface $targetClass the name of the ActiveRecord class that should be used to validate + * the uniqueness of the current attribute value. + * @param array $conditions conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format + * + * @return ActiveQueryInterface|ActiveQuery + */ + private function prepareQuery($targetClass, $conditions) + { + $query = $targetClass::find(); + $query->andWhere($conditions); + + if ($this->filter instanceof \Closure) { + call_user_func($this->filter, $query); + } elseif ($this->filter !== null) { + $query->andWhere($this->filter); + } + + return $query; + } + + /** + * Processes attributes' relations described in $targetAttribute parameter into conditions, compatible with + * [[\yii\db\Query::where()|Query::where()]] key-value format. + * + * @param string|array $targetAttribute the name of the [[\yii\db\ActiveRecord|ActiveRecord]] attribute that + * should be used to validate the uniqueness of the current attribute value. You may use an array to validate + * the uniqueness of multiple columns at the same time. The array values are the attributes that will be + * used to validate the uniqueness, while the array keys are the attributes whose values are to be validated. + * If the key and the value are the same, you can just specify the value. + * @param Model $model the data model to be validated + * @param string $attribute the name of the attribute to be validated in the $model + + * @return array conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format. + */ + private function prepareConditions($targetAttribute, $model, $attribute) + { + if (is_array($targetAttribute)) { + $conditions = []; + foreach ($targetAttribute as $k => $v) { + $conditions[$v] = is_int($k) ? $model->$v : $model->$k; } + } else { + $conditions = [$targetAttribute => $model->$attribute]; } + + return $conditions; } /** diff --git a/framework/validators/UrlValidator.php b/framework/validators/UrlValidator.php index 413736d..8f6ef5b 100644 --- a/framework/validators/UrlValidator.php +++ b/framework/validators/UrlValidator.php @@ -113,6 +113,20 @@ class UrlValidator extends Validator */ public function clientValidateAttribute($model, $attribute, $view) { + ValidationAsset::register($view); + if ($this->enableIDN) { + PunycodeAsset::register($view); + } + $options = $this->getClientOptions($model, $attribute); + + return 'yii.validation.url(value, messages, ' . Json::htmlEncode($options) . ');'; + } + + /** + * @inheritdoc + */ + public function getClientOptions($model, $attribute) + { if (strpos($this->pattern, '{schemes}') !== false) { $pattern = str_replace('{schemes}', '(' . implode('|', $this->validSchemes) . ')', $this->pattern); } else { @@ -133,11 +147,6 @@ class UrlValidator extends Validator $options['defaultScheme'] = $this->defaultScheme; } - ValidationAsset::register($view); - if ($this->enableIDN) { - PunycodeAsset::register($view); - } - - return 'yii.validation.url(value, messages, ' . Json::htmlEncode($options) . ');'; + return $options; } } diff --git a/framework/validators/Validator.php b/framework/validators/Validator.php index c162547..e04d6f4 100644 --- a/framework/validators/Validator.php +++ b/framework/validators/Validator.php @@ -328,6 +328,8 @@ class Validator extends Component /** * Returns the JavaScript needed for performing client-side validation. * + * Calls [[getClientOptions()]] to generate options array for client-side validation. + * * You may override this method to return the JavaScript validation code if * the validator can support client-side validation. * @@ -353,6 +355,7 @@ class Validator extends Component * containing a model form with this validator applied. * @return string the client-side validation script. Null if the validator does not support * client-side validation. + * @see getClientOptions() * @see \yii\widgets\ActiveForm::enableClientValidation */ public function clientValidateAttribute($model, $attribute, $view) @@ -361,6 +364,20 @@ class Validator extends Component } /** + * Returns the client-side validation options. + * This method is usually called from [[clientValidateAttribute()]]. You may override this method to modify options + * that will be passed to the client-side validation. + * @param \yii\base\Model $model the model being validated + * @param string $attribute the attribute name being validated + * @return array the client-side validation options + * @since 2.0.11 + */ + public function getClientOptions($model, $attribute) + { + return []; + } + + /** * Returns a value indicating whether the validator is active for the given scenario and attribute. * * A validator is active if diff --git a/framework/web/RangeNotSatisfiableHttpException.php b/framework/web/RangeNotSatisfiableHttpException.php new file mode 100644 index 0000000..c1ccbca --- /dev/null +++ b/framework/web/RangeNotSatisfiableHttpException.php @@ -0,0 +1,35 @@ + + * + * @since 2.0.11 + */ +class RangeNotSatisfiableHttpException extends HttpException +{ + /** + * Constructor. + * @param string $message error message + * @param integer $code error code + * @param \Exception $previous The previous exception used for the exception chaining. + */ + public function __construct($message = null, $code = 0, \Exception $previous = null) + { + parent::__construct(416, $message, $code, $previous); + } +} diff --git a/framework/web/Response.php b/framework/web/Response.php index 0d6b784..2ec4d6b 100644 --- a/framework/web/Response.php +++ b/framework/web/Response.php @@ -482,7 +482,7 @@ class Response extends \yii\base\Response * meaning a download dialog will pop up. * * @return $this the response object itself - * @throws HttpException if the requested range is not satisfiable + * @throws RangeNotSatisfiableHttpException if the requested range is not satisfiable * @see sendFile() for an example implementation. */ public function sendContentAsFile($content, $attachmentName, $options = []) @@ -494,7 +494,7 @@ class Response extends \yii\base\Response if ($range === false) { $headers->set('Content-Range', "bytes */$contentLength"); - throw new HttpException(416, 'Requested range not satisfiable'); + throw new RangeNotSatisfiableHttpException(); } list($begin, $end) = $range; @@ -533,7 +533,7 @@ class Response extends \yii\base\Response * This option is available since version 2.0.4. * * @return $this the response object itself - * @throws HttpException if the requested range cannot be satisfied. + * @throws RangeNotSatisfiableHttpException if the requested range is not satisfiable * @see sendFile() for an example implementation. */ public function sendStreamAsFile($handle, $attachmentName, $options = []) @@ -549,7 +549,7 @@ class Response extends \yii\base\Response $range = $this->getHttpRange($fileSize); if ($range === false) { $headers->set('Content-Range', "bytes */$fileSize"); - throw new HttpException(416, 'Requested range not satisfiable'); + throw new RangeNotSatisfiableHttpException(); } list($begin, $end) = $range; diff --git a/framework/widgets/ActiveField.php b/framework/widgets/ActiveField.php index eb946f4..2aa2d29 100644 --- a/framework/widgets/ActiveField.php +++ b/framework/widgets/ActiveField.php @@ -147,6 +147,11 @@ class ActiveField extends Component * it is maintained by various methods of this class. */ public $parts = []; + /** + * @var bool adds aria HTML attributes `aria-required` and `aria-invalid` for inputs + * @since 2.0.11 + */ + public $addAriaAttributes = true; /** * @var string this property holds a custom input id if it was set using [[inputOptions]] or in one of the @@ -158,7 +163,6 @@ class ActiveField extends Component */ private $_skipLabelFor = false; - /** * PHP magic method that returns the string representation of this object. * @return string the string representation of this object. @@ -358,6 +362,7 @@ class ActiveField extends Component public function input($type, $options = []) { $options = array_merge($this->inputOptions, $options); + $this->addAriaAttributes($options); $this->adjustLabelFor($options); $this->parts['{input}'] = Html::activeInput($type, $this->model, $this->attribute, $options); @@ -384,6 +389,7 @@ class ActiveField extends Component public function textInput($options = []) { $options = array_merge($this->inputOptions, $options); + $this->addAriaAttributes($options); $this->adjustLabelFor($options); $this->parts['{input}'] = Html::activeTextInput($this->model, $this->attribute, $options); @@ -429,6 +435,7 @@ class ActiveField extends Component public function passwordInput($options = []) { $options = array_merge($this->inputOptions, $options); + $this->addAriaAttributes($options); $this->adjustLabelFor($options); $this->parts['{input}'] = Html::activePasswordInput($this->model, $this->attribute, $options); @@ -456,6 +463,7 @@ class ActiveField extends Component if (!isset($this->form->options['enctype'])) { $this->form->options['enctype'] = 'multipart/form-data'; } + $this->addAriaAttributes($options); $this->adjustLabelFor($options); $this->parts['{input}'] = Html::activeFileInput($this->model, $this->attribute, $options); @@ -475,6 +483,7 @@ class ActiveField extends Component public function textarea($options = []) { $options = array_merge($this->inputOptions, $options); + $this->addAriaAttributes($options); $this->adjustLabelFor($options); $this->parts['{input}'] = Html::activeTextarea($this->model, $this->attribute, $options); @@ -522,6 +531,7 @@ class ActiveField extends Component $options['label'] = null; $this->parts['{input}'] = Html::activeRadio($this->model, $this->attribute, $options); } + $this->addAriaAttributes($options); $this->adjustLabelFor($options); return $this; @@ -568,6 +578,7 @@ class ActiveField extends Component $options['label'] = null; $this->parts['{input}'] = Html::activeCheckbox($this->model, $this->attribute, $options); } + $this->addAriaAttributes($options); $this->adjustLabelFor($options); return $this; @@ -595,6 +606,7 @@ class ActiveField extends Component public function dropDownList($items, $options = []) { $options = array_merge($this->inputOptions, $options); + $this->addAriaAttributes($options); $this->adjustLabelFor($options); $this->parts['{input}'] = Html::activeDropDownList($this->model, $this->attribute, $items, $options); @@ -623,6 +635,7 @@ class ActiveField extends Component public function listBox($items, $options = []) { $options = array_merge($this->inputOptions, $options); + $this->addAriaAttributes($options); $this->adjustLabelFor($options); $this->parts['{input}'] = Html::activeListBox($this->model, $this->attribute, $items, $options); @@ -642,6 +655,7 @@ class ActiveField extends Component */ public function checkboxList($items, $options = []) { + $this->addAriaAttributes($options); $this->adjustLabelFor($options); $this->_skipLabelFor = true; $this->parts['{input}'] = Html::activeCheckboxList($this->model, $this->attribute, $items, $options); @@ -661,6 +675,7 @@ class ActiveField extends Component */ public function radioList($items, $options = []) { + $this->addAriaAttributes($options); $this->adjustLabelFor($options); $this->_skipLabelFor = true; $this->parts['{input}'] = Html::activeRadioList($this->model, $this->attribute, $items, $options); @@ -699,6 +714,7 @@ class ActiveField extends Component $config['attribute'] = $this->attribute; $config['view'] = $this->form->getView(); if (isset($config['options']) && isset(class_parents($class)['yii\widgets\InputWidget'])) { + $this->addAriaAttributes($config['options']); $this->adjustLabelFor($config['options']); } $this->parts['{input}'] = $class::widget($config); @@ -732,10 +748,10 @@ class ActiveField extends Component return []; } - $enableClientValidation = $this->enableClientValidation || $this->enableClientValidation === null && $this->form->enableClientValidation; - $enableAjaxValidation = $this->enableAjaxValidation || $this->enableAjaxValidation === null && $this->form->enableAjaxValidation; + $clientValidation = $this->isClientValidationEnabled(); + $ajaxValidation = $this->isAjaxValidationEnabled(); - if ($enableClientValidation) { + if ($clientValidation) { $validators = []; foreach ($this->model->getActiveValidators($attribute) as $validator) { /* @var $validator \yii\validators\Validator */ @@ -749,7 +765,7 @@ class ActiveField extends Component } } - if (!$enableAjaxValidation && (!$enableClientValidation || empty($validators))) { + if (!$ajaxValidation && (!$clientValidation || empty($validators))) { return []; } @@ -770,7 +786,7 @@ class ActiveField extends Component } $options['encodeError'] = !isset($this->errorOptions['encode']) || $this->errorOptions['encode']; - if ($enableAjaxValidation) { + if ($ajaxValidation) { $options['enableAjaxValidation'] = true; } foreach (['validateOnChange', 'validateOnBlur', 'validateOnType', 'validationDelay'] as $name) { @@ -781,6 +797,10 @@ class ActiveField extends Component $options['validate'] = new JsExpression("function (attribute, value, messages, deferred, \$form) {" . implode('', $validators) . '}'); } + if ($this->addAriaAttributes === false) { + $options['updateAriaInvalid'] = false; + } + // only get the options that are different from the default ones (set in yii.activeForm.js) return array_diff_assoc($options, [ 'validateOnChange' => true, @@ -789,10 +809,31 @@ class ActiveField extends Component 'validationDelay' => 500, 'encodeError' => true, 'error' => '.help-block', + 'updateAriaInvalid' => true, ]); } /** + * Checks if client validation enabled for the field + * @return bool + * @since 2.0.11 + */ + protected function isClientValidationEnabled() + { + return $this->enableClientValidation || $this->enableClientValidation === null && $this->form->enableClientValidation; + } + + /** + * Checks if ajax validation enabled for the field + * @return bool + * @since 2.0.11 + */ + protected function isAjaxValidationEnabled() + { + return $this->enableAjaxValidation || $this->enableAjaxValidation === null && $this->form->enableAjaxValidation; + } + + /** * Returns the HTML `id` of the input element of this form field. * @return string the input id. * @since 2.0.7 @@ -801,4 +842,23 @@ class ActiveField extends Component { return $this->_inputId ?: Html::getInputId($this->model, $this->attribute); } + + /** + * Adds aria attributes to the input options + * @param $options array input options + * @since 2.0.11 + */ + protected function addAriaAttributes(&$options) + { + if ($this->addAriaAttributes) { + if (!isset($options['aria-required']) && $this->model->isAttributeRequired($this->attribute)) { + $options['aria-required'] = 'true'; + } + if (!isset($options['aria-invalid'])) { + if ($this->model->hasErrors($this->attribute)) { + $options['aria-invalid'] = 'true'; + } + } + } + } } diff --git a/framework/widgets/BaseListView.php b/framework/widgets/BaseListView.php index 82e3e30..41ba78e 100644 --- a/framework/widgets/BaseListView.php +++ b/framework/widgets/BaseListView.php @@ -71,7 +71,9 @@ abstract class BaseListView extends Widget */ public $summaryOptions = ['class' => 'summary']; /** - * @var bool whether to show the list view if [[dataProvider]] returns no data. + * @var bool whether to show an empty list view if [[dataProvider]] returns no data. + * The default value is false which displays an element according to the `emptyText` + * and `emptyTextOptions` properties. */ public $showOnEmpty = false; /** diff --git a/framework/widgets/Pjax.php b/framework/widgets/Pjax.php index fb4002e..ee109c5 100644 --- a/framework/widgets/Pjax.php +++ b/framework/widgets/Pjax.php @@ -97,6 +97,15 @@ class Pjax extends Widget * [pjax project page](https://github.com/yiisoft/jquery-pjax) for available options. */ public $clientOptions; + /** + * @inheritdoc + * @internal + */ + public static $counter = 0; + /** + * @inheritdoc + */ + public static $autoIdPrefix = 'p'; /** diff --git a/package.json b/package.json new file mode 100644 index 0000000..804d697 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "yii2", + "description": "a modern PHP framework designed for professional Web development", + "main": "index.js", + "directories": { + "doc": "docs", + "test": "tests/js/tests" + }, + "dependencies": {}, + "devDependencies": { + "chai": "^3.5.0", + "jsdom": "^9.8.3", + "leche": "^2.1.2", + "mocha": "^3.1.2", + "mocha-jsdom": "^1.1.0", + "sinon": "^1.17.6" + }, + "scripts": { + "test": "./node_modules/mocha/bin/mocha tests/js/tests/*.test.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/yiisoft/yii2.git" + }, + "keywords": [ + "php", + "framework" + ], + "author": "", + "license": "BSD-3-Clause", + "bugs": { + "url": "https://github.com/yiisoft/yii2/issues" + }, + "homepage": "https://github.com/yiisoft/yii2" +} diff --git a/tests/data/ar/Order.php b/tests/data/ar/Order.php index 12cc6e5..61e8b03 100644 --- a/tests/data/ar/Order.php +++ b/tests/data/ar/Order.php @@ -178,6 +178,13 @@ class Order extends ActiveRecord ->viaTable('order_item', ['order_id' => 'id']); } + public function getLimitedItems() + { + return $this->hasMany(Item::className(), ['id' => 'item_id']) + ->onCondition(['item.id' => [3, 5]]) + ->via('orderItems'); + } + public function beforeSave($insert) { if (parent::beforeSave($insert)) { diff --git a/tests/data/codeclimate/phpmd_ruleset.xml b/tests/data/codeclimate/phpmd_ruleset.xml new file mode 100644 index 0000000..3182294 --- /dev/null +++ b/tests/data/codeclimate/phpmd_ruleset.xml @@ -0,0 +1,40 @@ + + + Custom PHPMD settings for naming, cleancode and controversial rulesets + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/config.php b/tests/data/config.php index 8ba7b26..1072586 100644 --- a/tests/data/config.php +++ b/tests/data/config.php @@ -43,6 +43,12 @@ $config = [ 'password' => 'postgres', 'fixture' => __DIR__ . '/postgres.sql', ], + 'oci' => [ + 'dsn' => 'oci:dbname=LOCAL_XE;charset=AL32UTF8;', + 'username' => '', + 'password' => '', + 'fixture' => __DIR__ . '/oci.sql', + ], ], ]; diff --git a/tests/data/console/migrate_create/create_unsigned_big_pk.php b/tests/data/console/migrate_create/create_unsigned_big_pk.php new file mode 100644 index 0000000..54b3574 --- /dev/null +++ b/tests/data/console/migrate_create/create_unsigned_big_pk.php @@ -0,0 +1,32 @@ +createTable('{table}', [ + 'brand_id' => \$this->bigPrimaryKey()->unsigned(), + ]); + } + + /** + * @inheritdoc + */ + public function down() + { + \$this->dropTable('{table}'); + } +} + +CODE; diff --git a/tests/data/console/migrate_create/create_unsigned_pk.php b/tests/data/console/migrate_create/create_unsigned_pk.php new file mode 100644 index 0000000..1c69843 --- /dev/null +++ b/tests/data/console/migrate_create/create_unsigned_pk.php @@ -0,0 +1,32 @@ +createTable('{table}', [ + 'brand_id' => \$this->primaryKey()->unsigned(), + ]); + } + + /** + * @inheritdoc + */ + public function down() + { + \$this->dropTable('{table}'); + } +} + +CODE; diff --git a/tests/data/validators/models/FakedValidationModel.php b/tests/data/validators/models/FakedValidationModel.php index 6da3aa2..fa08686 100644 --- a/tests/data/validators/models/FakedValidationModel.php +++ b/tests/data/validators/models/FakedValidationModel.php @@ -11,6 +11,7 @@ class FakedValidationModel extends Model public $val_attr_c; public $val_attr_d; private $attr = []; + private $inlineValArgs; /** * @param array $attributes @@ -36,11 +37,18 @@ class FakedValidationModel extends Model ]; } - public function inlineVal($attribute, $params = []) + public function inlineVal($attribute, $params = [], $validator) { + $this->inlineValArgs = func_get_args(); + return true; } + public function clientInlineVal($attribute, $params = [], $validator) + { + return func_get_args(); + } + public function __get($name) { if (stripos($name, 'attr') === 0) { @@ -63,4 +71,14 @@ class FakedValidationModel extends Model { return $attr; } + + /** + * Returns the arguments of the inlineVal method in the last call. + * @return array|null an array of arguments in the last call or null if method never been called. + * @see inlineVal + */ + public function getInlineValArgs() + { + return $this->inlineValArgs; + } } diff --git a/tests/data/views/pageCacheLayout.php b/tests/data/views/pageCacheLayout.php new file mode 100644 index 0000000..4119779 --- /dev/null +++ b/tests/data/views/pageCacheLayout.php @@ -0,0 +1,15 @@ +beginPage(); +$this->head(); +$this->beginBody(); +?> +{ + "static": "", + "dynamic": "renderDynamic('return Yii::$app->params[\'dynamic\'];') ?>" +} +endBody(); +$this->endPage(); +?> diff --git a/tests/framework/base/ApplicationTest.php b/tests/framework/base/ApplicationTest.php new file mode 100644 index 0000000..37fc67e --- /dev/null +++ b/tests/framework/base/ApplicationTest.php @@ -0,0 +1,35 @@ +mockApplication([ + 'container' => [ + 'definitions' => [ + Dispatcher::className() => DispatcherMock::className() + ] + ], + 'bootstrap' => ['log'] + ]); + + $this->assertInstanceOf(DispatcherMock::className(), Yii::$app->log); + } +} + +class DispatcherMock extends Dispatcher +{ + +} diff --git a/tests/framework/base/ControllerTest.php b/tests/framework/base/ControllerTest.php new file mode 100644 index 0000000..fdf742e --- /dev/null +++ b/tests/framework/base/ControllerTest.php @@ -0,0 +1,57 @@ +mockApplication(); + + static::$actionRuns = []; + $controller = new TestController('test-controller', Yii::$app); + $this->assertNull($controller->action); + $result = $controller->runAction('test1'); + $this->assertEquals('test1', $result); + $this->assertEquals([ + 'test-controller/test1', + ], static::$actionRuns); + $this->assertNotNull($controller->action); + $this->assertEquals('test1', $controller->action->id); + $this->assertEquals('test-controller/test1', $controller->action->uniqueId); + + $result = $controller->runAction('test2'); + $this->assertEquals('test2', $result); + $this->assertEquals([ + 'test-controller/test1', + 'test-controller/test2', + ], static::$actionRuns); + $this->assertNotNull($controller->action); + $this->assertEquals('test1', $controller->action->id); + $this->assertEquals('test-controller/test1', $controller->action->uniqueId); + } +} + + +class TestController extends Controller +{ + public function actionTest1() + { + ControllerTest::$actionRuns[] = $this->action->uniqueId; + return 'test1'; + } + public function actionTest2() + { + ControllerTest::$actionRuns[] = $this->action->uniqueId; + return 'test2'; + } +} \ No newline at end of file diff --git a/tests/framework/base/DynamicModelTest.php b/tests/framework/base/DynamicModelTest.php index 534f372..8388a7f 100644 --- a/tests/framework/base/DynamicModelTest.php +++ b/tests/framework/base/DynamicModelTest.php @@ -11,9 +11,7 @@ use yii\base\DynamicModel; use yiiunit\TestCase; /** - * - * @author Qiang Xue - * @since 2.0 + * @group base */ class DynamicModelTest extends TestCase { @@ -77,4 +75,28 @@ class DynamicModelTest extends TestCase $this->setExpectedException('yii\base\UnknownPropertyException'); $age = $model->age; } + + public function testLoad() + { + $dynamic = new DynamicModel(); + //define two attributes + $dynamic->defineAttribute('name'); + $dynamic->defineAttribute('mobile'); + // define rule + $dynamic->addRule(['name','mobile'], 'required'); + // define your sample data + $data = [ + 'DynamicModel' => [ + 'name' => $name = 'your name 2', + 'mobile' => $mobile = 'my number mobile', + ] + ]; + // load data + $this->assertFalse($dynamic->load([])); + $this->assertTrue($dynamic->load($data)); + + $this->assertTrue($dynamic->validate()); + $this->assertEquals($name, $dynamic->name); + $this->assertEquals($mobile, $dynamic->mobile); + } } diff --git a/tests/framework/base/EventTest.php b/tests/framework/base/EventTest.php index c145711..3d9abcf 100644 --- a/tests/framework/base/EventTest.php +++ b/tests/framework/base/EventTest.php @@ -12,8 +12,7 @@ use yii\base\Event; use yiiunit\TestCase; /** - * @author Qiang Xue - * @since 2.0 + * @group base */ class EventTest extends TestCase { diff --git a/tests/framework/base/ModuleTest.php b/tests/framework/base/ModuleTest.php index 20071ce..c2348c1 100644 --- a/tests/framework/base/ModuleTest.php +++ b/tests/framework/base/ModuleTest.php @@ -2,6 +2,8 @@ namespace yiiunit\framework\base; +use Yii; +use yii\base\Controller; use yiiunit\TestCase; /** @@ -47,9 +49,54 @@ class ModuleTest extends TestCase $version = $module->getVersion(); $this->assertEquals('1.0', $version); } + + public static $actionRuns = []; + + public function testRunControllerAction() + { + $module = new TestModule('test'); + $this->assertNull(Yii::$app->controller); + static::$actionRuns = []; + + $module->runAction('test-controller1/test1'); + $this->assertEquals([ + 'test/test-controller1/test1', + ], static::$actionRuns); + $this->assertNotNull(Yii::$app->controller); + $this->assertEquals('test-controller1', Yii::$app->controller->id); + $this->assertEquals('test/test-controller1', Yii::$app->controller->uniqueId); + $this->assertNotNull(Yii::$app->controller->action); + $this->assertEquals('test/test-controller1/test1', Yii::$app->controller->action->uniqueId); + + $module->runAction('test-controller2/test2'); + $this->assertEquals([ + 'test/test-controller1/test1', + 'test/test-controller2/test2', + ], static::$actionRuns); + $this->assertNotNull(Yii::$app->controller); + $this->assertEquals('test-controller1', Yii::$app->controller->id); + $this->assertEquals('test/test-controller1', Yii::$app->controller->uniqueId); + $this->assertNotNull(Yii::$app->controller->action); + $this->assertEquals('test/test-controller1/test1', Yii::$app->controller->action->uniqueId); + } } class TestModule extends \yii\base\Module { + public $controllerMap = [ + 'test-controller1' => 'yiiunit\framework\base\ModuleTestController', + 'test-controller2' => 'yiiunit\framework\base\ModuleTestController', + ]; +} +class ModuleTestController extends Controller +{ + public function actionTest1() + { + ModuleTest::$actionRuns[] = $this->action->uniqueId; + } + public function actionTest2() + { + ModuleTest::$actionRuns[] = $this->action->uniqueId; + } } \ No newline at end of file diff --git a/tests/framework/console/UnkownCommandExceptionTest.php b/tests/framework/console/UnkownCommandExceptionTest.php new file mode 100644 index 0000000..88897ed --- /dev/null +++ b/tests/framework/console/UnkownCommandExceptionTest.php @@ -0,0 +1,70 @@ +mockApplication([ + 'enableCoreCommands' => false, + 'controllerMap' => [ + 'cache' => 'yii\console\controllers\CacheController', + 'migrate' => 'yii\console\controllers\MigrateController', + 'message' => 'yii\console\controllers\MessageController', + ], + ]); + } + + public function suggestedCommandsProvider() + { + return [ + ['migate', ['migrate']], + ['mihate/u', ['migrate/up']], + ['mirgte/u', ['migrate/up']], + ['mirgte/up', ['migrate/up']], + ['mirgte', ['migrate']], + ['hlp', ['help']], + ['ca', ['cache', 'cache/flush', 'cache/flush-all', 'cache/flush-schema', 'cache/index']], + ['cach', ['cache', 'cache/flush', 'cache/flush-all', 'cache/flush-schema', 'cache/index']], + ['cach/fush', ['cache/flush']], + ['cach/fushall', ['cache/flush-all']], + ['what?', []], + // test UTF 8 chars + ['ёлка', []], + // this crashes levenshtein because string is longer than 255 chars + [str_repeat('asdw1234', 31), []], + [str_repeat('asdw1234', 32), []], + [str_repeat('asdw1234', 33), []], + ]; + } + + /** + * @dataProvider suggestedCommandsProvider + */ + public function testSuggestCommand($command, $expectedSuggestion) + { + $exception = new UnknownCommandException($command, Yii::$app); + $this->assertEquals($expectedSuggestion, $exception->getSuggestedAlternatives()); + } + + public function testNameAndConstructor() + { + $exception = new UnknownCommandException('test', Yii::$app); + $this->assertEquals('Unknown command', $exception->getName()); + $this->assertEquals('test', $exception->command); + } +} diff --git a/tests/framework/console/controllers/HelpControllerTest.php b/tests/framework/console/controllers/HelpControllerTest.php index 14d25e1..e508b16 100644 --- a/tests/framework/console/controllers/HelpControllerTest.php +++ b/tests/framework/console/controllers/HelpControllerTest.php @@ -45,6 +45,79 @@ class HelpControllerTest extends TestCase return $controller->flushStdOutBuffer(); } + public function testActionList() + { + $this->mockApplication([ + 'enableCoreCommands' => false, + 'controllerMap' => [ + 'migrate' => 'yii\console\controllers\MigrateController', + 'cache' => 'yii\console\controllers\CacheController', + ], + ]); + $result = Console::stripAnsiFormat($this->runControllerAction('list')); + $this->assertEquals(<<mockApplication([ + 'enableCoreCommands' => false, + 'controllerMap' => [ + 'migrate' => 'yii\console\controllers\MigrateController', + 'cache' => 'yii\console\controllers\CacheController', + ], + ]); + $result = Console::stripAnsiFormat($this->runControllerAction('list-action-options', ['action' => 'help/list-action-options'])); + $this->assertEquals(<<mockApplication([ + 'enableCoreCommands' => false, + 'controllerMap' => [ + 'migrate' => 'yii\console\controllers\MigrateController', + 'cache' => 'yii\console\controllers\CacheController', + ], + ]); + $result = Console::stripAnsiFormat($this->runControllerAction('usage', ['action' => 'help/list-action-options'])); + $this->assertEquals(<< + +STRING + , $result); + } + public function testActionIndex() { $result = Console::stripAnsiFormat($this->runControllerAction('index')); @@ -56,7 +129,7 @@ class HelpControllerTest extends TestCase public function testActionIndexWithHelpCommand() { - $result = Console::stripAnsiFormat($this->runControllerAction('index', ['command' => 'help'])); + $result = Console::stripAnsiFormat($this->runControllerAction('index', ['command' => 'help/index'])); $this->assertContains('Displays available commands or the detailed information', $result); $this->assertContains('bootstrap.php help [command] [...options...]', $result); $this->assertContains('--appconfig: string', $result); @@ -88,4 +161,4 @@ class HelpControllerTest extends TestCase class BufferedHelpController extends HelpController { use StdOutBufferControllerTrait; -} \ No newline at end of file +} diff --git a/tests/framework/console/controllers/MigrateControllerTest.php b/tests/framework/console/controllers/MigrateControllerTest.php index c22cd74..c95d1cb 100644 --- a/tests/framework/console/controllers/MigrateControllerTest.php +++ b/tests/framework/console/controllers/MigrateControllerTest.php @@ -95,6 +95,14 @@ class MigrateControllerTest extends TestCase 'fields' => 'title:primaryKey,body:text:notNull,price:money(11,2)', ]); + $this->assertCommandCreatedFile('create_unsigned_pk', $migrationName, $table, [ + 'fields' => 'brand_id:primaryKey:unsigned', + ]); + + $this->assertCommandCreatedFile('create_unsigned_big_pk', $migrationName, $table, [ + 'fields' => 'brand_id:bigPrimaryKey:unsigned', + ]); + $this->assertCommandCreatedFile('create_id_pk', $migrationName, $table, [ 'fields' => 'id:primaryKey, address:string, diff --git a/tests/framework/db/ActiveQueryTest.php b/tests/framework/db/ActiveQueryTest.php index 7e0f6f7..6825aa7 100644 --- a/tests/framework/db/ActiveQueryTest.php +++ b/tests/framework/db/ActiveQueryTest.php @@ -118,7 +118,7 @@ abstract class ActiveQueryTest extends DatabaseTestCase } /** - * @todo: tests for internal logic of joinWith() + * @todo: tests for internal logic of innerJoinWith() */ public function testInnerJoinWith() { @@ -129,6 +129,24 @@ abstract class ActiveQueryTest extends DatabaseTestCase ], $result->joinWith); } + /** + * @todo: tests for the regex inside getQueryTableName + */ + public function testGetQueryTableName_from_not_set() + { + $query = new ActiveQuery(Customer::className()); + $result = $this->invokeMethod($query,'getTableNameAndAlias'); + $this->assertEquals(['customer','customer'], $result); + } + + public function testGetQueryTableName_from_set() + { + $options = ['from' => ['alias'=>'customer']]; + $query = new ActiveQuery(Customer::className(),$options); + $result = $this->invokeMethod($query,'getTableNameAndAlias'); + $this->assertEquals(['customer','alias'], $result); + } + public function testOnCondition() { $query = new ActiveQuery(Customer::className()); @@ -139,7 +157,7 @@ abstract class ActiveQueryTest extends DatabaseTestCase $this->assertEquals($params, $result->params); } - public function testAndOnCondition_OnIsNull() + public function testAndOnCondition_on_not_set() { $query = new ActiveQuery(Customer::className()); $on = ['active' => true]; @@ -149,7 +167,7 @@ abstract class ActiveQueryTest extends DatabaseTestCase $this->assertEquals($params, $result->params); } - public function testAndOnCondition_OnIsNotNull() + public function testAndOnCondition_on_set() { $onOld = ['active' => true]; $query = new ActiveQuery(Customer::className()); @@ -162,7 +180,7 @@ abstract class ActiveQueryTest extends DatabaseTestCase $this->assertEquals($params, $result->params); } - public function testOrOnCondition_OnIsNull() + public function testOrOnCondition_on_not_set() { $query = new ActiveQuery(Customer::className()); $on = ['active' => true]; @@ -172,7 +190,7 @@ abstract class ActiveQueryTest extends DatabaseTestCase $this->assertEquals($params, $result->params); } - public function testOrOnCondition_OnIsNotNull() + public function testOrOnCondition_on_set() { $onOld = ['active' => true]; $query = new ActiveQuery(Customer::className()); diff --git a/tests/framework/db/ActiveRecordTest.php b/tests/framework/db/ActiveRecordTest.php index 709757b..304bfb6 100644 --- a/tests/framework/db/ActiveRecordTest.php +++ b/tests/framework/db/ActiveRecordTest.php @@ -3,6 +3,7 @@ namespace yiiunit\framework\db; use yii\db\ActiveQuery; +use yii\db\ActiveRecordInterface; use yiiunit\data\ar\ActiveRecord; use yiiunit\data\ar\BitValues; use yiiunit\data\ar\Category; @@ -52,6 +53,11 @@ abstract class ActiveRecordTest extends DatabaseTestCase return OrderItem::className(); } + public function getCategoryClass() + { + return Category::className(); + } + public function getOrderWithNullFKClass() { return OrderWithNullFK::className(); @@ -1294,4 +1300,121 @@ abstract class ActiveRecordTest extends DatabaseTestCase $this->assertEquals($newTotal, $newOrder->total); } + public function testEmulateExecution() + { + $this->assertGreaterThan(0, Customer::find()->count()); + + $rows = Customer::find() + ->emulateExecution() + ->all(); + $this->assertSame([], $rows); + + $row = Customer::find() + ->emulateExecution() + ->one(); + $this->assertSame(null, $row); + + $exists = Customer::find() + ->emulateExecution() + ->exists(); + $this->assertSame(false, $exists); + + $count = Customer::find() + ->emulateExecution() + ->count(); + $this->assertSame(0, $count); + + $sum = Customer::find() + ->emulateExecution() + ->sum('id'); + $this->assertSame(0, $sum); + + $sum = Customer::find() + ->emulateExecution() + ->average('id'); + $this->assertSame(0, $sum); + + $max = Customer::find() + ->emulateExecution() + ->max('id'); + $this->assertSame(null, $max); + + $min = Customer::find() + ->emulateExecution() + ->min('id'); + $this->assertSame(null, $min); + + $scalar = Customer::find() + ->select(['id']) + ->emulateExecution() + ->scalar(); + $this->assertSame(null, $scalar); + + $column = Customer::find() + ->select(['id']) + ->emulateExecution() + ->column(); + $this->assertSame([], $column); + } + + /** + * https://github.com/yiisoft/yii2/issues/12213 + */ + public function testUnlinkAllOnCondition() + { + /** @var Category $categoryClass */ + $categoryClass = $this->getCategoryClass(); + /** @var Item $itemClass */ + $itemClass = $this->getItemClass(); + + // Ensure there are three items with category_id = 2 in the Items table + $itemsCount = $itemClass::find()->where(['category_id' => 2])->count(); + $this->assertEquals(3, $itemsCount); + + $categoryQuery = $categoryClass::find()->with('limitedItems')->where(['id' => 2]); + // Ensure that limitedItems relation returns only one item + // (category_id = 2 and id in (1,2,3)) + $category = $categoryQuery->one(); + $this->assertCount(1, $category->limitedItems); + + // Unlink all items in the limitedItems relation + $category->unlinkAll('limitedItems', true); + + // Make sure that only one item was unlinked + $itemsCount = $itemClass::find()->where(['category_id' => 2])->count(); + $this->assertEquals(2, $itemsCount); + + // Call $categoryQuery again to ensure no items were found + $this->assertCount(0, $categoryQuery->one()->limitedItems); + } + + /** + * https://github.com/yiisoft/yii2/issues/12213 + */ + public function testUnlinkAllOnConditionViaTable() + { + /** @var Order $orderClass */ + $orderClass = $this->getOrderClass(); + /** @var Item $itemClass */ + $itemClass = $this->getItemClass(); + + // Ensure there are three items with category_id = 2 in the Items table + $itemsCount = $itemClass::find()->where(['category_id' => 2])->count(); + $this->assertEquals(3, $itemsCount); + + $orderQuery = $orderClass::find()->with('limitedItems')->where(['id' => 2]); + // Ensure that limitedItems relation returns only one item + // (category_id = 2 and id in (4, 5)) + $category = $orderQuery->one(); + $this->assertCount(2, $category->limitedItems); + + // Unlink all items in the limitedItems relation + $category->unlinkAll('limitedItems', true); + + // Call $orderQuery again to ensure that links are removed + $this->assertCount(0, $orderQuery->one()->limitedItems); + + // Make sure that only links were removed, the items were not removed + $this->assertEquals(3, $itemClass::find()->where(['category_id' => 2])->count()); + } } diff --git a/tests/framework/db/QueryBuilderTest.php b/tests/framework/db/QueryBuilderTest.php index 719f3b1..5d9d064 100644 --- a/tests/framework/db/QueryBuilderTest.php +++ b/tests/framework/db/QueryBuilderTest.php @@ -167,7 +167,6 @@ abstract class QueryBuilderTest extends DatabaseTestCase [ 'mysql' => 'char(1) CHECK (value LIKE "test%")', 'sqlite' => 'char(1) CHECK (value LIKE "test%")', - 'oci' => 'CHAR(1) CHECK (value LIKE "test%")', 'cubrid' => 'char(1) CHECK (value LIKE "test%")', ], ], @@ -188,7 +187,6 @@ abstract class QueryBuilderTest extends DatabaseTestCase [ 'mysql' => 'char(6) CHECK (value LIKE "test%")', 'sqlite' => 'char(6) CHECK (value LIKE "test%")', - 'oci' => 'CHAR(6) CHECK (value LIKE "test%")', 'cubrid' => 'char(6) CHECK (value LIKE "test%")', ], ], @@ -883,7 +881,6 @@ abstract class QueryBuilderTest extends DatabaseTestCase 'mysql' => 'timestamp NULL DEFAULT NULL', 'postgres' => 'timestamp(0) NULL DEFAULT NULL', 'sqlite' => 'timestamp NULL DEFAULT NULL', - 'oci' => 'TIMESTAMP NULL DEFAULT NULL', 'sqlsrv' => 'timestamp NULL DEFAULT NULL', 'cubrid' => 'timestamp NULL DEFAULT NULL', ], @@ -912,7 +909,6 @@ abstract class QueryBuilderTest extends DatabaseTestCase [ 'mysql' => "int(11) COMMENT 'test comment'", 'postgres' => 'integer', - 'oci' => "NUMBER(10)", 'sqlsrv' => 'int', 'cubrid' => "int COMMENT 'test comment'", ], @@ -923,11 +919,65 @@ abstract class QueryBuilderTest extends DatabaseTestCase [ 'mysql' => "int(11) NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT 'test comment'", 'postgres' => 'serial NOT NULL PRIMARY KEY', - 'oci' => 'NUMBER(10) NOT NULL PRIMARY KEY', 'sqlsrv' => 'int IDENTITY PRIMARY KEY', 'cubrid' => "int NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT 'test comment'", ], ], + [ + Schema::TYPE_PK . " FIRST", + $this->primaryKey()->first(), + [ + 'mysql' => "int(11) NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST", + 'postgres' => 'serial NOT NULL PRIMARY KEY', + 'oci' => 'NUMBER(10) NOT NULL PRIMARY KEY', + 'sqlsrv' => 'int IDENTITY PRIMARY KEY', + 'cubrid' => "int NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST", + ], + ], + [ + Schema::TYPE_INTEGER . " FIRST", + $this->integer()->first(), + [ + 'mysql' => "int(11) FIRST", + 'postgres' => 'integer', + 'oci' => "NUMBER(10)", + 'sqlsrv' => 'int', + 'cubrid' => "int FIRST", + ], + ], + [ + Schema::TYPE_STRING . ' FIRST', + $this->string()->first(), + [ + 'mysql' => 'varchar(255) FIRST', + 'postgres' => 'varchar(255)', + 'oci' => 'VARCHAR2(255)', + 'sqlsrv' => 'varchar(255)', + 'cubrid' => 'varchar(255) FIRST', + ], + ], + [ + Schema::TYPE_INTEGER . " NOT NULL FIRST", + $this->integer()->append('NOT NULL')->first(), + [ + 'mysql' => "int(11) NOT NULL FIRST", + 'postgres' => 'integer NOT NULL', + 'oci' => "NUMBER(10) NOT NULL", + 'sqlsrv' => 'int NOT NULL', + 'cubrid' => "int NOT NULL FIRST", + ], + ], + [ + Schema::TYPE_STRING . ' NOT NULL FIRST', + $this->string()->append('NOT NULL')->first(), + [ + 'mysql' => 'varchar(255) NOT NULL FIRST', + 'postgres' => 'varchar(255) NOT NULL', + 'oci' => 'VARCHAR2(255) NOT NULL', + 'sqlsrv' => 'varchar(255) NOT NULL', + 'cubrid' => 'varchar(255) NOT NULL FIRST', + ], + ], ]; foreach ($items as $i => $item) { @@ -970,7 +1020,9 @@ abstract class QueryBuilderTest extends DatabaseTestCase if (!(strncmp($column, Schema::TYPE_PK, 2) === 0 || strncmp($column, Schema::TYPE_UPK, 3) === 0 || strncmp($column, Schema::TYPE_BIGPK, 5) === 0 || - strncmp($column, Schema::TYPE_UBIGPK, 6) === 0)) { + strncmp($column, Schema::TYPE_UBIGPK, 6) === 0 || + strncmp(substr($column, -5), 'FIRST', 5) === 0 + )) { $columns['col' . ++$i] = str_replace('CHECK (value', 'CHECK ([[col' . $i . ']]', $column); } } diff --git a/tests/framework/db/QueryTest.php b/tests/framework/db/QueryTest.php index a5a68f1..95ce860 100644 --- a/tests/framework/db/QueryTest.php +++ b/tests/framework/db/QueryTest.php @@ -2,6 +2,7 @@ namespace yiiunit\framework\db; +use yii\db\Connection; use yii\db\Expression; use yii\db\Query; @@ -55,9 +56,8 @@ abstract class QueryTest extends DatabaseTestCase $this->assertEquals([':id' => 1, ':name' => 'something', ':age' => '30'], $query->params); } - public function testFilterWhere() + public function testFilterWhereWithHashFormat() { - // should work with hash format $query = new Query; $query->filterWhere([ 'id' => 0, @@ -71,8 +71,10 @@ abstract class QueryTest extends DatabaseTestCase $query->orFilterWhere(['name' => '']); $this->assertEquals(['id' => 0], $query->where); + } - // should work with operator format + public function testFilterWhereWithOperatorFormat() + { $query = new Query; $condition = ['like', 'name', 'Alex']; $query->filterWhere($condition); @@ -90,9 +92,6 @@ abstract class QueryTest extends DatabaseTestCase $query->andFilterWhere(['not in', 'id', []]); $this->assertEquals($condition, $query->where); - $query->andFilterWhere(['not in', 'id', []]); - $this->assertEquals($condition, $query->where); - $query->andFilterWhere(['like', 'id', '']); $this->assertEquals($condition, $query->where); @@ -109,6 +108,58 @@ abstract class QueryTest extends DatabaseTestCase $this->assertEquals($condition, $query->where); } + public function testFilterHavingWithHashFormat() + { + $query = new Query; + $query->filterHaving([ + 'id' => 0, + 'title' => ' ', + 'author_ids' => [], + ]); + $this->assertEquals(['id' => 0], $query->having); + + $query->andFilterHaving(['status' => null]); + $this->assertEquals(['id' => 0], $query->having); + + $query->orFilterHaving(['name' => '']); + $this->assertEquals(['id' => 0], $query->having); + } + + public function testFilterHavingWithOperatorFormat() + { + $query = new Query; + $condition = ['like', 'name', 'Alex']; + $query->filterHaving($condition); + $this->assertEquals($condition, $query->having); + + $query->andFilterHaving(['between', 'id', null, null]); + $this->assertEquals($condition, $query->having); + + $query->orFilterHaving(['not between', 'id', null, null]); + $this->assertEquals($condition, $query->having); + + $query->andFilterHaving(['in', 'id', []]); + $this->assertEquals($condition, $query->having); + + $query->andFilterHaving(['not in', 'id', []]); + $this->assertEquals($condition, $query->having); + + $query->andFilterHaving(['like', 'id', '']); + $this->assertEquals($condition, $query->having); + + $query->andFilterHaving(['or like', 'id', '']); + $this->assertEquals($condition, $query->having); + + $query->andFilterHaving(['not like', 'id', ' ']); + $this->assertEquals($condition, $query->having); + + $query->andFilterHaving(['or not like', 'id', null]); + $this->assertEquals($condition, $query->having); + + $query->andFilterHaving(['or', ['eq', 'id', null], ['eq', 'id', []]]); + $this->assertEquals($condition, $query->having); + } + public function testFilterRecursively() { $query = new Query(); @@ -271,7 +322,8 @@ abstract class QueryTest extends DatabaseTestCase } /** - * @depends testFilterWhere + * @depends testFilterWhereWithHashFormat + * @depends testFilterWhereWithOperatorFormat */ public function testAndFilterCompare() { @@ -292,11 +344,11 @@ abstract class QueryTest extends DatabaseTestCase $query->andFilterCompare('name', 'Doe', 'like'); $this->assertEquals($condition, $query->where); - $condition = ['and', $condition, ['>', 'rating', '9']]; + $condition[] = ['>', 'rating', '9']; $query->andFilterCompare('rating', '>9'); $this->assertEquals($condition, $query->where); - $condition = ['and', $condition, ['<=', 'value', '100']]; + $condition[] = ['<=', 'value', '100']; $query->andFilterCompare('value', '<=100'); $this->assertEquals($condition, $query->where); } @@ -317,4 +369,73 @@ abstract class QueryTest extends DatabaseTestCase $count = (new Query)->from('customer')->having(['status' => 2])->count('*', $db); $this->assertEquals(1, $count); } + + public function testEmulateExecution() + { + $db = $this->getConnection(); + + $this->assertGreaterThan(0, (new Query())->from('customer')->count('*', $db)); + + $rows = (new Query()) + ->from('customer') + ->emulateExecution() + ->all($db); + $this->assertSame([], $rows); + + $row = (new Query()) + ->from('customer') + ->emulateExecution() + ->one($db); + $this->assertSame(false, $row); + + $exists = (new Query()) + ->from('customer') + ->emulateExecution() + ->exists($db); + $this->assertSame(false, $exists); + + $count = (new Query()) + ->from('customer') + ->emulateExecution() + ->count('*', $db); + $this->assertSame(0, $count); + + $sum = (new Query()) + ->from('customer') + ->emulateExecution() + ->sum('id', $db); + $this->assertSame(0, $sum); + + $sum = (new Query()) + ->from('customer') + ->emulateExecution() + ->average('id', $db); + $this->assertSame(0, $sum); + + $max = (new Query()) + ->from('customer') + ->emulateExecution() + ->max('id', $db); + $this->assertSame(null, $max); + + $min = (new Query()) + ->from('customer') + ->emulateExecution() + ->min('id', $db); + $this->assertSame(null, $min); + + $scalar = (new Query()) + ->select(['id']) + ->from('customer') + ->emulateExecution() + ->scalar($db); + $this->assertSame(null, $scalar); + + $column = (new Query()) + ->select(['id']) + ->from('customer') + ->emulateExecution() + ->column($db); + $this->assertSame([], $column); + } } diff --git a/tests/framework/db/oci/ConnectionTest.php b/tests/framework/db/oci/ConnectionTest.php index d65b000..b78c6c1 100644 --- a/tests/framework/db/oci/ConnectionTest.php +++ b/tests/framework/db/oci/ConnectionTest.php @@ -9,4 +9,62 @@ namespace yiiunit\framework\db\oci; class ConnectionTest extends \yiiunit\framework\db\ConnectionTest { protected $driverName = 'oci'; + + public function testSerialize() + { + $connection = $this->getConnection(false, false); + $connection->open(); + $serialized = serialize($connection); + $unserialized = unserialize($serialized); + $this->assertInstanceOf('yii\db\Connection', $unserialized); + + $this->assertEquals(123, $unserialized->createCommand("SELECT 123 FROM DUAL")->queryScalar()); + } + + public function testQuoteTableName() + { + $connection = $this->getConnection(false); + $this->assertEquals('"table"', $connection->quoteTableName('table')); + $this->assertEquals('"table"', $connection->quoteTableName('"table"')); + $this->assertEquals('"schema"."table"', $connection->quoteTableName('schema.table')); + $this->assertEquals('"schema"."table"', $connection->quoteTableName('schema."table"')); + $this->assertEquals('"schema"."table"', $connection->quoteTableName('"schema"."table"')); + $this->assertEquals('{{table}}', $connection->quoteTableName('{{table}}')); + $this->assertEquals('(table)', $connection->quoteTableName('(table)')); + } + + public function testQuoteColumnName() + { + $connection = $this->getConnection(false); + $this->assertEquals('"column"', $connection->quoteColumnName('column')); + $this->assertEquals('"column"', $connection->quoteColumnName('"column"')); + $this->assertEquals('[[column]]', $connection->quoteColumnName('[[column]]')); + $this->assertEquals('{{column}}', $connection->quoteColumnName('{{column}}')); + $this->assertEquals('(column)', $connection->quoteColumnName('(column)')); + + $this->assertEquals('"column"', $connection->quoteSql('[[column]]')); + $this->assertEquals('"column"', $connection->quoteSql('{{column}}')); + } + + public function testQuoteFullColumnName() + { + $connection = $this->getConnection(false, false); + $this->assertEquals('"table"."column"', $connection->quoteColumnName('table.column')); + $this->assertEquals('"table"."column"', $connection->quoteColumnName('table."column"')); + $this->assertEquals('"table"."column"', $connection->quoteColumnName('"table".column')); + $this->assertEquals('"table"."column"', $connection->quoteColumnName('"table"."column"')); + + $this->assertEquals('[[table.column]]', $connection->quoteColumnName('[[table.column]]')); + $this->assertEquals('{{table}}."column"', $connection->quoteColumnName('{{table}}.column')); + $this->assertEquals('{{table}}."column"', $connection->quoteColumnName('{{table}}."column"')); + $this->assertEquals('{{table}}.[[column]]', $connection->quoteColumnName('{{table}}.[[column]]')); + $this->assertEquals('{{%table}}."column"', $connection->quoteColumnName('{{%table}}.column')); + $this->assertEquals('{{%table}}."column"', $connection->quoteColumnName('{{%table}}."column"')); + + $this->assertEquals('"table"."column"', $connection->quoteSql('[[table.column]]')); + $this->assertEquals('"table"."column"', $connection->quoteSql('{{table}}.[[column]]')); + $this->assertEquals('"table"."column"', $connection->quoteSql('{{table}}."column"')); + $this->assertEquals('"table"."column"', $connection->quoteSql('{{%table}}.[[column]]')); + $this->assertEquals('"table"."column"', $connection->quoteSql('{{%table}}."column"')); + } } diff --git a/tests/framework/di/ContainerTest.php b/tests/framework/di/ContainerTest.php index c6ac6db..fe632a8 100644 --- a/tests/framework/di/ContainerTest.php +++ b/tests/framework/di/ContainerTest.php @@ -10,6 +10,9 @@ namespace yiiunit\framework\di; use Yii; use yii\di\Container; use yii\di\Instance; +use yiiunit\data\ar\Cat; +use yiiunit\data\ar\Order; +use yiiunit\data\ar\Type; use yiiunit\framework\di\stubs\Bar; use yiiunit\framework\di\stubs\Foo; use yiiunit\framework\di\stubs\Qux; @@ -223,4 +226,58 @@ class ContainerTest extends TestCase }; $this->assertNull($container->invoke($closure)); } + + public function testSetDependencies() + { + $container = new Container(); + $container->setDefinitions([ + 'model.order' => Order::className(), + Cat::className() => Type::className(), + 'test\TraversableInterface' => [ + ['class' => 'yiiunit\data\base\TraversableObject'], + [['item1', 'item2']] + ], + 'qux.using.closure' => function () { + return new Qux(); + } + ]); + $container->setDefinitions([]); + + $this->assertInstanceOf(Order::className(), $container->get('model.order')); + $this->assertInstanceOf(Type::className(), $container->get(Cat::className())); + + $traversable = $container->get('test\TraversableInterface'); + $this->assertInstanceOf('yiiunit\data\base\TraversableObject', $traversable); + $this->assertEquals('item1', $traversable->current()); + + $this->assertInstanceOf('yiiunit\framework\di\stubs\Qux', $container->get('qux.using.closure')); + } + + public function testContainerSingletons() + { + $container = new Container(); + $container->setSingletons([ + 'model.order' => Order::className(), + 'test\TraversableInterface' => [ + ['class' => 'yiiunit\data\base\TraversableObject'], + [['item1', 'item2']] + ], + 'qux.using.closure' => function () { + return new Qux(); + } + ]); + $container->setSingletons([]); + + $order = $container->get('model.order'); + $sameOrder = $container->get('model.order'); + $this->assertSame($order, $sameOrder); + + $traversable = $container->get('test\TraversableInterface'); + $sameTraversable = $container->get('test\TraversableInterface'); + $this->assertSame($traversable, $sameTraversable); + + $foo = $container->get('qux.using.closure'); + $sameFoo = $container->get('qux.using.closure'); + $this->assertSame($foo, $sameFoo); + } } diff --git a/tests/framework/di/ServiceLocatorTest.php b/tests/framework/di/ServiceLocatorTest.php index 8df9b55..5e3dd5f 100644 --- a/tests/framework/di/ServiceLocatorTest.php +++ b/tests/framework/di/ServiceLocatorTest.php @@ -86,4 +86,29 @@ class ServiceLocatorTest extends TestCase $this->assertTrue($object2 instanceof $className); $this->assertTrue($object === $object2); } + + /** + * https://github.com/yiisoft/yii2/issues/11771 + */ + public function testModulePropertyIsset() + { + $config = [ + 'components' => [ + 'captcha' => [ + 'name' => 'foo bar', + 'class' => 'yii\captcha\Captcha', + ], + ], + ]; + + $app = new ServiceLocator($config); + + $this->assertTrue(isset($app->captcha->name)); + $this->assertFalse(empty($app->captcha->name)); + + $this->assertEquals('foo bar', $app->captcha->name); + + $this->assertTrue(isset($app->captcha->name)); + $this->assertFalse(empty($app->captcha->name)); + } } diff --git a/tests/framework/filters/HostControlTest.php b/tests/framework/filters/HostControlTest.php index 8097f0c..ed53d4d 100644 --- a/tests/framework/filters/HostControlTest.php +++ b/tests/framework/filters/HostControlTest.php @@ -131,4 +131,31 @@ class HostControlTest extends TestCase $this->assertFalse($filter->beforeAction($action)); $this->assertTrue($this->denyCallBackCalled, 'denyCallback should have been called.'); } -} \ No newline at end of file + + public function testDefaultHost() + { + $filter = new HostControl(); + $filter->allowedHosts = ['example.com']; + $filter->fallbackHostInfo = 'http://yiiframework.com'; + $filter->denyCallback = function() {}; + + $controller = new Controller('test', Yii::$app); + $action = new Action('test', $controller); + $filter->beforeAction($action); + + $this->assertSame('yiiframework.com', Yii::$app->getRequest()->getHostName()); + } + + public function testErrorHandlerWithDefaultHost() + { + $this->setExpectedException('yii\web\NotFoundHttpException', 'Page not found.'); + + $filter = new HostControl(); + $filter->allowedHosts = ['example.com']; + $filter->fallbackHostInfo = 'http://yiiframework.com'; + + $controller = new Controller('test', Yii::$app); + $action = new Action('test', $controller); + $filter->beforeAction($action); + } +} diff --git a/tests/framework/filters/PageCacheTest.php b/tests/framework/filters/PageCacheTest.php new file mode 100644 index 0000000..c23a797 --- /dev/null +++ b/tests/framework/filters/PageCacheTest.php @@ -0,0 +1,443 @@ + 'disabled', + 'properties' => [ + 'enabled' => false + ], + 'cacheable' => false, + ]], + [[ + 'name' => 'simple', + ]], + + // Cookies + [[ + 'name' => 'allCookies', + 'properties' => [ + 'cacheCookies' => true + ], + 'cookies' => [ + 'test-cookie-1' => true, + 'test-cookie-2' => true, + ] + ]], + [[ + 'name' => 'someCookies', + 'properties' => [ + 'cacheCookies' => ['test-cookie-2'] + ], + 'cookies' => [ + 'test-cookie-1' => false, + 'test-cookie-2' => true, + ] + ]], + [[ + 'name' => 'noCookies', + 'properties' => [ + 'cacheCookies' => false + ], + 'cookies' => [ + 'test-cookie-1' => false, + 'test-cookie-2' => false, + ] + ]], + + // Headers + [[ + 'name' => 'allHeaders', + 'properties' => [ + 'cacheHeaders' => true + ], + 'headers' => [ + 'test-header-1' => true, + 'test-header-2' => true, + ] + ]], + [[ + 'name' => 'someHeaders', + 'properties' => [ + 'cacheHeaders' => ['test-header-2'] + ], + 'headers' => [ + 'test-header-1' => false, + 'test-header-2' => true, + ] + ]], + [[ + 'name' => 'noHeaders', + 'properties' => [ + 'cacheHeaders' => false + ], + 'headers' => [ + 'test-header-1' => false, + 'test-header-2' => false, + ] + ]], + + // All together + [[ + 'name' => 'someCookiesSomeHeaders', + 'properties' => [ + 'cacheCookies' => ['test-cookie-2'], + 'cacheHeaders' => ['test-header-2'] + ], + 'cookies' => [ + 'test-cookie-1' => false, + 'test-cookie-2' => true, + ], + 'headers' => [ + 'test-header-1' => false, + 'test-header-2' => true, + ] + ]], + ]; + } + + /** + * @dataProvider cacheTestCaseProvider + */ + public function testCache($testCase) + { + $testCase = ArrayHelper::merge([ + 'properties' => [], + 'cacheable' => true, + ], $testCase); + if (isset(Yii::$app)) { + $this->destroyApplication(); + } + // Prepares the test response + $this->mockWebApplication(); + $controller = new Controller('test', Yii::$app); + $action = new Action('test', $controller); + $filter = new PageCache(array_merge([ + 'cache' => $cache = new ArrayCache(), + 'view' => new View() + ], $testCase['properties'])); + $this->assertTrue($filter->beforeAction($action), $testCase['name']); + // Cookies + $cookies = []; + if (isset($testCase['cookies'])) { + foreach (array_keys($testCase['cookies']) as $name) { + $value = Yii::$app->security->generateRandomString(); + Yii::$app->response->cookies->add(new Cookie([ + 'name' => $name, + 'value' => $value, + 'expire' => PHP_INT_MAX + ])); + $cookies[$name] = $value; + } + } + // Headers + $headers = []; + if (isset($testCase['headers'])) { + foreach (array_keys($testCase['headers']) as $name) { + $value = Yii::$app->security->generateRandomString(); + Yii::$app->response->headers->add($name, $value); + $headers[$name] = $value; + } + } + // Content + $static = Yii::$app->security->generateRandomString(); + Yii::$app->params['dynamic'] = $dynamic = Yii::$app->security->generateRandomString(); + $content = $filter->view->render('@yiiunit/data/views/pageCacheLayout.php', ['static' => $static]); + Yii::$app->response->content = $content; + ob_start(); + Yii::$app->response->send(); + ob_end_clean(); + // Metadata + $metadata = [ + 'format' => Yii::$app->response->format, + 'version' => Yii::$app->response->version, + 'statusCode' => Yii::$app->response->statusCode, + 'statusText' => Yii::$app->response->statusText, + ]; + if ($testCase['cacheable']) { + $this->assertNotEmpty($this->getInaccessibleProperty($filter->cache, '_cache'), $testCase['name']); + } else { + $this->assertEmpty($this->getInaccessibleProperty($filter->cache, '_cache'), $testCase['name']); + return; + } + + // Verifies the cached response + $this->destroyApplication(); + $this->mockWebApplication(); + $controller = new Controller('test', Yii::$app); + $action = new Action('test', $controller); + $filter = new PageCache(array_merge([ + 'cache' => $cache, + 'view' => new View() + ]), $testCase['properties']); + Yii::$app->params['dynamic'] = $dynamic = Yii::$app->security->generateRandomString(); + $this->assertFalse($filter->beforeAction($action), $testCase['name']); + // Content + $json = Json::decode(Yii::$app->response->content); + $this->assertSame($static, $json['static'], $testCase['name']); + $this->assertSame($dynamic, $json['dynamic'], $testCase['name']); + // Metadata + $this->assertSame($metadata['format'], Yii::$app->response->format, $testCase['name']); + $this->assertSame($metadata['version'], Yii::$app->response->version, $testCase['name']); + $this->assertSame($metadata['statusCode'], Yii::$app->response->statusCode, $testCase['name']); + $this->assertSame($metadata['statusText'], Yii::$app->response->statusText, $testCase['name']); + // Cookies + if (isset($testCase['cookies'])) { + foreach ($testCase['cookies'] as $name => $expected) { + $this->assertSame($expected, Yii::$app->response->cookies->has($name), $testCase['name']); + if ($expected) { + $this->assertSame($cookies[$name], Yii::$app->response->cookies->getValue($name), $testCase['name']); + } + } + } + // Headers + if (isset($testCase['headers'])) { + foreach ($testCase['headers'] as $name => $expected) { + $this->assertSame($expected, Yii::$app->response->headers->has($name), $testCase['name']); + if ($expected) { + $this->assertSame($headers[$name], Yii::$app->response->headers->get($name), $testCase['name']); + } + } + } + } + + public function testExpired() + { + CacheTestCase::$time = time(); + CacheTestCase::$microtime = microtime(true); + + // Prepares the test response + $this->mockWebApplication(); + $controller = new Controller('test', Yii::$app); + $action = new Action('test', $controller); + $filter = new PageCache([ + 'cache' => $cache = new ArrayCache(), + 'view' => new View(), + 'duration' => 1 + ]); + $this->assertTrue($filter->beforeAction($action)); + $static = Yii::$app->security->generateRandomString(); + Yii::$app->params['dynamic'] = $dynamic = Yii::$app->security->generateRandomString(); + $content = $filter->view->render('@yiiunit/data/views/pageCacheLayout.php', ['static' => $static]); + Yii::$app->response->content = $content; + ob_start(); + Yii::$app->response->send(); + ob_end_clean(); + + $this->assertNotEmpty($this->getInaccessibleProperty($filter->cache, '_cache')); + + // mock sleep(2); + CacheTestCase::$time += 2; + CacheTestCase::$microtime += 2; + + // Verifies the cached response + $this->destroyApplication(); + $this->mockWebApplication(); + $controller = new Controller('test', Yii::$app); + $action = new Action('test', $controller); + $filter = new PageCache([ + 'cache' => $cache, + 'view' => new View() + ]); + Yii::$app->params['dynamic'] = $dynamic = Yii::$app->security->generateRandomString(); + $this->assertTrue($filter->beforeAction($action)); + ob_start(); + Yii::$app->response->send(); + ob_end_clean(); + } + + public function testVaryByRoute() + { + $testCases = [ + false, + true, + ]; + + foreach ($testCases as $enabled) { + if (isset(Yii::$app)) { + $this->destroyApplication(); + } + // Prepares the test response + $this->mockWebApplication(); + $controller = new Controller('test', Yii::$app); + $action = new Action('test', $controller); + Yii::$app->requestedRoute = $action->uniqueId; + $filter = new PageCache([ + 'cache' => $cache = new ArrayCache(), + 'view' => new View(), + 'varyByRoute' => $enabled + ]); + $this->assertTrue($filter->beforeAction($action)); + $static = Yii::$app->security->generateRandomString(); + Yii::$app->params['dynamic'] = $dynamic = Yii::$app->security->generateRandomString(); + $content = $filter->view->render('@yiiunit/data/views/pageCacheLayout.php', ['static' => $static]); + Yii::$app->response->content = $content; + ob_start(); + Yii::$app->response->send(); + ob_end_clean(); + + $this->assertNotEmpty($this->getInaccessibleProperty($filter->cache, '_cache')); + + // Verifies the cached response + $this->destroyApplication(); + $this->mockWebApplication(); + $controller = new Controller('test', Yii::$app); + $action = new Action('test2', $controller); + Yii::$app->requestedRoute = $action->uniqueId; + $filter = new PageCache([ + 'cache' => $cache, + 'view' => new View(), + 'varyByRoute' => $enabled + ]); + Yii::$app->params['dynamic'] = $dynamic = Yii::$app->security->generateRandomString(); + $this->assertSame($enabled, $filter->beforeAction($action), $enabled); + ob_start(); + Yii::$app->response->send(); + ob_end_clean(); + } + } + + public function testVariations() + { + $testCases = [ + [true, 'name' => 'value'], + [false, 'name' => 'value2'], + ]; + + foreach ($testCases as $testCase) { + if (isset(Yii::$app)) { + $this->destroyApplication(); + } + $expected = array_shift($testCase); + // Prepares the test response + $this->mockWebApplication(); + $controller = new Controller('test', Yii::$app); + $action = new Action('test', $controller); + $originalVariations = $testCases[0]; + array_shift($originalVariations); + $filter = new PageCache([ + 'cache' => $cache = new ArrayCache(), + 'view' => new View(), + 'variations' => $originalVariations + ]); + $this->assertTrue($filter->beforeAction($action)); + $static = Yii::$app->security->generateRandomString(); + Yii::$app->params['dynamic'] = $dynamic = Yii::$app->security->generateRandomString(); + $content = $filter->view->render('@yiiunit/data/views/pageCacheLayout.php', ['static' => $static]); + Yii::$app->response->content = $content; + ob_start(); + Yii::$app->response->send(); + ob_end_clean(); + + $this->assertNotEmpty($this->getInaccessibleProperty($filter->cache, '_cache')); + + // Verifies the cached response + $this->destroyApplication(); + $this->mockWebApplication(); + $controller = new Controller('test', Yii::$app); + $action = new Action('test', $controller); + $filter = new PageCache([ + 'cache' => $cache, + 'view' => new View(), + 'variations' => $testCase + ]); + Yii::$app->params['dynamic'] = $dynamic = Yii::$app->security->generateRandomString(); + $this->assertNotSame($expected, $filter->beforeAction($action), $expected); + ob_start(); + Yii::$app->response->send(); + ob_end_clean(); + } + } + + public function testDependency() + { + $testCases = [ + false, + true, + ]; + + foreach ($testCases as $changed) { + if (isset(Yii::$app)) { + $this->destroyApplication(); + } + // Prepares the test response + $this->mockWebApplication(); + $controller = new Controller('test', Yii::$app); + $action = new Action('test', $controller); + $filter = new PageCache([ + 'cache' => $cache = new ArrayCache(), + 'view' => new View(), + 'dependency' => [ + 'class' => ExpressionDependency::className(), + 'expression' => 'Yii::$app->params[\'dependency\']', + ] + ]); + $this->assertTrue($filter->beforeAction($action)); + $static = Yii::$app->security->generateRandomString(); + Yii::$app->params['dynamic'] = $dynamic = Yii::$app->security->generateRandomString(); + Yii::$app->params['dependency'] = $dependency = Yii::$app->security->generateRandomString(); + $content = $filter->view->render('@yiiunit/data/views/pageCacheLayout.php', ['static' => $static]); + Yii::$app->response->content = $content; + ob_start(); + Yii::$app->response->send(); + ob_end_clean(); + + $this->assertNotEmpty($this->getInaccessibleProperty($filter->cache, '_cache')); + + // Verifies the cached response + $this->destroyApplication(); + $this->mockWebApplication(); + $controller = new Controller('test', Yii::$app); + $action = new Action('test', $controller); + $filter = new PageCache([ + 'cache' => $cache, + 'view' => new View(), + ]); + Yii::$app->params['dynamic'] = $dynamic = Yii::$app->security->generateRandomString(); + if ($changed) { + Yii::$app->params['dependency'] = Yii::$app->security->generateRandomString(); + } else { + Yii::$app->params['dependency'] = $dependency; + } + $this->assertSame($changed, $filter->beforeAction($action), $changed); + ob_start(); + Yii::$app->response->send(); + ob_end_clean(); + } + } +} diff --git a/tests/framework/grid/RadiobuttonColumnTest.php b/tests/framework/grid/RadiobuttonColumnTest.php new file mode 100644 index 0000000..c247bf4 --- /dev/null +++ b/tests/framework/grid/RadiobuttonColumnTest.php @@ -0,0 +1,105 @@ + null + ]); + } + + public function testOptionsByArray() + { + $column = new RadioButtonColumn([ + 'radioOptions' => [ + 'value' => 42 + ] + ]); + $this->assertEquals('', $column->renderDataCell([], 1, 0)); + } + + public function testOptionsByCallback() + { + $model = [ + 'label' => 'label', + 'value' => 123 + ]; + $column = new RadioButtonColumn([ + 'radioOptions' => function ($model) { + return [ + 'value' => $model['value'] + ]; + } + ]); + $actual = $column->renderDataCell($model, 1, 0); + $this->assertEquals('', $actual); + } + + public function testMultipleInGrid() + { + $this->mockApplication(); + Yii::setAlias('@webroot', '@yiiunit/runtime'); + Yii::setAlias('@web', 'http://localhost/'); + Yii::$app->assetManager->bundles['yii\web\JqueryAsset'] = false; + Yii::$app->set('request', new Request(['url' => '/abc'])); + + $models = [ + ['label' => 'label1', 'value' => 1], + ['label' => 'label2', 'value' => 2, 'checked' => true] + ]; + $grid = new GridView([ + 'dataProvider' => new ArrayDataProvider(['allModels' => $models]), + 'options' => ['id' => 'radio-gridview'], + 'columns' => [ + [ + 'class' => RadioButtonColumn::className(), + 'radioOptions' => function ($model) { + return [ + 'value' => $model['value'], + 'checked' => $model['value'] == 2 + ]; + } + ] + ] + ]); + ob_start(); + $grid->run(); + $actual = ob_get_clean(); + $this->assertEqualsWithoutLE(<<
Showing 1-2 of 2 items.
+ + + + + + +
 
+ +HTML + , $actual); + } +} diff --git a/tests/framework/helpers/FileHelperTest.php b/tests/framework/helpers/FileHelperTest.php index 333994b..90b3b6e 100644 --- a/tests/framework/helpers/FileHelperTest.php +++ b/tests/framework/helpers/FileHelperTest.php @@ -61,10 +61,11 @@ class FileHelperTest extends TestCase if ($handle = opendir($dirName)) { while (false !== ($entry = readdir($handle))) { if ($entry != '.' && $entry != '..') { - if (is_dir($dirName . DIRECTORY_SEPARATOR . $entry) === true) { - $this->removeDir($dirName . DIRECTORY_SEPARATOR . $entry); + $item = $dirName . DIRECTORY_SEPARATOR . $entry; + if (is_dir($item) === true && !is_link($item)) { + $this->removeDir($item); } else { - unlink($dirName . DIRECTORY_SEPARATOR . $entry); + unlink($item); } } } @@ -515,6 +516,58 @@ class FileHelperTest extends TestCase /** * @depends testFindFiles */ + public function testFindFilesRecursiveWithSymLink() + { + $dirName = 'test_dir'; + $this->createFileStructure([ + $dirName => [ + 'theDir' => [ + 'file1' => 'abc', + 'file2' => 'def', + ], + 'symDir' => ['symlink', 'theDir'], + ], + ]); + $dirName = $this->testFilePath . DIRECTORY_SEPARATOR . $dirName; + + $expected = [ + $dirName . DIRECTORY_SEPARATOR . 'symDir' . DIRECTORY_SEPARATOR . 'file1', + $dirName . DIRECTORY_SEPARATOR . 'symDir' . DIRECTORY_SEPARATOR . 'file2', + $dirName . DIRECTORY_SEPARATOR . 'theDir' . DIRECTORY_SEPARATOR . 'file1', + $dirName . DIRECTORY_SEPARATOR . 'theDir' . DIRECTORY_SEPARATOR . 'file2', + ]; + $result = FileHelper::findFiles($dirName); + sort($result); + $this->assertEquals($expected, $result); + } + + /** + * @depends testFindFiles + */ + public function testFindFilesNotRecursive() + { + $dirName = 'test_dir'; + $this->createFileStructure([ + $dirName => [ + 'theDir' => [ + 'file1' => 'abc', + 'file2' => 'def', + ], + 'symDir' => ['symlink', 'theDir'], + 'file3' => 'root' + ], + ]); + $dirName = $this->testFilePath . DIRECTORY_SEPARATOR . $dirName; + + $expected = [ + $dirName . DIRECTORY_SEPARATOR . 'file3', + ]; + $this->assertEquals($expected, FileHelper::findFiles($dirName, ['recursive' => false])); + } + + /** + * @depends testFindFiles + */ public function testFindFilesExclude() { $basePath = $this->testFilePath . DIRECTORY_SEPARATOR; diff --git a/tests/framework/helpers/UrlTest.php b/tests/framework/helpers/UrlTest.php index 391d894..1be2ffe 100644 --- a/tests/framework/helpers/UrlTest.php +++ b/tests/framework/helpers/UrlTest.php @@ -186,6 +186,13 @@ class UrlTest extends TestCase $this->assertEquals('https://example.com/#test', Url::to('@web5', 'https')); $this->assertEquals('//example.com/#test', Url::to('@web5', '')); + // @see https://github.com/yiisoft/yii2/issues/13156 + \Yii::setAlias('@cdn', '//cdn.example.com'); + $this->assertEquals('http://cdn.example.com/images/logo.gif', Url::to('@cdn/images/logo.gif', 'http')); + $this->assertEquals('//cdn.example.com/images/logo.gif', Url::to('@cdn/images/logo.gif', '')); + $this->assertEquals('https://cdn.example.com/images/logo.gif', Url::to('@cdn/images/logo.gif', 'https')); + \Yii::setAlias('@cdn', null); + //In case there is no controller, throw an exception $this->removeMockedAction(); diff --git a/tests/framework/rbac/ManagerTestCase.php b/tests/framework/rbac/ManagerTestCase.php index 4fc10d2..70c30b1 100644 --- a/tests/framework/rbac/ManagerTestCase.php +++ b/tests/framework/rbac/ManagerTestCase.php @@ -182,6 +182,16 @@ abstract class ManagerTestCase extends TestCase 'blablabla' => false, null => false, ], + 'guest' => [ + // all actions denied for guest (user not exists) + 'createPost' => false, + 'readPost' => false, + 'updatePost' => false, + 'deletePost' => false, + 'updateAnyPost' => false, + 'blablabla' => false, + null => false, + ], ]; $params = ['authorID' => 'author B']; diff --git a/tests/framework/rest/UrlRuleTest.php b/tests/framework/rest/UrlRuleTest.php index f3f9e6c..4129235 100644 --- a/tests/framework/rest/UrlRuleTest.php +++ b/tests/framework/rest/UrlRuleTest.php @@ -2,6 +2,7 @@ namespace yiiunit\framework\rest; +use Yii; use yii\web\UrlManager; use yii\rest\UrlRule; use yii\web\Request; @@ -194,4 +195,164 @@ class UrlRuleTest extends TestCase ]; } + + /** + * Proviedes test cases for createUrl() method + * + * - first param are properties of the UrlRule + * - second param is an array of test cases, containing two element arrays: + * - first element is the route to create + * - second element is the expected URL + */ + public function createUrlDataProvider() + { + return [ + // with pluralize + [ + [ // Rule properties + 'controller' => 'v1/channel', + 'pluralize' => true, + ], + [ // test cases: route, expected + [ ['v1/channel/index'], 'v1/channels' ], + [ ['v1/channel/index', 'offset' => 1], 'v1/channels?offset=1' ], + [ ['v1/channel/view', 'id' => 42], 'v1/channels/42' ], + [ ['v1/channel/options'], 'v1/channels' ], + [ ['v1/channel/options', 'id' => 42], 'v1/channels/42' ], + [ ['v1/channel/delete'], false ], + ], + ], + [ + [ // Rule properties + 'controller' => ['v1/channel'], + 'pluralize' => true, + ], + [ // test cases: route, expected + [ ['v1/channel/index'], 'v1/channels' ], + [ ['v1/channel/index', 'offset' => 1], 'v1/channels?offset=1' ], + [ ['v1/channel/view', 'id' => 42], 'v1/channels/42' ], + [ ['v1/channel/options'], 'v1/channels' ], + [ ['v1/channel/options', 'id' => 42], 'v1/channels/42' ], + [ ['v1/channel/delete'], false ], + ], + ], + [ + [ // Rule properties + 'controller' => ['v1/channel', 'v1/u' => 'v1/user'], + 'pluralize' => true, + ], + [ // test cases: route, expected + [ ['v1/channel/index'], 'v1/channels' ], + [ ['v1/channel/view', 'id' => 42], 'v1/channels/42' ], + [ ['v1/channel/options'], 'v1/channels' ], + [ ['v1/channel/options', 'id' => 42], 'v1/channels/42' ], + [ ['v1/channel/delete'], false ], + [ ['v1/user/index'], 'v1/u' ], + [ ['v1/user/view', 'id' => 1], 'v1/u/1' ], + [ ['v1/channel/options'], 'v1/channels' ], + [ ['v1/channel/options', 'id' => 42], 'v1/channels/42' ], + [ ['v1/user/delete'], false ], + ], + ], + + + // without pluralize + [ + [ // Rule properties + 'controller' => 'v1/channel', + 'pluralize' => false, + ], + [ // test cases: route, expected + [ ['v1/channel/index'], 'v1/channel' ], + [ ['v1/channel/index', 'offset' => 1], 'v1/channel?offset=1' ], + [ ['v1/channel/view', 'id' => 42], 'v1/channel/42' ], + [ ['v1/channel/options'], 'v1/channel' ], + [ ['v1/channel/options', 'id' => 42], 'v1/channel/42' ], + [ ['v1/channel/delete'], false ], + ], + ], + [ + [ // Rule properties + 'controller' => ['v1/channel'], + 'pluralize' => false, + ], + [ // test cases: route, expected + [ ['v1/channel/index'], 'v1/channel' ], + [ ['v1/channel/index', 'offset' => 1], 'v1/channel?offset=1' ], + [ ['v1/channel/view', 'id' => 42], 'v1/channel/42' ], + [ ['v1/channel/options'], 'v1/channel' ], + [ ['v1/channel/options', 'id' => 42], 'v1/channel/42' ], + [ ['v1/channel/delete'], false ], + ], + ], + [ + [ // Rule properties + 'controller' => ['v1/channel', 'v1/u' => 'v1/user'], + 'pluralize' => false, + ], + [ // test cases: route, expected + [ ['v1/channel/index'], 'v1/channel' ], + [ ['v1/channel/view', 'id' => 42], 'v1/channel/42' ], + [ ['v1/channel/options'], 'v1/channel' ], + [ ['v1/channel/options', 'id' => 42], 'v1/channel/42' ], + [ ['v1/channel/delete'], false ], + [ ['v1/user/index'], 'v1/u' ], + [ ['v1/user/view', 'id' => 1], 'v1/u/1' ], + [ ['v1/user/options'], 'v1/u' ], + [ ['v1/user/options', 'id' => 42], 'v1/u/42' ], + [ ['v1/user/delete'], false ], + ], + ], + + // using extra patterns + [ + [ // Rule properties + 'controller' => 'v1/channel', + 'pluralize' => true, + 'extraPatterns' => [ + '{id}/my' => 'my', + 'my' => 'my', + // this should not create a URL, no GET definition + 'POST {id}/my2' => 'my2', + ], + ], + [ // test cases: route, expected + // normal actions should behave as before + [ ['v1/channel/index'], 'v1/channels' ], + [ ['v1/channel/index', 'offset' => 1], 'v1/channels?offset=1' ], + [ ['v1/channel/view', 'id' => 42], 'v1/channels/42' ], + [ ['v1/channel/options'], 'v1/channels' ], + [ ['v1/channel/options', 'id' => 42], 'v1/channels/42' ], + [ ['v1/channel/delete'], false ], + + [ ['v1/channel/my'], 'v1/channels/my' ], + [ ['v1/channel/my', 'id' => 42], 'v1/channels/42/my' ], + [ ['v1/channel/my2'], false ], + [ ['v1/channel/my2', 'id' => 42], false ], + ], + ], + + ]; + } + + /** + * @dataProvider createUrlDataProvider + */ + public function testCreateUrl($rule, $tests) + { + foreach($tests as $test) { + list($params, $expected) = $test; + + $this->mockWebApplication(); + Yii::$app->set('request', new Request(['hostInfo' => 'http://api.example.com', 'scriptUrl' => '/index.php'])); + $route = array_shift($params); + + $manager = new UrlManager([ + 'cache' => null, + ]); + $rule = new UrlRule($rule); + $this->assertEquals($expected, $rule->createUrl($manager, $route, $params)); + } + } + } diff --git a/tests/framework/validators/IpValidatorTest.php b/tests/framework/validators/IpValidatorTest.php index 9471a33..04cd90e 100644 --- a/tests/framework/validators/IpValidatorTest.php +++ b/tests/framework/validators/IpValidatorTest.php @@ -61,7 +61,7 @@ class IpValidatorTest extends TestCase } public function provideBadIps() { - return [['not.an.ip'], [['what an array', '??']], [123456], [true], [false]]; + return [['not.an.ip'], [['what an array', '??']], [123456], [true], [false], ['bad:forSure']]; } /** @@ -74,6 +74,37 @@ class IpValidatorTest extends TestCase $this->assertFalse($validator->validate($badIp)); } + /** + * @dataProvider provideBadIps + */ + public function testValidateModelAttributeNotAnIP($badIp) + { + $validator = new IpValidator(); + $model = new FakedValidationModel(); + + $model->attr_ip = $badIp; + $validator->validateAttribute($model, 'attr_ip'); + $this->assertEquals('attr_ip must be a valid IP address.', $model->getFirstError('attr_ip')); + $model->clearErrors(); + + + $validator->ipv4 = false; + + $model->attr_ip = $badIp; + $validator->validateAttribute($model, 'attr_ip'); + $this->assertEquals('attr_ip must be a valid IP address.', $model->getFirstError('attr_ip')); + $model->clearErrors(); + + + $validator->ipv4 = true; + $validator->ipv6 = false; + + $model->attr_ip = $badIp; + $validator->validateAttribute($model, 'attr_ip'); + $this->assertEquals('attr_ip must be a valid IP address.', $model->getFirstError('attr_ip')); + $model->clearErrors(); + } + public function testValidateValueIPv4() { $validator = new IpValidator(); @@ -359,4 +390,4 @@ class IpValidatorTest extends TestCase $this->assertEquals('fa01::2/614', $model->attr_ip); $this->assertEquals('attr_ip contains wrong subnet mask.', $model->getFirstError('attr_ip')); } -} \ No newline at end of file +} diff --git a/tests/framework/validators/UniqueValidatorTest.php b/tests/framework/validators/UniqueValidatorTest.php index 9305118..02a3940 100644 --- a/tests/framework/validators/UniqueValidatorTest.php +++ b/tests/framework/validators/UniqueValidatorTest.php @@ -303,4 +303,61 @@ abstract class UniqueValidatorTest extends DatabaseTestCase $val->validateAttribute($m, 'ref'); $this->assertTrue($m->hasErrors('ref')); } -} \ No newline at end of file + + public function testPrepareParams() + { + $model = new FakedValidationModel(); + $model->val_attr_a = 'test value a'; + $model->val_attr_b = 'test value b'; + $model->val_attr_c = 'test value c'; + $attribute = 'val_attr_a'; + + $targetAttribute = 'val_attr_b'; + $result = $this->invokeMethod(new UniqueValidator(), 'prepareConditions', [$targetAttribute, $model, $attribute]); + $expected = ['val_attr_b' => 'test value a']; + $this->assertEquals($expected, $result); + + $targetAttribute = ['val_attr_b', 'val_attr_c']; + $result = $this->invokeMethod(new UniqueValidator(), 'prepareConditions', [$targetAttribute, $model, $attribute]); + $expected = ['val_attr_b' => 'test value b', 'val_attr_c' => 'test value c']; + $this->assertEquals($expected, $result); + + $targetAttribute = ['val_attr_a' => 'val_attr_b']; + $result = $this->invokeMethod(new UniqueValidator(), 'prepareConditions', [$targetAttribute, $model, $attribute]); + $expected = ['val_attr_b' => 'test value a']; + $this->assertEquals($expected, $result); + + + $targetAttribute = ['val_attr_b', 'val_attr_a' => 'val_attr_c']; + $result = $this->invokeMethod(new UniqueValidator(), 'prepareConditions', [$targetAttribute, $model, $attribute]); + $expected = ['val_attr_b' => 'test value b', 'val_attr_c' => 'test value a']; + $this->assertEquals($expected, $result); + } + + public function testPrepareQuery() + { + $schema = $this->getConnection()->schema; + + $model = new ValidatorTestMainModel(); + $query = $this->invokeMethod(new UniqueValidator(), 'prepareQuery', [$model,['val_attr_b' => 'test value a']]); + $expected = "SELECT * FROM {$schema->quoteTableName('validator_main')} WHERE {$schema->quoteColumnName('val_attr_b')}=:qp0"; + $this->assertEquals($expected, $query->createCommand()->getSql()); + + $params = ['val_attr_b' => 'test value b', 'val_attr_c' => 'test value a']; + $query = $this->invokeMethod(new UniqueValidator(), 'prepareQuery', [$model, $params]); + $expected = "SELECT * FROM {$schema->quoteTableName('validator_main')} WHERE ({$schema->quoteColumnName('val_attr_b')}=:qp0) AND ({$schema->quoteColumnName('val_attr_c')}=:qp1)"; + $this->assertEquals($expected, $query->createCommand()->getSql()); + + $params = ['val_attr_b' => 'test value b']; + $query = $this->invokeMethod(new UniqueValidator(['filter' => 'val_attr_a > 0']), 'prepareQuery', [$model, $params]); + $expected = "SELECT * FROM {$schema->quoteTableName('validator_main')} WHERE ({$schema->quoteColumnName('val_attr_b')}=:qp0) AND (val_attr_a > 0)"; + $this->assertEquals($expected, $query->createCommand()->getSql()); + + $params = ['val_attr_b' => 'test value b']; + $query = $this->invokeMethod(new UniqueValidator(['filter' => function($query) { + $query->orWhere('val_attr_a > 0'); + }]), 'prepareQuery', [$model, $params]); + $expected = "SELECT * FROM {$schema->quoteTableName('validator_main')} WHERE ({$schema->quoteColumnName('val_attr_b')}=:qp0) OR (val_attr_a > 0)"; + $this->assertEquals($expected, $query->createCommand()->getSql()); + } +} diff --git a/tests/framework/validators/ValidatorTest.php b/tests/framework/validators/ValidatorTest.php index f2c1721..2a7d9ff 100644 --- a/tests/framework/validators/ValidatorTest.php +++ b/tests/framework/validators/ValidatorTest.php @@ -176,12 +176,39 @@ class ValidatorTest extends TestCase $val->validate('abc'); } + public function testValidateAttribute() + { + // Access to validator in inline validation (https://github.com/yiisoft/yii2/issues/6242) + + $model = new FakedValidationModel(); + $val = TestValidator::createValidator('inlineVal', $model, ['val_attr_a'], ['params' => ['foo' => 'bar']]); + $val->validateAttribute($model, 'val_attr_a'); + $args = $model->getInlineValArgs(); + + $this->assertCount(3, $args); + $this->assertEquals('val_attr_a', $args[0]); + $this->assertEquals(['foo' => 'bar'], $args[1]); + $this->assertInstanceOf(InlineValidator::className(), $args[2]); + } + public function testClientValidateAttribute() { $val = new TestValidator(); $this->assertNull( $val->clientValidateAttribute($this->getTestModel(), 'attr_runMe1', []) ); //todo pass a view instead of array + + // Access to validator in inline validation (https://github.com/yiisoft/yii2/issues/6242) + + $model = new FakedValidationModel(); + $val = TestValidator::createValidator('inlineVal', $model, ['val_attr_a'], ['params' => ['foo' => 'bar']]); + $val->clientValidate = 'clientInlineVal'; + $args = $val->clientValidateAttribute($model, 'val_attr_a', null); + + $this->assertCount(3, $args); + $this->assertEquals('val_attr_a', $args[0]); + $this->assertEquals(['foo' => 'bar'], $args[1]); + $this->assertInstanceOf(InlineValidator::className(), $args[2]); } public function testIsActive() diff --git a/tests/framework/web/ResponseTest.php b/tests/framework/web/ResponseTest.php index 8a9c22d..26b0221 100644 --- a/tests/framework/web/ResponseTest.php +++ b/tests/framework/web/ResponseTest.php @@ -71,7 +71,7 @@ class ResponseTest extends \yiiunit\TestCase */ public function testSendFileWrongRanges($rangeHeader) { - $this->setExpectedException('yii\web\HttpException'); + $this->setExpectedException('yii\web\RangeNotSatisfiableHttpException'); $dataFile = \Yii::getAlias('@yiiunit/data/web/data.txt'); $_SERVER['HTTP_RANGE'] = 'bytes=' . $rangeHeader; diff --git a/tests/framework/widgets/ActiveFieldTest.php b/tests/framework/widgets/ActiveFieldTest.php index b8199ae..49d4873 100644 --- a/tests/framework/widgets/ActiveFieldTest.php +++ b/tests/framework/widgets/ActiveFieldTest.php @@ -444,6 +444,67 @@ EOD; ], $actualValue); } + public function testAriaAttributes() + { + $this->activeField->addAriaAttributes = true; + + $expectedValue = << + + +
Hint for attributeName attribute
+
+ +EOD; + + $actualValue = $this->activeField->render(); + $this->assertEqualsWithoutLE($expectedValue, $actualValue); + } + + public function testAriaRequiredAttribute() + { + $this->activeField->addAriaAttributes = true; + $this->helperModel->addRule([$this->attributeName], 'required'); + + $expectedValue = << + + +
Hint for attributeName attribute
+
+ +EOD; + + $actualValue = $this->activeField->render(); + $this->assertEqualsWithoutLE($expectedValue, $actualValue); + } + + public function testAriaInvalidAttribute() + { + $this->activeField->addAriaAttributes = true; + $this->helperModel->addError($this->attributeName, 'Some error'); + + $expectedValue = << + + +
Hint for attributeName attribute
+
Some error
+ +EOD; + + $actualValue = $this->activeField->render(); + $this->assertEqualsWithoutLE($expectedValue, $actualValue); + } + + public function testEmptyTag() + { + $this->activeField->options = ['tag' => false]; + $expectedValue = ''; + $actualValue = $this->activeField->hiddenInput()->label(false)->error(false)->hint(false)->render(); + $this->assertEqualsWithoutLE($expectedValue, trim($actualValue)); + } + /** * Helper methods */ diff --git a/tests/framework/widgets/ListViewTest.php b/tests/framework/widgets/ListViewTest.php index 448980b..5cbd727 100644 --- a/tests/framework/widgets/ListViewTest.php +++ b/tests/framework/widgets/ListViewTest.php @@ -7,13 +7,40 @@ use yii\data\ArrayDataProvider; use yii\widgets\ListView; use yiiunit\TestCase; -class ListViewTest extends TestCase +/** + * @group widgets + */ +class ListViewTest extends \yiiunit\TestCase { protected function setUp() { parent::setUp(); + $this->mockApplication(); + } + + public function testEmptyListShown() + { + $this->getListView([ + 'dataProvider' => new ArrayDataProvider(['allModels' => []]), + 'emptyText' => "Nothing at all", + ])->run(); - $this->mockWebApplication(); + $this->expectOutputString('
Nothing at all
'); + } + + public function testEmptyListNotShown() + { + $this->getListView([ + 'dataProvider' => new ArrayDataProvider(['allModels' => []]), + 'showOnEmpty' => true, + ])->run(); + + $this->expectOutputString(<< + + +HTML + ); } private function getListView($options = []) @@ -37,7 +64,7 @@ class ListViewTest extends TestCase public function testSimplyListView() { - $listView = $this->getListView(); + $this->getListView()->run(); $this->expectOutputString(<<
Showing 1-3 of 3 items.
@@ -47,13 +74,11 @@ class ListViewTest extends TestCase HTML ); - - $listView->run(); } public function testWidgetOptions() { - $listView = $this->getListView(['options' => ['class' => 'test-passed'], 'separator' => '']); + $this->getListView(['options' => ['class' => 'test-passed'], 'separator' => ''])->run(); $this->expectOutputString(<<
Showing 1-3 of 3 items.
@@ -61,8 +86,6 @@ HTML HTML ); - - $listView->run(); } public function itemViewOptions() @@ -84,7 +107,7 @@ HTML
Item #0: silverfire - Widget: yii\widgets\ListView
Item #1: samdark - Widget: yii\widgets\ListView
Item #2: cebe - Widget: yii\widgets\ListView
-' +', ], [ '@yiiunit/data/views/widgets/ListView/item', @@ -92,8 +115,8 @@ HTML
Item #0: silverfire - Widget: yii\widgets\ListView
Item #1: samdark - Widget: yii\widgets\ListView
Item #2: cebe - Widget: yii\widgets\ListView
-' - ] +', + ], ]; } @@ -102,9 +125,8 @@ HTML */ public function testItemViewOptions($itemView, $expected) { - $listView = $this->getListView(['itemView' => $itemView]); + $this->getListView(['itemView' => $itemView])->run(); $this->expectOutputString($expected); - $listView->run(); } public function itemOptions() @@ -126,8 +148,8 @@ HTML 'test' => 'passed', 'key' => $key, 'index' => $index, - 'id' => $model['id'] - ] + 'id' => $model['id'], + ], ]; }, @@ -135,8 +157,8 @@ HTML 0 1 2 -' - ] +', + ], ]; } @@ -145,8 +167,7 @@ HTML */ public function testItemOptions($itemOptions, $expected) { - $listView = $this->getListView(['itemOptions' => $itemOptions]); + $this->getListView(['itemOptions' => $itemOptions])->run(); $this->expectOutputString($expected); - $listView->run(); } } diff --git a/tests/framework/widgets/PjaxTest.php b/tests/framework/widgets/PjaxTest.php new file mode 100644 index 0000000..2018e9d --- /dev/null +++ b/tests/framework/widgets/PjaxTest.php @@ -0,0 +1,37 @@ + new ArrayDataProvider()]); + ob_start(); + $pjax1 = new Pjax(); + ob_end_clean(); + $nonPjaxWidget2 = new ListView(['dataProvider' => new ArrayDataProvider()]); + ob_start(); + $pjax2 = new Pjax(); + ob_end_clean(); + + $this->assertEquals('w0', $nonPjaxWidget1->options['id']); + $this->assertEquals('w1', $nonPjaxWidget2->options['id']); + $this->assertEquals('p0', $pjax1->options['id']); + $this->assertEquals('p1', $pjax2->options['id']); + } + + protected function setUp() + { + parent::setUp(); + $this->mockWebApplication(); + } + +} diff --git a/tests/js/tests/yii.captcha.test.js b/tests/js/tests/yii.captcha.test.js new file mode 100644 index 0000000..0ac51c2 --- /dev/null +++ b/tests/js/tests/yii.captcha.test.js @@ -0,0 +1,151 @@ +var assert = require('chai').assert; +var sinon; +var withData = require('leche').withData; +var jsdom = require('mocha-jsdom'); + +var fs = require('fs'); +var vm = require('vm'); + +describe('yii.captcha', function () { + var yiiCaptchaPath = 'framework/assets/yii.captcha.js'; + var jQueryPath = 'vendor/bower/jquery/dist/jquery.js'; + var $; + var $captcha; + var settings = { + refreshUrl: '/site/captcha?refresh=1', + hashKey: 'yiiCaptcha/site/captcha' + }; + + function registerTestableCode() { + var code = fs.readFileSync(yiiCaptchaPath); + var script = new vm.Script(code); + var context = new vm.createContext({window: window}); + + script.runInContext(context); + } + + var imgHtml = '' + + ''; + var html = '' + imgHtml + ''; + + jsdom({ + html: html, + src: fs.readFileSync(jQueryPath, 'utf-8') + }); + + before(function () { + $ = window.$; + registerTestableCode(); + sinon = require('sinon'); + }); + + afterEach(function () { + if ($captcha.length) { + $captcha.yiiCaptcha('destroy'); + } + }); + + describe('init', function () { + var customSettings = { + refreshUrl: '/posts/captcha?refresh=1', + hashKey: 'yiiCaptcha/posts/captcha' + }; + + withData({ + 'no method specified': [function () { + $captcha = $('.captcha').yiiCaptcha(settings); + }, settings], + 'no method specified, custom options': [function () { + $captcha = $('.captcha').yiiCaptcha(customSettings); + }, customSettings], + 'manual method call': [function () { + $captcha = $('.captcha').yiiCaptcha('init', settings); + }, settings] + }, function (initFunction, expectedSettings) { + it('should save settings for all elements', function () { + initFunction(); + assert.deepEqual($('#captcha').data('yiiCaptcha'), {settings: expectedSettings}); + assert.deepEqual($('#captcha-2').data('yiiCaptcha'), {settings: expectedSettings}); + }); + }); + }); + + describe('refresh', function () { + var server; + var response = {hash1: 747, hash2: 748, url: '/site/captcha?v=584696959e038'}; + + beforeEach(function () { + server = sinon.fakeServer.create(); + window.XMLHttpRequest = global.XMLHttpRequest; + }); + + afterEach(function () { + server.restore(); + }); + + withData({ + 'click on the captcha': [function () { + $captcha.trigger('click'); + }], + 'manual method call': [function () { + $captcha.yiiCaptcha('refresh'); + }] + }, function (refreshFunction) { + it('should send ajax request, update the image and data for client-side validation', function () { + $captcha = $('#captcha').yiiCaptcha(settings); + refreshFunction(); + server.requests[0].respond(200, {"Content-Type": "application/json"}, JSON.stringify(response)); + + assert.lengthOf(server.requests, 1); + assert.include(server.requests[0].url, settings.refreshUrl + '&_='); + assert.include(server.requests[0].requestHeaders.Accept, 'application/json'); + assert.equal($captcha.attr('src'), response.url); + assert.deepEqual($('body').data(settings.hashKey), [response.hash1, response.hash2]); + }); + }); + }); + + describe('destroy method', function () { + var ajaxStub; + + before(function () { + ajaxStub = sinon.stub($, 'ajax'); + }); + + after(function () { + ajaxStub.restore(); + }); + + var message = 'should remove event handlers with saved settings for destroyed element only and return ' + + 'initial jQuery object'; + it(message, function () { + $captcha = $('.captcha').yiiCaptcha(settings); + var $captcha1 = $('#captcha'); + var $captcha2 = $('#captcha-2'); + var destroyResult = $captcha1.yiiCaptcha('destroy'); + $captcha1.trigger('click'); + $captcha2.trigger('click'); + + assert.strictEqual(destroyResult, $captcha1); + assert.isTrue(ajaxStub.calledOnce); + assert.isUndefined($captcha1.data('yiiCaptcha')); + assert.deepEqual($captcha2.data('yiiCaptcha'), {settings: settings}); + }); + }); + + describe('data method', function () { + it('should return saved settings', function () { + $captcha = $('#captcha').yiiCaptcha(settings); + assert.deepEqual($captcha.yiiCaptcha('data'), {settings: settings}); + }); + }); + + describe('call of not existing method', function () { + it('should throw according error', function () { + $captcha = $('#captcha').yiiCaptcha(settings); + assert.throws(function () { + $captcha.yiiCaptcha('foobar'); + }, 'Method foobar does not exist in jQuery.yiiCaptcha'); + }); + }); +}); diff --git a/tests/js/tests/yii.validation.test.js b/tests/js/tests/yii.validation.test.js new file mode 100644 index 0000000..dba4e3d --- /dev/null +++ b/tests/js/tests/yii.validation.test.js @@ -0,0 +1,1657 @@ +var assert = require('chai').assert; + +assert.isDeferred = function (object) { + if (typeof object.resolve !== 'function') { + return false; + } + + return String(object.resolve) === String($.Deferred().resolve); +}; + +var sinon; +var withData = require('leche').withData; + +var StringUtils = { + repeatString: function (value, times) { + return (new Array(times + 1)).join(value); + } +}; + +var jsdom = require('mocha-jsdom'); +var punycode = require('../../../vendor/bower/punycode/punycode'); + +var fs = require('fs'); +var vm = require('vm'); +var yii; + +describe('yii.validation', function () { + var VALIDATOR_SUCCESS_MESSAGE = 'should leave messages as is'; + var VALIDATOR_ERROR_MESSAGE = 'should add appropriate errors(s) to messages'; + + function getValidatorMessage(expectedResult) { + var isTrueBoolean = typeof expectedResult === 'boolean' && expectedResult === true; + var isEmptyArray = Array.isArray(expectedResult) && expectedResult.length === 0; + + return isTrueBoolean || isEmptyArray ? VALIDATOR_SUCCESS_MESSAGE : VALIDATOR_ERROR_MESSAGE; + } + + var $; + var code; + var script; + + function FileReader() { + this.readAsDataURL = function() { + }; + } + + function Image() { + } + + function registerTestableCode(customSandbox) { + if (customSandbox === undefined) { + customSandbox = { + File: {}, + FileReader: FileReader, + Image: Image, + punycode: punycode + }; + } + + var path = 'framework/assets/yii.validation.js'; + + if (code === undefined) { + code = fs.readFileSync(path); + } + + if (script === undefined) { + script = new vm.Script(code); + } + + var defaultSandbox = {yii: {}, jQuery: $}; + var sandbox = $.extend({}, defaultSandbox, customSandbox); + var context = new vm.createContext(sandbox); + + script.runInContext(context); + yii = sandbox.yii; + } + + jsdom({src: fs.readFileSync('vendor/bower/jquery/dist/jquery.js', 'utf-8')}); + + before(function () { + $ = window.$; + registerTestableCode(); + sinon = require('sinon'); + }); + + it('should exist', function () { + assert.isObject(yii.validation); + }); + + describe('isEmpty method', function () { + withData({ + 'undefined': [undefined, true], + 'null': [null, true], + 'empty array': [[], true], + 'empty string': ['', true], + 'string containing whitespace': [' ', false], + 'empty object': [{}, false], + 'non-zero integer': [1, false], + 'non-empty string': ['a', false], + 'non-empty array': [[1], false] + }, function (value, expectedValue) { + var message = expectedValue ? 'should return "true"' : 'should return "false"'; + it(message, function () { + assert.strictEqual(yii.validation.isEmpty(value), expectedValue); + }); + }); + }); + + describe('addMessage method', function () { + withData({ + 'empty messages': [[], 'Message', 1, ['Message']], + 'non-empty messages': [['Message 1'], 'Message 2', 1, ['Message 1', 'Message 2']], + 'message as template': [[], 'Message with value {value}', 1, ['Message with value 1']] + }, function (messages, message, value, expectedMessages) { + it('should extend messages and replace value in template', function () { + yii.validation.addMessage(messages, message, value); + assert.deepEqual(messages, expectedMessages); + }); + }); + }); + + describe('required validator', function () { + withData({ + 'empty string': ['', {}, false], + 'empty string, strict mode': ['', {strict: true}, true], + 'string containing whitespace': [' ', {}, false], + 'string containing whitespace, strict mode': [' ', {strict: true}, true], + 'non-empty string': ['a', {}, true], + 'undefined': [undefined, {}, false], + 'undefined, strict mode': [undefined, {strict: true}, false], + // requiredValue + 'integer and required value set to different integer': [1, {requiredValue: 2}, false], + 'string and required value set to integer with the same value': ['1', {requiredValue: 1}, true], + 'string and required value set to integer with the same value, strict mode': [ + '1', + {requiredValue: 1, strict: true}, + false + ], + 'integer and required value set to same integer, strict mode': [ + 1, + {requiredValue: 1, strict: true}, + true + ] + }, function (value, options, expectValid) { + it(getValidatorMessage(expectValid), function () { + options.message = 'This field is required.'; + var messages = []; + var expectedMessages = expectValid ? [] : ['This field is required.']; + + yii.validation.required(value, messages, options); + assert.deepEqual(messages, expectedMessages); + }); + }); + }); + + describe('boolean validator', function () { + var defaultOptions = { + message: 'The value must have a boolean type.', + trueValue: '1', + falseValue: '0' + }; + + withData({ + 'empty string': ['', {}, false], + 'empty string, skip on empty': ['', {skipOnEmpty: true}, true], + 'non-empty string, does not equal neither trueValue no falseValue': ['a', {}, false], + 'integer, value equals falseValue': [0, {}, true], + 'integer, value equals trueValue': [1, {}, true], + 'string equals falseValue': ['0', {}, true], + 'string equals trueValue': ['1', {}, true], + 'integer, value equals falseValue, strict mode': [0, {strict: true}, false], + 'integer, value equals trueValue, strict mode': [1, {strict: true}, false], + // trueValue, falseValue + 'string equals custom trueValue, custom trueValue is set': ['yes', {trueValue: 'yes'}, true], + 'string does not equal neither trueValue no falseValue, custom trueValue is set': [ + 'no', + {trueValue: 'yes'}, + false + ], + 'string equals custom falseValue, custom falseValue is set': ['no', {falseValue: 'no'}, true], + 'string does not equal neither trueValue no falseValue, custom falseValue is set': [ + 'yes', + {falseValue: 'no'}, + false + ], + 'string equals custom trueValue, custom trueValue and falseValue are set': [ + 'yes', + {trueValue: 'yes', falseValue: 'no'}, + true + ], + 'string equals custom falseValue, custom trueValue and falseValue are set': [ + 'no', + {trueValue: 'yes', falseValue: 'no'}, + true + ], + 'string does not equal neither custom trueValue no falseValue, custom trueValue and falseValue are set': [ + 'a', + {trueValue: 'yes', falseValue: 'no'}, + false + ] + }, function (value, customOptions, expectValid) { + it(getValidatorMessage(expectValid), function () { + var options = $.extend({}, defaultOptions, customOptions); + var messages = []; + var expectedMessages = expectValid ? [] : ['The value must have a boolean type.']; + + yii.validation.boolean(value, messages, options); + assert.deepEqual(messages, expectedMessages); + }); + }); + }); + + describe('string validator', function () { + var defaultOptions = { + message: 'Invalid type.', + tooShort: 'Too short.', + tooLong: 'Too long.', + notEqual: 'Not equal.' + }; + + withData({ + 'empty string': ['', {}, []], + 'empty string, skip on empty': ['', {skipOnEmpty: true}, []], + 'non-empty string': ['a', {}, []], + 'integer': [1, {}, ['Invalid type.']], + // min + 'string less than min': ['Word', {min: 5}, ['Too short.']], + 'string more than min': ['Some string', {min: 5}, []], + 'string equals min': ['Equal', {min: 5}, []], + // max + 'string less than max': ['Word', {max: 5}, []], + 'string more than max': ['Some string', {max: 5}, ['Too long.']], + 'string equals max': ['Equal', {max: 5}, []], + // is + 'string equals exact length': ['Equal', {is: 5}, []], + 'string does not equal exact length': ['Does not equal', {is: 5}, ['Not equal.']], + 'string does not equal exact length and less than min': ['Word', {is: 5, min: 5}, ['Not equal.']], + // min and max + 'string less than min, both min and max are set': ['Word', {min: 5, max: 10}, ['Too short.']], + 'string in between of min and max, both min and max are set': ['Between', {min: 5, max: 10}, []], + 'string more than max, both min and max are set': ['Some string', {min: 5, max: 10}, ['Too long.']] + }, function (value, customOptions, expectedMessages) { + it(getValidatorMessage(expectedMessages), function () { + var options = $.extend({}, defaultOptions, customOptions); + var messages = []; + + yii.validation.string(value, messages, options); + assert.deepEqual(messages, expectedMessages); + }); + }); + }); + + describe('file validator', function () { + var defaultOptions = { + message: 'Unable to upload a file.', + uploadRequired: 'Upload is required.', + tooMany: 'Too many files.', + wrongExtension: 'File {file} has wrong extension.', + wrongMimeType: 'File {file} has wrong mime type.', + tooSmall: 'File {file} is too small.', + tooBig: 'File {file} is too big.' + }; + var attribute = { + input: '#input-id', + $form: 'jQuery form object' + }; + var files; + var filesService = { + getFiles: function () { + return files; + } + }; + var $input = { + get: function (value) { + return value === 0 ? {files: filesService.getFiles()} : undefined; + } + }; + var jQueryInitStub; + var inputGetSpy; + var filesServiceSpy; + + beforeEach(function () { + jQueryInitStub = sinon.stub($.fn, 'init'); + jQueryInitStub.withArgs(attribute.input, attribute.$form).returns($input); + inputGetSpy = sinon.spy($input, 'get'); + filesServiceSpy = sinon.spy(filesService, 'getFiles'); + }); + + afterEach(function () { + jQueryInitStub.restore(); + inputGetSpy.restore(); + filesServiceSpy.restore(); + }); + + describe('with File API is not available', function () { + beforeEach(function () { + registerTestableCode({File: undefined}); + }); + + afterEach(function () { + registerTestableCode(); + }); + + it(VALIDATOR_SUCCESS_MESSAGE, function () { + var messages = []; + + yii.validation.file(attribute, messages, defaultOptions); + assert.deepEqual(messages, []); + + assert.isFalse(jQueryInitStub.called); + assert.isFalse(inputGetSpy.called); + assert.isFalse(filesServiceSpy.called); + }); + }); + + describe('with File API is available', function () { + withData({ + 'files are not available': [undefined, {}, ['Unable to upload a file.']], + 'no files': [[], {}, ['Upload is required.']], + 'no files, skip on empty': [[], {skipOnEmpty: true}, []], + // maxFiles + 'number of files less than maximum': [ + {name: 'file.jpg', type: 'image/jpeg', size: 100 * 1024}, + {maxFiles: 2}, + [] + ], + 'number of files equals maximum': [ + [ + {name: 'file.jpg', type: 'image/jpeg', size: 100 * 1024}, + {name: 'file.png', type: 'image/png', size: 150 * 1024} + ], + {maxFiles: 2}, + [] + ], + 'number of files more than maximum': [ + [ + {name: 'file.jpg', type: 'image/jpeg', size: 100 * 1024}, + {name: 'file.png', type: 'image/png', size: 150 * 1024}, + {name: 'file.bmp', type: 'image/bmp', size: 200 * 1024} + ], + {maxFiles: 2}, + ['Too many files.'] + ], + // extensions + 'files in extensions list': [ + [ + {name: 'file.jpg', type: 'image/jpeg', size: 100 * 1024}, + {name: 'file.png', type: 'image/png', size: 150 * 1024} + ], + {extensions: ['jpg', 'png']}, + [] + ], + 'file not in extensions list': [ + [ + {name: 'file.jpg', type: 'image/jpeg', size: 100 * 1024}, + {name: 'file.bmp', type: 'image/bmp', size: 150 * 1024} + ], + {extensions: ['jpg', 'png']}, + ['File file.bmp has wrong extension.'] + ], + // mimeTypes + 'mime type in mime types list': [ + [ + {name: 'file.jpg', type: 'image/jpeg', size: 100 * 1024}, + {name: 'file.png', type: 'image/png', size: 150 * 1024} + ], + {mimeTypes: ['image/jpeg', 'image/png']}, + [] + ], + 'mime type not in mime types list': [ + [ + {name: 'file.jpg', type: 'image/jpeg', size: 100 * 1024}, + {name: 'file.bmp', type: 'image/bmp', size: 150 * 1024} + ], + {mimeTypes: ['image/jpeg', 'image/png']}, + ['File file.bmp has wrong mime type.'] + ], + // maxSize + 'size less than maximum size': [ + [ + {name: 'file.jpg', type: 'image/jpeg', size: 100 * 1024}, + {name: 'file.png', type: 'image/png', size: 150 * 1024} + ], + {maxSize: 200 * 1024}, + [] + ], + 'size equals maximum size': [ + [ + {name: 'file.jpg', type: 'image/jpeg', size: 100 * 1024}, + {name: 'file.png', type: 'image/png', size: 100 * 1024} + ], + {maxSize: 100 * 1024}, + [] + ], + 'size more than maximum size': [ + [ + {name: 'file.jpg', type: 'image/jpeg', size: 100 * 1024}, + {name: 'file.png', type: 'image/png', size: 150 * 1024} + ], + {maxSize: 50 * 1024}, + ['File file.jpg is too big.', 'File file.png is too big.'] + ], + // minSize + 'size less than minimum size': [ + [ + {name: 'file.jpg', type: 'image/jpeg', size: 100 * 1024}, + {name: 'file.png', type: 'image/png', size: 150 * 1024} + ], + {minSize: 120 * 1024}, + ['File file.jpg is too small.'] + ], + 'size equals minimum size': [ + [ + {name: 'file.jpg', type: 'image/bmp', size: 100 * 1024}, + {name: 'file.png', type: 'image/png', size: 100 * 1024} + ], + {maxSize: 100 * 1024}, + [] + ], + 'size more than minimum size': [ + [ + {name: 'file.jpg', type: 'image/jpeg', size: 100 * 1024}, + {name: 'file.bmp', type: 'image/bmp', size: 150 * 1024} + ], + {minSize: 80 * 1024}, + [] + ], + 'one file is less than minimum size, one file is more than maximum size': [ + [ + {name: 'file.jpg', type: 'image/jpeg', size: 100 * 1024}, + {name: 'file.png', type: 'image/png', size: 250 * 1024} + ], + {minSize: 150 * 1024, maxSize: 200 * 1024}, + ['File file.jpg is too small.', 'File file.png is too big.'] + ] + }, function (uploadedFiles, customOptions, expectedMessages) { + it(getValidatorMessage(expectedMessages), function () { + files = uploadedFiles; + var options = $.extend({}, defaultOptions, customOptions); + var messages = []; + + yii.validation.file(attribute, messages, options); + assert.deepEqual(messages, expectedMessages); + + assert.isTrue(jQueryInitStub.calledOnce); + assert.deepEqual(jQueryInitStub.getCall(0).args, [attribute.input, attribute.$form]); + assert.isTrue(inputGetSpy.calledOnce); + assert.deepEqual(inputGetSpy.getCall(0).args, [0]); + assert.isTrue(filesServiceSpy.calledOnce); + }); + }); + }); + }); + + describe('image validator', function () { + var attribute = { + input: '#input-id', + $form: 'jQuery form object' + }; + var files; + var filesService = { + getFiles: function () { + return files; + } + }; + var $input = { + get: function (value) { + return value === 0 ? {files: filesService.getFiles()} : undefined; + } + }; + var deferred; + var jQueryInitStub; + var inputGetSpy; + var filesServiceSpy; + var validateImageStub; + var deferredStub; + + beforeEach(function () { + jQueryInitStub = sinon.stub($.fn, 'init'); + jQueryInitStub.withArgs(attribute.input, attribute.$form).returns($input); + inputGetSpy = sinon.spy($input, 'get'); + filesServiceSpy = sinon.spy(filesService, 'getFiles'); + validateImageStub = sinon.stub(yii.validation, 'validateImage'); + deferred = $.Deferred(); + deferredStub = sinon.stub(deferred, 'resolve'); + }); + + afterEach(function () { + jQueryInitStub.restore(); + inputGetSpy.restore(); + filesServiceSpy.restore(); + validateImageStub.restore(); + deferredStub.restore(); + }); + + describe('with FileReader API is not available', function () { + beforeEach(function () { + registerTestableCode({FileReader: undefined}); + }); + + afterEach(function () { + registerTestableCode(); + }); + + it(VALIDATOR_SUCCESS_MESSAGE, function () { + files = [ + {name: 'file.jpg', type: 'image/jpeg', size: 100 * 1024, width: 100, height: 100}, + {name: 'file.png', type: 'image/png', size: 150 * 1024, width: 250, height: 250} + ]; + var messages = []; + var deferredList = []; + + yii.validation.image(attribute, messages, {}, deferredList); + assert.deepEqual(messages, []); + + assert.isFalse(validateImageStub.called); + assert.isFalse(deferredStub.called); + assert.deepEqual(deferredList, []); + }); + }); + + describe('with FileReader API is available', function () { + it(VALIDATOR_ERROR_MESSAGE, function () { + files = [ + {name: 'file.jpg', type: 'image/jpeg', size: 100 * 1024, width: 100, height: 100}, + {name: 'file.bmp', type: 'image/bmp', size: 150 * 1024, width: 250, height: 250} + ]; + var options = { + extensions: ['jpg', 'png'], + wrongExtension: 'File {file} has wrong extension.', + minWidth: 200, + underWidth: 'File {file} has small width.' + }; + var messages = []; + var deferredList = []; + + yii.validation.image(attribute, messages, options, deferredList); + assert.deepEqual(messages, ['File file.bmp has wrong extension.']); + + assert.equal(validateImageStub.callCount, files.length); + + for (var i = 0; i < validateImageStub.callCount; i++) { + assert.equal(validateImageStub.getCall(i).args.length, 6); + assert.deepEqual(validateImageStub.getCall(i).args[0], files[i]); + assert.deepEqual(validateImageStub.getCall(i).args[1], ['File file.bmp has wrong extension.']); + assert.deepEqual(validateImageStub.getCall(i).args[2], options); + assert.isDeferred(validateImageStub.getCall(i).args[3]); + assert.instanceOf(validateImageStub.getCall(i).args[4], FileReader); + assert.instanceOf(validateImageStub.getCall(i).args[5], Image); + } + + assert.equal(deferredList.length, files.length); + + for (i = 0; i < deferredList.length; i++) { + assert.isDeferred(deferredList[i]); + } + }); + }); + }); + + describe('validateImage method', function () { + var file = {name: 'file.jpg', type: 'image/jpeg', size: 100 * 1024}; + var image = new Image(); + var deferred; + var fileReader = new FileReader(); + var deferredStub; + var fileReaderStub; + + beforeEach(function () { + deferred = $.Deferred(); + deferredStub = sinon.stub(deferred, 'resolve'); + }); + + afterEach(function () { + deferredStub.restore(); + fileReaderStub.restore(); + }); + + function verifyStubs() { + assert.isTrue(fileReaderStub.calledOnce); + assert.isTrue(deferredStub.calledOnce); + } + + describe('with error while reading data', function () { + beforeEach(function () { + fileReaderStub = sinon.stub(fileReader, 'readAsDataURL', function () { + this.onerror(); + }); + }); + + it(VALIDATOR_SUCCESS_MESSAGE, function () { + var messages = []; + + yii.validation.validateImage(file, messages, {}, deferred, fileReader, image); + assert.deepEqual(messages, []); + + verifyStubs(); + }); + }); + + describe('with error while reading image', function () { + beforeEach(function () { + fileReaderStub = sinon.stub(fileReader, 'readAsDataURL', function () { + this.onload = function () { + image.onerror(); + }; + + this.onload(); + }); + }); + + it(VALIDATOR_ERROR_MESSAGE, function () { + var messages = []; + var options = {notImage: 'File {file} is not an image.'}; + + yii.validation.validateImage(file, messages, options, deferred, fileReader, image); + assert.deepEqual(messages, ['File file.jpg is not an image.']); + + verifyStubs(); + }); + }); + + describe('with successfully read image', function () { + var defaultOptions = { + underWidth: 'File {file} has small width.', + overWidth: 'File {file} has big width.', + underHeight: 'File {file} has small height.', + overHeight: 'File {file} has big height.' + }; + + beforeEach(function () { + fileReaderStub = sinon.stub(fileReader, 'readAsDataURL', function () { + this.onload = function () { + image.onload(); + }; + + this.onload(); + }); + }); + + withData({ + // minWidth + 'width less than minimum width': [ + {width: 100, height: 100}, + {minWidth: 200}, + ['File file.jpg has small width.'] + ], + 'width equals minimum width': [{width: 100, height: 100}, {minWidth: 100}, []], + 'width more than minimum width': [{width: 200, height: 200}, {minWidth: 100}, []], + // maxWidth + 'width less than maximum width': [{width: 100, height: 100}, {maxWidth: 200}, []], + 'width equals maximum width': [{width: 100, height: 100}, {maxWidth: 100}, []], + 'width more than maximum width': [ + {width: 200, height: 200}, + {maxWidth: 100}, + ['File file.jpg has big width.'] + ], + // minHeight + 'height less than minimum height': [ + {width: 100, height: 100}, + {minHeight: 200}, + ['File file.jpg has small height.'] + ], + 'height equals minimum height': [{width: 100, height: 100}, {minHeight: 100}, []], + 'height more than minimum height': [{width: 200, height: 200}, {minHeight: 100}, []], + // maxHeight + 'height less than maximum height': [{width: 100, height: 100}, {maxHeight: 200}, []], + 'height equals maximum height': [{width: 100, height: 100}, {maxHeight: 100}, []], + 'height more than maximum height': [ + {width: 200, height: 200}, + {maxHeight: 100}, + ['File file.jpg has big height.'] + ], + // minWidth and minHeight + 'width less than minimum width and height less than minimum height': [ + {width: 100, height: 100}, + {minWidth: 200, minHeight: 200}, + ['File file.jpg has small width.', 'File file.jpg has small height.'] + ] + }, function (imageSize, customOptions, expectedMessages) { + it(getValidatorMessage(expectedMessages), function () { + image.width = imageSize.width; + image.height = imageSize.height; + var options = $.extend({}, defaultOptions, customOptions); + var messages = []; + + yii.validation.validateImage(file, messages, options, deferred, fileReader, image); + assert.deepEqual(messages, expectedMessages); + + verifyStubs(); + }); + }); + }); + }); + + describe('number validator', function () { + var integerPattern = /^\s*[+-]?\d+\s*$/; + var numberPattern = /^\s*[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?\s*$/; + var defaultOptions = { + message: 'Not a number.', + tooSmall: 'Number is too small.', + tooBig: 'Number is too big.' + }; + + describe('with integer pattern', function () { + withData({ + 'empty string': ['', false], + 'non-empty string': ['a', false], + 'zero': ['0', true], + 'positive integer, no sign': ['2', true], + 'positive integer with sign': ['+2', true], + 'negative integer': ['-2', true], + 'decimal fraction with dot': ['2.5', false], + 'decimal fraction with comma': ['2,5', false] + }, function (value, expectValid) { + it(getValidatorMessage(expectValid), function () { + var options = $.extend({}, defaultOptions, {pattern: integerPattern}); + var messages = []; + var expectedMessages = expectValid ? [] : ['Not a number.']; + + yii.validation.number(value, messages, options); + assert.deepEqual(messages, expectedMessages); + }); + }); + }); + + describe('with number pattern', function () { + withData({ + 'empty string': ['', false], + 'non-empty string': ['a', false], + 'zero': ['0', true], + 'positive integer, no sign': ['2', true], + 'positive integer with sign': ['+2', true], + 'negative integer': ['-2', true], + 'decimal fraction with dot, no sign': ['2.5', true], + 'positive decimal fraction with dot and sign': ['+2.5', true], + 'negative decimal fraction with dot': ['-2.5', true], + 'decimal fraction with comma': ['2,5', false], + 'floating number with exponential part': ['-1.23e-10', true] + }, function (value, expectValid) { + it(getValidatorMessage(expectValid), function () { + var options = $.extend({}, defaultOptions, {pattern: numberPattern}); + var messages = []; + var expectedMessages = expectValid ? [] : ['Not a number.']; + + yii.validation.number(value, messages, options); + assert.deepEqual(messages, expectedMessages); + }); + }); + }); + + describe('with different options, integer pattern', function () { + withData({ + 'empty string, skip on empty': ['', {skipOnEmpty: true}, []], + // Not a string + 'undefined': [undefined, {}, []], + 'integer, fits pattern': [2, {}, []], + 'integer, does not fit pattern': [2.5, {}, []], + // min + 'less than minimum': ['1', {min: 2}, ['Number is too small.']], + 'equals minimum': ['2', {min: 2}, []], + 'more than minimum': ['3', {min: 2}, []], + 'wrong integer and less than min': ['1.5', {min: 2}, ['Not a number.']], + // max + 'less than maximum': ['1', {max: 2}, []], + 'equals maximum': ['2', {max: 2}, []], + 'more than maximum': ['3', {max: 2}, ['Number is too big.']] + }, function (value, customOptions, expectedMessages) { + it(getValidatorMessage(expectedMessages), function () { + customOptions.pattern = integerPattern; + var options = $.extend({}, defaultOptions, customOptions); + var messages = []; + + yii.validation.number(value, messages, options); + assert.deepEqual(messages, expectedMessages); + }); + }); + }); + }); + + describe('range validator', function () { + withData({ + 'empty string, skip on empty': ['', {skipOnEmpty: true}, []], + 'array and arrays are not allowed': [['a', 'b'], {}, ['Invalid value.']], + 'string in array': ['a', {range: ['a', 'b', 'c']}, []], + 'string not in array': ['d', {range: ['a', 'b', 'c']}, ['Invalid value.']], + 'array in array': [['a', 'b'], {range: ['a', 'b', 'c'], allowArray: true}, []], + 'array not in array': [['a', 'd'], {range: ['a', 'b', 'c'], allowArray: true}, ['Invalid value.']], + 'string in array and inverted logic': ['a', {range: ['a', 'b', 'c'], not: true}, ['Invalid value.']], + 'string not in array and inverted logic': ['d', {range: ['a', 'b', 'c'], not: true}, []], + 'array in array and inverted logic': [ + ['a', 'b'], + {range: ['a', 'b', 'c'], allowArray: true, not: true}, + ['Invalid value.'] + ], + 'array not in array and inverted logic': [ + ['a', 'd'], + {range: ['a', 'b', 'c'], allowArray: true, not: true}, + [] + ] + }, function (value, options, expectedMessages) { + it(getValidatorMessage(expectedMessages), function () { + options.message = 'Invalid value.'; + var messages = []; + + yii.validation.range(value, messages, options); + assert.deepEqual(messages, expectedMessages); + }); + }); + }); + + describe('regular expression validator', function () { + var integerPattern = /^\s*[+-]?\d+\s*$/; + + describe('with integer pattern', function () { + withData({ + 'empty string, skip on empty': ['', {skipOnEmpty: true}, []], + 'regular integer': ['2', {}, []], + 'non-integer': ['2.5', {}, ['Invalid value.']], + 'regular integer, inverted logic': ['2', {not: true}, ['Invalid value.']], + 'integer pattern, non-integer, inverted logic': ['2.5', {pattern: integerPattern, not: true}, []] + }, function (value, options, expectedMessages) { + it(getValidatorMessage(expectedMessages), function () { + options.message = 'Invalid value.'; + options.pattern = integerPattern; + var messages = []; + + yii.validation.regularExpression(value, messages, options); + assert.deepEqual(messages, expectedMessages); + }); + }); + }); + }); + + describe('email validator', function () { + var pattern = "^[a-zA-Z0-9!#$%&'*+\\/=?^_`{|}~-]+(?:\\.[a-zA-Z0-9!#$%&'*+\\/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$"; + pattern = new RegExp(pattern); + var fullPattern = "^[^@]*<[a-zA-Z0-9!#$%&'*+\\/=?^_`{|}~-]+(?:\\.[a-zA-Z0-9!#$%&'*+\\/=?^_`{|}~-]+)*@" + + "(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?>$"; + fullPattern = new RegExp(fullPattern); + var defaultOptions = { + pattern: pattern, + fullPattern: fullPattern, + message: 'Invalid value.' + }; + + describe('with empty string, skip on empty', function () { + it(VALIDATOR_SUCCESS_MESSAGE, function () { + var messages = []; + var options = $.extend({}, defaultOptions, {skipOnEmpty: true}); + + yii.validation.email('', messages, options); + assert.deepEqual(messages, []); + }); + }); + + describe('with basic configuration', function () { + withData({ + 'letters only': ['sam@rmcreative.ru', true], + 'numbers in local-part': ['5011@gmail.com', true], + 'uppercase and lowercase letters, dot and numbers in local-part': ['Abc.123@example.com', true], + 'user mailbox': ['user+mailbox/department=shipping@example.com', true], + 'special symbols in local-part': ['!#$%&\'*+-/=?^_`.{|}~@example.com', true], + 'domain only': ['rmcreative.ru', false], + 'unicode in domain': ['example@äüößìà.de', false], + 'unicode (russian characters) in domain': ['sam@рмкреатиф.ru', false], + 'ASCII in domain': ['example@xn--zcack7ayc9a.de', true], + 'angle brackets, name': ['Carsten Brandt ', false], + 'angle brackets, quoted name': ['"Carsten Brandt" ', false], + 'angle brackets, no name': ['', false], + 'angle brackets, name, dot in local-part': ['John Smith ', false], + 'angle brackets, name, domain only': ['John Smith ', false], + 'no angle brackets, name': ['Information info@oertliches.de', false], + 'no angle brackets, name, unicode in domain': ['Information info@örtliches.de', false], + 'angle brackets, long quoted name': [ + '"' + StringUtils.repeatString('a', 300) + '" ', + false + ], + 'angle brackets, name, local part more than 64 characters': [ + 'Short Name <' + StringUtils.repeatString('a', 65) + '@example.com>', + false + ], + 'angle brackets, name, domain more than 254 characters': [ + 'Short Name <' + StringUtils.repeatString('a', 255) + '.com>', + false + ], + 'angle brackets, name, unicode in domain': ['Information ', false], + 'angle brackets, name, unicode, local-part length is close to 64 characters': [ + // 21 * 3 = 63 + 'Короткое имя <' + StringUtils.repeatString('бла', 21) + '@пример.com>', + false + ], + 'angle brackets, name, unicode, domain length is close to 254 characters': [ + // 83 * 3 + 4 = 253 + 'Короткое имя <тест@' + StringUtils.repeatString('бла', 83) + '.com>', + false + ] + }, function (value, expectValid) { + it(getValidatorMessage(expectValid), function () { + var messages = []; + var expectedMessages = expectValid ? [] : ['Invalid value.']; + + yii.validation.email(value, messages, defaultOptions); + assert.deepEqual(messages, expectedMessages); + }); + }); + }); + + describe('with allowed name', function () { + withData({ + 'letters only': ['sam@rmcreative.ru', true], + 'numbers in local-part': ['5011@gmail.com', true], + 'uppercase and lowercase letters, dot and numbers in local-part': ['Abc.123@example.com', true], + 'user mailbox': ['user+mailbox/department=shipping@example.com', true], + 'special symbols in local-part': ['!#$%&\'*+-/=?^_`.{|}~@example.com', true], + 'domain only': ['rmcreative.ru', false], + 'unicode in domain': ['example@äüößìà.de', false], + 'unicode (russian characters) in domain': ['sam@рмкреатиф.ru', false], + 'ASCII in domain': ['example@xn--zcack7ayc9a.de', true], + 'angle brackets, name': ['Carsten Brandt ', true], + 'angle brackets, quoted name': ['"Carsten Brandt" ', true], + 'angle brackets, no name': ['', true], + 'angle brackets, name, dot in local-part': ['John Smith ', true], + 'angle brackets, name, domain only': ['John Smith ', false], + 'no angle brackets, name': ['Information info@oertliches.de', false], + 'no angle brackets, name, unicode in domain': ['Information info@örtliches.de', false], + 'angle brackets, long quoted name': [ + '"' + StringUtils.repeatString('a', 300) + '" ', + true + ], + 'angle brackets, name, local part more than 64 characters': [ + 'Short Name <' + StringUtils.repeatString('a', 65) + '@example.com>', + false + ], + 'angle brackets, name, domain more than 254 characters': [ + 'Short Name <' + StringUtils.repeatString('a', 255) + '.com>', + false + ], + 'angle brackets, name, unicode in domain': ['Information ', false], + 'angle brackets, name, unicode, local-part length is close to 64 characters': [ + // 21 * 3 = 63 + 'Короткое имя <' + StringUtils.repeatString('бла', 21) + '@пример.com>', + false + ], + 'angle brackets, name, unicode, domain length is close to 254 characters': [ + // 83 * 3 + 4 = 253 + 'Короткое имя <тест@' + StringUtils.repeatString('бла', 83) + '.com>', + false + ] + }, function (value, expectValid) { + it(getValidatorMessage(expectValid), function () { + var options = $.extend({}, defaultOptions, {allowName: true}); + var messages = []; + var expectedMessages = expectValid ? [] : ['Invalid value.']; + + yii.validation.email(value, messages, options); + assert.deepEqual(messages, expectedMessages); + }); + }); + }); + + describe('with enabled IDN', function () { + withData({ + 'letters only': ['sam@rmcreative.ru', true], + 'numbers in local-part': ['5011@gmail.com', true], + 'uppercase and lowercase letters, dot and numbers in local-part': ['Abc.123@example.com', true], + 'user mailbox': ['user+mailbox/department=shipping@example.com', true], + 'special symbols in local-part': ['!#$%&\'*+-/=?^_`.{|}~@example.com', true], + 'domain only': ['rmcreative.ru', false], + 'unicode in domain': ['example@äüößìà.de', true], + 'unicode (russian characters) in domain': ['sam@рмкреатиф.ru', true], + 'ASCII in domain': ['example@xn--zcack7ayc9a.de', true], + 'angle brackets, name': ['Carsten Brandt ', false], + 'angle brackets, quoted name': ['"Carsten Brandt" ', false], + 'angle brackets, no name': ['', false], + 'angle brackets, name, dot in local-part': ['John Smith ', false], + 'angle brackets, name, domain only': ['John Smith ', false], + 'no angle brackets, name': ['Information info@oertliches.de', false], + 'no angle brackets, name, unicode in domain': ['Information info@örtliches.de', false], + 'angle brackets, long quoted name': [ + '"' + StringUtils.repeatString('a', 300) + '" ', + false + ], + 'angle brackets, name, local part more than 64 characters': [ + 'Short Name <' + StringUtils.repeatString('a', 65) + '@example.com>', + false + ], + 'angle brackets, name, domain more than 254 characters': [ + 'Short Name <' + StringUtils.repeatString('a', 255) + '.com>', + false + ], + 'angle brackets, name, unicode in domain': ['Information ', false], + 'angle brackets, name, unicode, local-part length is close to 64 characters': [ + // 21 * 3 = 63 + 'Короткое имя <' + StringUtils.repeatString('бла', 21) + '@пример.com>', + false + ], + 'angle brackets, name, unicode, domain length is close to 254 characters': [ + // 83 * 3 + 4 = 253 + 'Короткое имя <тест@' + StringUtils.repeatString('бла', 83) + '.com>', + false + ] + }, function (value, expectValid) { + it(getValidatorMessage(expectValid), function () { + var options = $.extend({}, defaultOptions, {enableIDN: true}); + var messages = []; + var expectedMessages = expectValid ? [] : ['Invalid value.']; + + yii.validation.email(value, messages, options); + assert.deepEqual(messages, expectedMessages); + }); + }); + }); + + describe('with allowed name and enabled IDN', function () { + withData({ + 'letters only': ['sam@rmcreative.ru', true], + 'numbers in local-part': ['5011@gmail.com', true], + 'uppercase and lowercase letters, dot and numbers in local-part': ['Abc.123@example.com', true], + 'user mailbox': ['user+mailbox/department=shipping@example.com', true], + 'special symbols in local-part': ['!#$%&\'*+-/=?^_`.{|}~@example.com', true], + 'domain only': ['rmcreative.ru', false], + 'unicode in domain': ['example@äüößìà.de', true], + 'unicode (russian characters) in domain': ['sam@рмкреатиф.ru', true], + 'ASCII in domain': ['example@xn--zcack7ayc9a.de', true], + 'angle brackets, name': ['Carsten Brandt ', true], + 'angle brackets, quoted name': ['"Carsten Brandt" ', true], + 'angle brackets, no name': ['', true], + 'angle brackets, name, dot in local-part': ['John Smith ', true], + 'angle brackets, name, domain only': ['John Smith ', false], + 'no angle brackets, name': ['Information info@oertliches.de', false], + 'no angle brackets, name, unicode in domain': ['Information info@örtliches.de', false], + 'angle brackets, long quoted name': [ + '"' + StringUtils.repeatString('a', 300) + '" ', + true + ], + 'angle brackets, name, local part more than 64 characters': [ + 'Short Name <' + StringUtils.repeatString('a', 65) + '@example.com>', + false + ], + 'angle brackets, name, domain more than 254 characters': [ + 'Short Name <' + StringUtils.repeatString('a', 255) + '.com>', + false + ], + 'angle brackets, name, unicode in domain': ['Information ', true], + 'angle brackets, name, unicode, local-part length is close to 64 characters': [ + // 21 * 3 = 63 + 'Короткое имя <' + StringUtils.repeatString('бла', 21) + '@пример.com>', + false + ], + 'angle brackets, name, unicode, domain length is close to 254 characters': [ + // 83 * 3 + 4 = 253 + 'Короткое имя <тест@' + StringUtils.repeatString('бла', 83) + '.com>', + false + ] + }, function (value, expectValid) { + it(getValidatorMessage(expectValid), function () { + var options = $.extend({}, defaultOptions, {allowName: true, enableIDN: true}); + var messages = []; + var expectedMessages = expectValid ? [] : ['Invalid value.']; + + yii.validation.email(value, messages, options); + assert.deepEqual(messages, expectedMessages); + }); + }); + }); + }); + + describe('url validator', function () { + function getPattern(validSchemes) { + if (validSchemes === undefined) { + validSchemes = ['http', 'https']; + } + + var pattern = '^{schemes}://(([A-Z0-9][A-Z0-9_-]*)(\\.[A-Z0-9][A-Z0-9_-]*)+)(?::\\d{1,5})?(?:$|[?\\/#])'; + pattern = pattern.replace('{schemes}', '(' + validSchemes.join('|') + ')'); + + return new RegExp(pattern, 'i'); + } + + var defaultOptions = { + pattern: getPattern(), + message: 'Invalid value.' + }; + + describe('with empty string, skip on empty', function () { + it(VALIDATOR_SUCCESS_MESSAGE, function () { + var messages = []; + var options = $.extend({}, defaultOptions, {skipOnEmpty: true}); + + yii.validation.url('', messages, options); + assert.deepEqual(messages, []); + }); + }); + + describe('with basic configuration', function () { + withData({ + 'domain only': ['google.de', false], + 'http': ['http://google.de', true], + 'https': ['https://google.de', true], + 'scheme with typo': ['htp://yiiframework.com', false], + 'https, action with get parameters': [ + 'https://www.google.de/search?q=yii+framework&ie=utf-8&oe=utf-8&rls=org.mozilla:de:official' + + '&client=firefox-a&gws_rd=cr', + true + ], + 'scheme not in valid schemes': ['ftp://ftp.ruhr-uni-bochum.de/', false], + 'invalid domain': ['http://invalid,domain', false], + 'not allowed symbol (comma) after domain': ['http://example.com,', false], + 'not allowed symbol (star) after domain': ['http://example.com*12', false], + 'symbols after slash': ['http://example.com/*12', true], + 'get parameter without value': ['http://example.com/?test', true], + 'anchor': ['http://example.com/#test', true], + 'port, anchor': ['http://example.com:80/#test', true], + 'port (length equals limit), anchor': ['http://example.com:65535/#test', true], + 'port, get parameter without value': ['http://example.com:81/?good', true], + 'get parameter without value and slash': ['http://example.com?test', true], + 'anchor without slash': ['http://example.com#test', true], + 'port and anchor without slash': ['http://example.com:81#test', true], + 'port and get parameter without value and slash': ['http://example.com:81?good', true], + 'not allowed symbol after domain followed by get parameter without value': [ + 'http://example.com,?test', + false + ], + 'skipped port and get parameter without value': ['http://example.com:?test', false], + 'skipped port and action': ['http://example.com:test', false], + 'port (length more than limit) and action': ['http://example.com:123456/test', false], + 'unicode, special symbols': ['http://äüö?=!"§$%&/()=}][{³²€.edu', false] + }, function (value, expectValid) { + it(getValidatorMessage(expectValid), function () { + var messages = []; + var expectedMessages = expectValid ? [] : ['Invalid value.']; + + yii.validation.url(value, messages, defaultOptions); + assert.deepEqual(messages, expectedMessages); + }); + }); + }); + + describe('with default scheme', function () { + withData({ + 'no scheme': ['yiiframework.com', true], + 'http': ['http://yiiframework.com', true] + }, function (value, expectValid) { + it(getValidatorMessage(expectValid), function () { + var messages = []; + var expectedMessages = expectValid ? [] : ['Invalid value.']; + var options = $.extend({}, defaultOptions, {defaultScheme: 'https'}); + + yii.validation.url(value, messages, options); + assert.deepEqual(messages, expectedMessages); + }); + }); + }); + + describe('without scheme', function () { + it(VALIDATOR_SUCCESS_MESSAGE, function () { + var messages = []; + var options = $.extend({}, defaultOptions, { + pattern: /(([A-Z0-9][A-Z0-9_-]*)(\.[A-Z0-9][A-Z0-9_-]*)+)/i + }); + + yii.validation.url('yiiframework.com', messages, options); + assert.deepEqual(messages, []); + }); + }); + + describe('with default scheme and custom schemes', function () { + withData({ + 'ftp': ['ftp://ftp.ruhr-uni-bochum.de/', true], + 'no scheme': ['google.de', true], + 'http': ['http://google.de', true], + 'https': ['https://google.de', true], + 'scheme with typo': ['htp://yiiframework.com', false], + 'relative url': ['//yiiframework.com', false] + }, function (value, expectValid) { + it(getValidatorMessage(expectValid), function () { + var messages = []; + var expectedMessages = expectValid ? [] : ['Invalid value.']; + var options = $.extend({}, defaultOptions, { + pattern: getPattern(['http', 'https', 'ftp', 'ftps']), + defaultScheme: 'http' + }); + + yii.validation.url(value, messages, options); + assert.deepEqual(messages, expectedMessages); + }); + }); + }); + + describe('with enabled IDN', function () { + withData({ + 'unicode in domain': ['http://äüößìà.de', true], + // converted via http://mct.verisign-grs.com/convertServlet + 'ASCII in domain': ['http://xn--zcack7ayc9a.de', true] + }, function (value, expectValid) { + it(getValidatorMessage(expectValid), function () { + var messages = []; + var expectedMessages = expectValid ? [] : ['Invalid value.']; + var options = $.extend({}, defaultOptions, {enableIDN: true}); + + yii.validation.url(value, messages, options); + assert.deepEqual(messages, expectedMessages); + }); + }); + }); + }); + + describe('trim filter', function () { + var attribute = {input: '#input-id'}; + var getInputVal; + var $input = { + val: function () { + return getInputVal(); + } + }; + var $form = { + find: function () { + return $input; + } + }; + + var formSpy; + var inputSpy; + + beforeEach(function () { + formSpy = sinon.spy($form, 'find'); + inputSpy = sinon.spy($input, 'val'); + }); + + afterEach(function () { + formSpy.restore(); + inputSpy.restore(); + }); + + describe('with empty string, skip on empty', function () { + it('should leave value and element value as is and return not changed value', function () { + getInputVal = function () { + return ''; + }; + + assert.strictEqual(yii.validation.trim($form, attribute, {skipOnEmpty: true}), ''); + + assert.isTrue(formSpy.calledOnce); + assert.equal(formSpy.getCall(0).args[0], attribute.input); + + assert.isTrue(inputSpy.calledOnce); + assert.strictEqual(inputSpy.getCall(0).args[0], undefined); + }); + }); + + withData({ + 'nothing to trim': ['value', 'value'], + 'spaces at the beginning and end': [' value ', 'value'], + 'newlines at the beginning and end': ['\nvalue\n', 'value'], + 'spaces and newlines at the beginning and end': ['\n value \n', 'value'] + }, function (value, expectedValue) { + it('should return trimmed value and set it as value of element', function () { + getInputVal = function (val) { + return val === undefined ? value : undefined; + }; + + assert.equal(yii.validation.trim($form, attribute, {}), expectedValue); + + assert.isTrue(formSpy.calledOnce); + assert.equal(formSpy.getCall(0).args[0], attribute.input); + + assert.equal(inputSpy.callCount, 2); + assert.strictEqual(inputSpy.getCall(0).args[0], undefined); + assert.equal(inputSpy.getCall(1).args[0], expectedValue); + }); + }); + }); + + describe('captcha validator', function () { + // Converted using yii\captcha\CaptchaAction generateValidationHash() method + var hashes = {'Code': 379, 'code': 411}; + var caseInSensitiveData = { + 'valid code in lowercase': ['code', true], + 'valid code in uppercase': ['CODE', true], + 'valid code as is': ['Code', true], + 'invalid code': ['invalid code', false] + }; + var caseSensitiveData = { + 'valid code in lowercase': ['code', false], + 'valid code in uppercase': ['CODE', false], + 'valid code as is': ['Code', true], + 'invalid code': ['invalid code', false] + }; + var defaultOptions = { + message: 'Invalid value.', + hashKey: 'hashKey' + }; + var hashesData = [hashes['Code'], hashes['code']]; + var jQueryDataStub; + + beforeEach(function () { + jQueryDataStub = sinon.stub($.prototype, 'data', function () { + return hashesData; + }); + }); + + afterEach(function () { + jQueryDataStub.restore(); + }); + + function verifyJQueryDataStub() { + assert.isTrue(jQueryDataStub.calledOnce); + assert.equal(jQueryDataStub.getCall(0).args[0], defaultOptions.hashKey); + } + + describe('with empty string, skip on empty', function () { + it(VALIDATOR_SUCCESS_MESSAGE, function () { + var messages = []; + var options = $.extend({}, defaultOptions, {skipOnEmpty: true}); + + yii.validation.captcha('', messages, options); + assert.deepEqual(messages, []); + + assert.isFalse(jQueryDataStub.called); + }); + }); + + describe('with ajax, case insensitive', function () { + withData(caseInSensitiveData, function (value, expectValid) { + it(getValidatorMessage(expectValid), function () { + var messages = []; + var expectedMessages = expectValid ? [] : ['Invalid value.']; + + yii.validation.captcha(value, messages, defaultOptions); + assert.deepEqual(messages, expectedMessages); + + verifyJQueryDataStub(); + }); + }); + }); + + describe('with ajax, case sensitive', function () { + withData(caseSensitiveData, function (value, expectValid) { + it(getValidatorMessage(expectValid), function () { + var messages = []; + var expectedMessages = expectValid ? [] : ['Invalid value.']; + var options = $.extend({}, defaultOptions, {caseSensitive: true}); + + yii.validation.captcha(value, messages, options); + assert.deepEqual(messages, expectedMessages); + + verifyJQueryDataStub(); + }); + }); + }); + + describe('with hash, case insensitive', function () { + withData(caseInSensitiveData, function (value, expectValid) { + it(getValidatorMessage(expectValid), function () { + hashesData = undefined; + var messages = []; + var expectedMessages = expectValid ? [] : ['Invalid value.']; + var options = $.extend({}, defaultOptions, {hash: hashes['code']}); + + yii.validation.captcha(value, messages, options); + assert.deepEqual(messages, expectedMessages); + + verifyJQueryDataStub(); + }); + }); + }); + + describe('with hash, case sensitive', function () { + withData(caseSensitiveData, function (value, expectValid) { + it(getValidatorMessage(expectValid), function () { + hashesData = undefined; + var messages = []; + var expectedMessages = expectValid ? [] : ['Invalid value.']; + var options = $.extend({}, defaultOptions, {hash: hashes['Code'], caseSensitive: true}); + + yii.validation.captcha(value, messages, options); + assert.deepEqual(messages, expectedMessages); + + verifyJQueryDataStub(); + }); + }); + }); + }); + + describe('compare validator', function () { + var $input = { + val: function () { + return 'b'; + } + }; + var jQueryInitStub; + var inputSpy; + + beforeEach(function () { + jQueryInitStub = sinon.stub($.fn, 'init', function () { + return $input; + }); + inputSpy = sinon.spy($input, 'val'); + }); + + afterEach(function () { + jQueryInitStub.restore(); + inputSpy.restore(); + }); + + withData({ + 'empty string, skip on empty': ['', {skipOnEmpty: true}, true], + // == + '"==" operator, 2 identical integers': [2, {operator: '==', compareValue: 2}, true], + '"==" operator, 2 different integers': [2, {operator: '==', compareValue: 3}, false], + '"==" operator, 2 identical decimal fractions': [2.5, {operator: '==', compareValue: 2.5}, true], + '"==" operator, integer and string with the same values': [2, {operator: '==', compareValue: '2'}, true], + '"==" operator, integer and string with the different values': [ + 2, + {operator: '==', compareValue: '3'}, + false + ], + '"==" operator, 2 identical strings': ['b', {operator: '==', compareValue: 'b'}, true], + // === + '"===" operator, 2 identical integers': [2, {operator: '===', compareValue: 2}, true], + '"===" operator, 2 different integers': [2, {operator: '===', compareValue: 3}, false], + '"===" operator, 2 identical decimal fractions': [2.5, {operator: '===', compareValue: 2.5}, true], + '"===" operator, integer and string with the same value': [2, {operator: '===', compareValue: '2'}, false], + '"===" operator, integer and string with the different values': [ + 2, + {operator: '===', compareValue: '3'}, + false + ], + '"===" operator, 2 identical strings': ['b', {operator: '===', compareValue: 'b'}, true], + // != + '"!=" operator, 2 identical integers': [2, {operator: '!=', compareValue: 2}, false], + '"!=" operator, 2 different integers': [2, {operator: '!=', compareValue: 3}, true], + '"!=" operator, 2 identical decimal fractions': [2.5, {operator: '!=', compareValue: 2.5}, false], + '"!=" operator, integer and string with the same value': [2, {operator: '!=', compareValue: '2'}, false], + '"!=" operator, integer and string with the different values': [ + 2, + {operator: '!=', compareValue: '3'}, + true + ], + '"!=" operator, 2 identical strings': ['b', {operator: '!=', compareValue: 'b'}, false], + // !== + '"!==" operator, 2 identical integers': [2, {operator: '!==', compareValue: 2}, false], + '"!==" operator, 2 different integers': [2, {operator: '!==', compareValue: 3}, true], + '"!==" operator, 2 identical decimal fractions': [2.5, {operator: '!==', compareValue: 2.5}, false], + '"!==" operator, integer and string with the same value': [2, {operator: '!==', compareValue: '2'}, true], + '"!==" operator, integer and string with the different values': [ + 2, + {operator: '!==', compareValue: '3'}, + true + ], + '"!==" operator, 2 identical strings': ['b', {operator: '!==', compareValue: 'b'}, false], + // > + '">" operator, 2 identical integers': [2, {operator: '>', compareValue: 2}, false], + '">" operator, 2 integers, 2nd is greater': [2, {operator: '>', compareValue: 3}, false], + '">" operator, 2 integers, 2nd is lower': [2, {operator: '>', compareValue: 1}, true], + '">" operator, 2 identical strings': ['b', {operator: '>', compareValue: 'b'}, false], + '">" operator, 2 strings, 2nd is greater': ['a', {operator: '>', compareValue: 'b'}, false], + '">" operator, 2 strings, 2nd is lower': ['b', {operator: '>', compareValue: 'a'}, true], + // >= + '">=" operator, 2 identical integers': [2, {operator: '>=', compareValue: 2}, true], + '">=" operator, 2 integers, 2nd is greater': [2, {operator: '>=', compareValue: 3}, false], + '">=" operator, 2 integers, 2nd is lower': [2, {operator: '>=', compareValue: 1}, true], + '">=" operator, 2 identical strings': ['b', {operator: '>=', compareValue: 'b'}, true], + '">=" operator, 2 strings, 2nd is greater': ['a', {operator: '>=', compareValue: 'b'}, false], + '">=" operator, 2 strings, 2nd is lower': ['b', {operator: '>=', compareValue: 'a'}, true], + // < + '"<" operator, 2 identical integers': [2, {operator: '<', compareValue: 2}, false], + '"<" operator, 2 integers, 2nd is greater': [2, {operator: '<', compareValue: 3}, true], + '"<" operator, 2 integers, 2nd is lower': [2, {operator: '<', compareValue: 1}, false], + '"<" operator, 2 identical strings': ['b', {operator: '<', compareValue: 'b'}, false], + '"<" operator, 2 strings, 2nd is greater': ['a', {operator: '<', compareValue: 'b'}, true], + '"<" operator, 2 strings, 2nd is lower': ['b', {operator: '<', compareValue: 'a'}, false], + '"<" operator, strings "10" and "2"': ['10', {operator: '<', compareValue: '2'}, true], + // <= + '"<=" operator, 2 identical integers': [2, {operator: '<=', compareValue: 2}, true], + '"<=" operator, 2 integers, 2nd is greater': [2, {operator: '<=', compareValue: 3}, true], + '"<=" operator, 2 integers, 2nd is lower': [2, {operator: '<=', compareValue: 1}, false], + '"<=" operator, 2 identical strings': ['b', {operator: '<=', compareValue: 'b'}, true], + '"<=" operator, 2 strings, 2nd is greater': ['a', {operator: '<=', compareValue: 'b'}, true], + '"<=" operator, 2 strings, 2nd is lower': ['b', {operator: '<=', compareValue: 'a'}, false], + // type + 'number type, "<" operator, strings "10" and "2"': [ + '10', + {operator: '<', compareValue: '2', type: 'number'}, + false + ], + // default compare value + 'default compare value, "===" operator, against undefined': [undefined, {operator: '==='}, true] + }, function (value, options, expectValid) { + it(getValidatorMessage(expectValid), function () { + options.message = 'Invalid value.'; + var messages = []; + var expectedMessages = expectValid ? [] : ['Invalid value.']; + + yii.validation.compare(value, messages, options); + assert.deepEqual(messages, expectedMessages); + + assert.isFalse(jQueryInitStub.called); + assert.isFalse(inputSpy.called); + }) + }); + + describe('with compareAttribute, "==" operator and 2 identical strings', function () { + it(VALIDATOR_SUCCESS_MESSAGE, function () { + var messages = []; + + yii.validation.compare('b', messages, {operator: '==', compareAttribute: 'input-id'}); + assert.deepEqual(messages, []); + + assert.isTrue(jQueryInitStub.calledOnce); + assert.equal(jQueryInitStub.getCall(0).args[0], '#input-id'); + + assert.isTrue(inputSpy.calledOnce); + assert.strictEqual(inputSpy.getCall(0).args[0], undefined); + }); + }); + }); + + describe('ip validator', function () { + var ipParsePattern = '^(\\!?)(.+?)(\/(\\d+))?$'; + var ipv4Pattern = '^(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?' + + '[0-9]?[0-9]))$'; + var ipv6Pattern = '^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:)' + + '{1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}' + + '(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}' + + '(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|' + + 'fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}' + + '[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|' + + '(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$'; + var defaultOptions = { + messages: { + message: 'Invalid value.', + noSubnet: 'No subnet.', + hasSubnet: 'Has subnet.', + ipv4NotAllowed: 'IPv4 is not allowed.', + ipv6NotAllowed: 'IPv6 is not allowed.' + }, + 'ipParsePattern': ipParsePattern, + 'ipv4Pattern': ipv4Pattern, + 'ipv6Pattern': ipv6Pattern, + ipv4: true, + ipv6: true + }; + + withData({ + 'empty string, skip on empty': ['', {skipOnEmpty: true}, []], + 'not IP': ['not IP', {}, ['Invalid value.']], + 'not IP, IPv4 is disabled': ['not:IP', {ipv4: false}, ['Invalid value.']], + 'not IP, IPv6 is disabled': ['not IP', {ipv6: false}, ['Invalid value.']], + // subnet, IPv4 + 'IPv4, subnet option is not defined': ['192.168.10.0', {}, []], + 'IPv4, subnet option is set to "false"': ['192.168.10.0', {subnet: false}, []], + 'IPv4, subnet option is set to "true"': ['192.168.10.0', {subnet: true}, ['No subnet.']], + 'IPv4 with CIDR subnet, subnet option is not defined': ['192.168.10.0/24', {}, []], + 'IPv4 with CIDR subnet, subnet option is set to "false"': [ + '192.168.10.0/24', + {subnet: false}, + ['Has subnet.'] + ], + 'IPv4 with CIDR subnet, subnet option is set to "true"': ['192.168.10.0/24', {subnet: true}, []], + // subnet, IPv6 + 'IPv6, subnet option is not defined': ['2001:0db8:11a3:09d7:1f34:8a2e:07a0:765d', {}, []], + 'IPv6, subnet option is set to "false"': ['2001:0db8:11a3:09d7:1f34:8a2e:07a0:765d', {subnet: false}, []], + 'IPv6, subnet option is set to "true"': [ + '2001:0db8:11a3:09d7:1f34:8a2e:07a0:765d', + {subnet: true}, + ['No subnet.'] + ], + 'IPv6 with CIDR subnet, subnet option is not defined': [ + '2001:0db8:11a3:09d7:1f34:8a2e:07a0:765d/24', + {}, + [] + ], + 'IPv6 with CIDR subnet, subnet option is set to "false"': [ + '2001:0db8:11a3:09d7:1f34:8a2e:07a0:765d/24', + {subnet: false}, + ['Has subnet.'] + ], + 'IPv6 with CIDR subnet, subnet option is set to "true"': [ + '2001:0db8:11a3:09d7:1f34:8a2e:07a0:765d/24', + {subnet: true}, + [] + ], + // negation, IPv4 + 'IPv4, negation option is not defined': ['192.168.10.0', {}, []], + 'IPv4, negation option is set to "false"': ['192.168.10.0', {negation: false}, []], + 'IPv4, negation option is set to "true"': ['192.168.10.0', {negation: true}, []], + 'IPv4 with negation, negation option is not defined': ['!192.168.10.0', {}, []], + 'IPv4 with negation, negation option is set to "false"': [ + '!192.168.10.0', + {negation: false}, + ['Invalid value.'] + ], + 'IPv4 with negation, negation option is set to "true"': ['!192.168.10.0', {negation: true}, []], + // negation, IPv6 + 'IPv6, negation option is not defined': ['2001:0db8:11a3:09d7:1f34:8a2e:07a0:765d', {}, []], + 'IPv6, negation option is set to "false"': [ + '2001:0db8:11a3:09d7:1f34:8a2e:07a0:765d', + {negation: false}, + [] + ], + 'IPv6, negation option is set to "true"': ['2001:0db8:11a3:09d7:1f34:8a2e:07a0:765d', {negation: true}, []], + 'IPv6 with negation, negation option is not defined': ['!2001:0db8:11a3:09d7:1f34:8a2e:07a0:765d', {}, []], + 'IPv6 with negation, negation option is set to "false"': [ + '!2001:0db8:11a3:09d7:1f34:8a2e:07a0:765d', + {negation: false}, + ['Invalid value.'] + ], + 'IPv6 with negation, negation option is set to "true"': [ + '!2001:0db8:11a3:09d7:1f34:8a2e:07a0:765d', + {negation: true}, + [] + ], + // ipv4, ipv6 + 'IPv4, IPv4 option is set to "false"': ['192.168.10.0', {ipv4: false}, ['IPv4 is not allowed.']], + 'IPv6, IPv6 option is set to "false"': [ + '2001:0db8:11a3:09d7:1f34:8a2e:07a0:765d', + {ipv6: false}, + ['IPv6 is not allowed.'] + ], + 'IPv6, short variation (4 groups)': ['2001:db8::ae21:ad12', {}, []], + 'IPv6, short variation (2 groups)': ['::ae21:ad12', {}, []], + 'IPv4, IPv4 and IPv6 options are set to "false"': [ + '192.168.10.0', + {ipv4: false, ipv6: false}, + ['IPv4 is not allowed.'] + ], + 'IPv6, IPv4 and IPv6 options are set to "false"': [ + '2001:0db8:11a3:09d7:1f34:8a2e:07a0:765d', + {ipv4: false, ipv6: false}, + ['IPv6 is not allowed.'] + ], + 'invalid IPv4': ['192,168.10.0', {}, ['Invalid value.']], + 'invalid IPv6': ['2001,0db8:11a3:09d7:1f34:8a2e:07a0:765d', {}, ['Invalid value.']], + 'invalid IPv4, IPv4 option is set to "false"': [ + '192,168.10.0', + {ipv4: false}, + ['Invalid value.', 'IPv4 is not allowed.'] + ], + 'invalid IPv6, IPv6 option is set to "false"': [ + '2001,0db8:11a3:09d7:1f34:8a2e:07a0:765d', + {ipv6: false}, + ['Invalid value.', 'IPv6 is not allowed.'] + ] + }, function (value, customOptions, expectedMessages) { + it(getValidatorMessage(expectedMessages), function () { + var messages = []; + var options = $.extend({}, defaultOptions, customOptions); + + yii.validation.ip(value, messages, options); + assert.deepEqual(messages, expectedMessages); + }) + }); + }); +});