diff --git a/apps/advanced/backend/config/main.php b/apps/advanced/backend/config/main.php index c745a1f..d1a45a7 100644 --- a/apps/advanced/backend/config/main.php +++ b/apps/advanced/backend/config/main.php @@ -22,6 +22,7 @@ return [ 'mail' => $params['components.mail'], 'user' => [ 'identityClass' => 'common\models\User', + 'enableAutoLogin' => true, ], 'log' => [ 'traceLevel' => YII_DEBUG ? 3 : 0, diff --git a/apps/advanced/frontend/config/main.php b/apps/advanced/frontend/config/main.php index 6ee8ae5..2a0f330 100644 --- a/apps/advanced/frontend/config/main.php +++ b/apps/advanced/frontend/config/main.php @@ -23,6 +23,7 @@ return [ 'mail' => $params['components.mail'], 'user' => [ 'identityClass' => 'common\models\User', + 'enableAutoLogin' => true, ], 'log' => [ 'traceLevel' => YII_DEBUG ? 3 : 0, diff --git a/apps/basic/config/web.php b/apps/basic/config/web.php index 472f842..e142855 100644 --- a/apps/basic/config/web.php +++ b/apps/basic/config/web.php @@ -12,6 +12,7 @@ $config = [ ], 'user' => [ 'identityClass' => 'app\models\User', + 'enableAutoLogin' => true, ], 'errorHandler' => [ 'errorAction' => 'site/error', diff --git a/docs/guide/active-record.md b/docs/guide/active-record.md index 70d41e0..a414bce 100644 --- a/docs/guide/active-record.md +++ b/docs/guide/active-record.md @@ -386,6 +386,74 @@ $customers = Customer::find()->limit(100)->with([ ``` +Joining with Relations +---------------------- + +When working with relational databases, a common task is to join multiple tables and apply various +query conditions and parameters to the JOIN SQL statement. Instead of calling [[ActiveQuery::join()]] +explicitly to build up the JOIN query, you may reuse the existing relation definitions and call +[[ActiveQuery::joinWith()]] to achieve this goal. For example, + +```php +// find all orders and sort the orders by the customer id and the order id. also eager loading "customer" +$orders = Order::find()->joinWith('customer')->orderBy('tbl_customer.id, tbl_order.id')->all(); +// find all orders that contain books, and eager loading "books" +$orders = Order::find()->innerJoinWith('books')->all(); +``` + +In the above, the method [[ActiveQuery::innerJoinWith()|innerJoinWith()]] is a shortcut to [[ActiveQuery::joinWith()|joinWith()]] +with the join type set as `INNER JOIN`. + +You may join with one or multiple relations; you may apply query conditions to the relations on-the-fly; +and you may also join with sub-relations. For example, + +```php +// join with multiple relations +// find out the orders that contain books and are placed by customers who registered within the past 24 hours +$orders = Order::find()->innerJoinWith([ + 'books', + 'customer' => function ($query) { + $query->where('tbl_customer.create_time > ' . (time() - 24 * 3600)); + } +])->all(); +// join with sub-relations: join with books and books' authors +$orders = Order::find()->joinWith('books.author')->all(); +``` + +Behind the scene, Yii will first execute a JOIN SQL statement to bring back the primary models +satisfying the conditions applied to the JOIN SQL. It will then execute a query for each relation +and populate the corresponding related records. + +The difference between [[ActiveQuery::joinWith()|joinWith()]] and [[ActiveQuery::with()|with()]] is that +the former joins the tables for the primary model class and the related model classes to retrieve +the primary models, while the latter just queries against the table for the primary model class to +retrieve the primary models. + +Because of this difference, you may apply query conditions that are only available to a JOIN SQL statement. +For example, you may filter the primary models by the conditions on the related models, like the example +above. You may also sort the primary models using columns from the related tables. + +When using [[ActiveQuery::joinWith()|joinWith()]], you are responsible to disambiguate column names. +In the above examples, we use `tbl_item.id` and `tbl_order.id` to disambiguate the `id` column references +because both of the order table and the item table contain a column named `id`. + +By default, when you join with a relation, the relation will also be eagerly loaded. You may change this behavior +by passing the `$eagerLoading` parameter which specifies whether to eager load the specified relations. + +And also by default, [[ActiveQuery::joinWith()|joinWith()]] uses `LEFT JOIN` to join the related tables. +You may pass it with the `$joinType` parameter to customize the join type. As a shortcut to the `INNER JOIN` type, +you may use [[ActiveQuery::innerJoinWith()|innerJoinWith()]]. + +Below are some more examples, + +```php +// find all orders that contain books, but do not eager loading "books". +$orders = Order::find()->innerJoinWith('books', false)->all(); +// equivalent to the above +$orders = Order::find()->joinWith('books', false, 'INNER JOIN')->all(); +``` + + Working with Relationships -------------------------- diff --git a/docs/guide/apps-own.md b/docs/guide/apps-own.md index ebf7597..3ebf83f 100644 --- a/docs/guide/apps-own.md +++ b/docs/guide/apps-own.md @@ -1,4 +1,4 @@ Creating your own Application structure ======================================= -TDB \ No newline at end of file +TBD \ No newline at end of file diff --git a/docs/guide/assets.md b/docs/guide/assets.md index 47c4063..467d7f8 100644 --- a/docs/guide/assets.md +++ b/docs/guide/assets.md @@ -40,7 +40,7 @@ application's `web` directory. is an alias that corresponds to your website base URL such as `http://example.com/`. In case you have asset files under non web accessible directory, that is the case for any extension, you need -to additionally specify `$sourcePath`. Files will be copied or symlinked from source bath to base path prior to being +to additionally specify `$sourcePath`. Files will be copied or symlinked from source path to base path prior to being registered. In case source path is used `baseUrl` is generated automatically at the time of publishing asset bundle. Dependencies on other asset bundles are specified via `$depends` property. It is an array that contains fully qualified diff --git a/docs/guide/caching.md b/docs/guide/caching.md index e36ae00..23bb872 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -152,7 +152,33 @@ $value2 = $cache['var2']; // equivalent to: $value2 = $cache->get('var2'); ### Cache Dependency -TBD: http://www.yiiframework.com/doc/guide/1.1/en/caching.data#cache-dependency +Besides expiration setting, cached data may also be invalidated according to some dependency changes. For example, if we +are caching the content of some file and the file is changed, we should invalidate the cached copy and read the latest +content from the file instead of the cache. + +We represent a dependency as an instance of [[\yii\caching\Dependency]] or its child class. We pass the dependency +instance along with the data to be cached when calling `set()`. + +```php +use yii\cache\FileDependency; + +// the value will expire in 30 seconds +// it may also be invalidated earlier if the dependent file is changed +Yii::$app->cache->set($id, $value, 30, new FileDependency(['fileName' => 'example.txt'])); +``` + +Now if we retrieve $value from cache by calling `get()`, the dependency will be evaluated and if it is changed, we will +get a false value, indicating the data needs to be regenerated. + +Below is a summary of the available cache dependencies: + +- [[\yii\cache\FileDependency]]: the dependency is changed if the file's last modification time is changed. +- [[\yii\cache\GroupDependency]]: marks a cached data item with a group name. You may invalidate the cached data items + with the same group name all at once by calling [[\yii\cache\GroupDependency::invalidate()]]. +- [[\yii\cache\DbDependency]]: the dependency is changed if the query result of the specified SQL statement is changed. +- [[\yii\cache\ChainedDependency]]: the dependency is changed if any of the dependencies on the chain is changed. +- [[\yii\cache\ExpressionDependency]]: the dependency is changed if the result of the specified PHP expression is + changed. ### Query Caching diff --git a/docs/guide/controller.md b/docs/guide/controller.md index c07968b..de6cec5 100644 --- a/docs/guide/controller.md +++ b/docs/guide/controller.md @@ -194,9 +194,9 @@ public function behaviors() 'class' => 'yii\web\AccessControl', 'rules' => [ ['allow' => true, 'actions' => ['admin'], 'roles' => ['@']], - ), - ), - ); + ], + ], + ]; } ``` @@ -206,7 +206,7 @@ Two other filters, [[PageCache]] and [[HttpCache]] are described in [caching](ca Catching all incoming requests ------------------------------ -TDB +TBD See also -------- diff --git a/docs/guide/query-builder.md b/docs/guide/query-builder.md index ac79f1d..f775c76 100644 --- a/docs/guide/query-builder.md +++ b/docs/guide/query-builder.md @@ -1,86 +1,98 @@ Query Builder and Query ======================= -Yii provides a basic database access layer as was described in [Database basics](database-basics.md) section. Still it's -a bit too much to use SQL directly all the time. To solve the issue Yii provides a query builder that allows you to -work with the database in object-oriented style. +Yii provides a basic database access layer as described in the [Database basics](database-basics.md) section. The database access layer provides a low-level way to interact with the database. While useful in some situations, it can be tedious to rely too much upon direct SQL. An alternative approach that Yii provides is the Query Builder. The Query Builder provides an object-oriented vehicle for generating queries to be executed. -Basic query builder usage is the following: +Here's a basic example: ```php $query = new Query; -// Define query +// Define the query: $query->select('id, name') - ->from('tbl_user') - ->limit(10); + ->from('tbl_user') + ->limit(10); -// Create a command. You can get the actual SQL using $command->sql +// Create a command. $command = $query->createCommand(); -// Execute command +// You can get the actual SQL using $command->sql + +// Execute the command: $rows = $command->queryAll(); ``` -Basic selects and joins ------------------------ +Basic selects +------------- -In order to form a `SELECT` query you need to specify what to select and where to select it from. +In order to form a basic `SELECT` query, you need to specify what columns to select and from what table: ```php $query->select('id, name') ->from('tbl_user'); ``` -If you want to get IDs of all users with posts you can use `DISTINCT`. With query builder it will look like the following: +Select options can be specified as a comma-separated string, as in the above, or as an array. The array syntax is especially useful when forming the selection dynamically: ```php -$query->select('user_id')->distinct()->from('tbl_post'); +$columns = []; +$columns[] = 'id'; +$columns[] = 'name'; +$query->select($columns) + ->from('tbl_user'); ``` -Select options can be specified as array. It's especially useful when these are formed dynamically. - -```php -$query->select(['tbl_user.name AS author', 'tbl_post.title as title']) // <-- specified as array - ->from('tbl_user') - ->leftJoin('tbl_post', 'tbl_post.user_id = tbl_user.id'); // <-- join with another table -``` +Joins +----- -In the code above we've used `leftJoin` method to select from two related tables at the same time. First parameter -specifies table name and the second is the join condition. Query builder has the following methods to join tables: +Joins are generated in the Query Builder by using the applicable join method: - `innerJoin` - `leftJoin` - `rightJoin` -If your data storage supports more types you can use generic `join` method: +This left join selects data from two related tables in one query: + +```php +$query->select(['tbl_user.name AS author', 'tbl_post.title as title']) ->from('tbl_user') + ->leftJoin('tbl_post', 'tbl_post.user_id = tbl_user.id'); +``` + +In the code, the `leftJion` method's first parameter +specifies the table to join to. The second paramter defines the join condition. + +If your database application supports other join types, you can use those via the generic `join` method: ```php $query->join('FULL OUTER JOIN', 'tbl_post', 'tbl_post.user_id = tbl_user.id'); ``` -Specifying conditions +The first argument is the join type to perform. The second is the table to join to, and the third is the condition. + +Specifying SELECT conditions --------------------- -Usually you need data that matches some conditions. There are some useful methods to specify these and the most powerful -is `where`. There are multiple ways to use it. +Usually data is selected based upon certain criteria. Query Builder has some useful methods to specify these, the most powerful of which being `where`. It can be used in multiple ways. -The simplest is to specify condition in a string: +The simplest way to apply a condition is to use a string: ```php $query->where('status=:status', [':status' => $status]); ``` -When using this format make sure you're binding parameters and not creating a query by string concatenation. +When using strings, make sure you're binding the query parameters, not creating a query by string concatenation. The above approach is safe to use, the following is not: -Instead of binding status value immediately you can do it using `params` or `addParams`: +```php +$query->where("status=$status"); // Dangerous! +``` + +Instead of binding the status value immediately, you can do so using `params` or `addParams`: ```php $query->where('status=:status'); - $query->addParams([':status' => $status]); ``` -There is another convenient way to use the method called hash format: +Multiple conditions can simultaneously be set in `where` using the *hash format*: ```php $query->where([ @@ -90,19 +102,19 @@ $query->where([ ]); ``` -It will generate the following SQL: +That code will generate the following SQL: ```sql WHERE (`status` = 10) AND (`type` = 2) AND (`id` IN (4, 8, 15, 16, 23, 42)) ``` -If you'll specify value as `null` such as the following: +NULL is a special value in databases, and is handled smartly by the Query Builder. This code: ```php $query->where(['status' => null]); ``` -SQL generated will be: +results in this WHERE clause: ```sql WHERE (`status` IS NULL) @@ -174,6 +186,15 @@ $query->orderBy([ Here we are ordering by `id` ascending and then by `name` descending. +Distinct +-------- + +If you want to get IDs of all users with posts you can use `DISTINCT`. With query builder it will look like the following: + +```php +$query->select('user_id')->distinct()->from('tbl_post'); +``` + Group and Having ---------------- diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 4b88a9a..1395d17 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -1,4 +1,4 @@ Testing ======= -TDB \ No newline at end of file +TBD \ No newline at end of file diff --git a/docs/guide/theming.md b/docs/guide/theming.md index 308316a..7be1292 100644 --- a/docs/guide/theming.md +++ b/docs/guide/theming.md @@ -1,4 +1,4 @@ Theming ======= -TDB \ No newline at end of file +TBD \ No newline at end of file diff --git a/docs/guide/validation.md b/docs/guide/validation.md index 1b1e92e..5067cb6 100644 --- a/docs/guide/validation.md +++ b/docs/guide/validation.md @@ -67,9 +67,9 @@ Validates that the attribute value is a valid email address. Validates that the attribute value exists in a table. -- `className` the ActiveRecord class name or alias of the class that should be used to look for the attribute value being +- `targetClass` the ActiveRecord class name or alias of the class that should be used to look for the attribute value being validated. _(ActiveRecord class of the attribute being validated)_ -- `attributeName` the ActiveRecord attribute name that should be used to look for the attribute value being validated. +- `targetAttribute` the ActiveRecord attribute name that should be used to look for the attribute value being validated. _(name of the attribute being validated)_ ### `file`: [[FileValidator]] @@ -112,7 +112,9 @@ Validates that the attribute value is among a list of values. ### `inline`: [[InlineValidator]] -Uses a custom function to validate the attribute. You need to define a public method in your model class which will evaluate the validity of the attribute. For example, if an attribute needs to be divisible by 10. In the rules you would define: `['attributeName', 'myValidationMethod'],`. +Uses a custom function to validate the attribute. You need to define a public method in your +model class which will evaluate the validity of the attribute. For example, if an attribute +needs to be divisible by 10. In the rules you would define: `['attributeName', 'myValidationMethod'],`. Then, your own method could look like this: ```php @@ -161,9 +163,9 @@ Validates that the attribute value is of certain length. Validates that the attribute value is unique in the corresponding database table. -- `className` the ActiveRecord class name or alias of the class that should be used to look for the attribute value being +- `targetClass` the ActiveRecord class name or alias of the class that should be used to look for the attribute value being validated. _(ActiveRecord class of the attribute being validated)_ -- `attributeName` the ActiveRecord attribute name that should be used to look for the attribute value being validated. +- `targetAttribute` the ActiveRecord attribute name that should be used to look for the attribute value being validated. _(name of the attribute being validated)_ ### `url`: [[UrlValidator]] diff --git a/docs/internals/getting-started.md b/docs/internals/getting-started.md index 0d211a5..07b95bc 100644 --- a/docs/internals/getting-started.md +++ b/docs/internals/getting-started.md @@ -7,7 +7,7 @@ Composer package. Here's how to do it: 1. `git clone git@github.com:yiisoft/yii2-app-basic.git`. 2. Remove `.git` directory from cloned directory. 3. Change `composer.json`. Instead of all stable requirements add just one `"yiisoft/yii2-dev": "*"`. -4. Execute `composer install`. +4. Execute `composer create-project`. 5. Now you have working playground that uses latest code. Note that requirements of extensions that come with `yii2-dev` are not loaded automatically. @@ -27,3 +27,5 @@ If you're not core developer or want to use your own fork for pull requests: [remote "origin"] url = git://github.com/username/yii2.git ``` + +> Hint: The workflow of forking a package and pushing changes back into your fork and then sending a pull-request to the maintainer is the same for all extensions you require via composer. diff --git a/docs/internals/versions.md b/docs/internals/versions.md index ba349f6..fe21fd7 100644 --- a/docs/internals/versions.md +++ b/docs/internals/versions.md @@ -1,12 +1,35 @@ Yii version numbering ===================== +Releases +-------- + A.B.C A = For Yii2 it's always 2. B = Major version. Non-BC changes with upgrade instructions. C = BC changes and additions. -A.B.CrcD +Release candidates +------------------ + +A.B.C-rc +A.B.C-rc2 + +This is when we want to do a release candidate. RC number increments till we're getting a stable release with no +critical bugs and backwards incompatibility reports. + +Alphas and betas +---------------- + +A.B.C-alpha +A.B.C-alpha2 + +Alphas are unstable versions where significant bugs may and probably do exist. API isn't fixed yet and may be changed +significantly. `alpha2` etc. may or may not be released based on overall stability of code and API. + +A.B.C-beta +A.B.C-beta2 -This is when we want to release release candidate. D is the RC number. Starts with 1 and increments till we're getting a stable release with no critical bugs and BC incompatibility reports. \ No newline at end of file +Beta is more or less stable with less bugs and API instability than alphas. There still could be changes in API but +there should be a significant reason for it. diff --git a/extensions/yii/bootstrap/ButtonDropdown.php b/extensions/yii/bootstrap/ButtonDropdown.php index 1ffde7d..34d3ae2 100644 --- a/extensions/yii/bootstrap/ButtonDropdown.php +++ b/extensions/yii/bootstrap/ButtonDropdown.php @@ -49,6 +49,14 @@ class ButtonDropdown extends Widget * @var boolean whether to display a group of split-styled button group. */ public $split = false; + /** + * @var string the tag to use to render the button + */ + public $tagName = 'button'; + /** + * @var boolean whether the label should be HTML-encoded. + */ + public $encodeLabel = true; /** @@ -67,8 +75,11 @@ class ButtonDropdown extends Widget protected function renderButton() { Html::addCssClass($this->options, 'btn'); + $label = $this->label; + if ($this->encodeLabel) { + $label = Html::encode($label); + } if ($this->split) { - $tag = 'button'; $options = $this->options; $this->options['data-toggle'] = 'dropdown'; Html::addCssClass($this->options, 'dropdown-toggle'); @@ -78,8 +89,7 @@ class ButtonDropdown extends Widget 'options' => $this->options, ]); } else { - $tag = 'a'; - $this->label .= ' '; + $label .= ' '; $options = $this->options; if (!isset($options['href'])) { $options['href'] = '#'; @@ -89,8 +99,8 @@ class ButtonDropdown extends Widget $splitButton = ''; } return Button::widget([ - 'tagName' => $tag, - 'label' => $this->label, + 'tagName' => $this->tagName, + 'label' => $label, 'options' => $options, 'encodeLabel' => false, ]) . "\n" . $splitButton; diff --git a/extensions/yii/bootstrap/CHANGELOG.md b/extensions/yii/bootstrap/CHANGELOG.md index 0eaa722..d6fbee3 100644 --- a/extensions/yii/bootstrap/CHANGELOG.md +++ b/extensions/yii/bootstrap/CHANGELOG.md @@ -6,6 +6,7 @@ Yii Framework 2 bootstrap extension Change Log - Enh #1474: Added option to make NavBar 100% width (cebe) - Enh #1553: Only add navbar-default class to NavBar when no other class is specified (cebe) +- Enh #1601: Added support for tagName and encodeLabel parameters in ButtonDropdown (omnilight) - Bug #1459: Update Collapse to use bootstrap 3 classes (tonydspaniard) 2.0.0 alpha, December 1, 2013 diff --git a/extensions/yii/bootstrap/Nav.php b/extensions/yii/bootstrap/Nav.php index ef45f09..42e6346 100644 --- a/extensions/yii/bootstrap/Nav.php +++ b/extensions/yii/bootstrap/Nav.php @@ -164,7 +164,7 @@ class Nav extends Widget if ($items !== null) { $linkOptions['data-toggle'] = 'dropdown'; Html::addCssClass($options, 'dropdown'); - Html::addCssClass($urlOptions, 'dropdown-toggle'); + Html::addCssClass($linkOptions, 'dropdown-toggle'); $label .= ' ' . Html::tag('b', '', ['class' => 'caret']); if (is_array($items)) { $items = Dropdown::widget([ diff --git a/extensions/yii/codeception/README.md b/extensions/yii/codeception/README.md index 6df55e3..62af9f0 100644 --- a/extensions/yii/codeception/README.md +++ b/extensions/yii/codeception/README.md @@ -5,9 +5,8 @@ This extension provides [Codeception](http://codeception.com/) integration for t It provides classes that help with testing with codeception: -- a base class for unit-tests: `yii\codeception\TestCase +- a base class for unit-tests: `yii\codeception\TestCase`; - a base class for codeception page-objects: `yii\codeception\BasePage`. -- a solution for testing emails Installation @@ -39,7 +38,55 @@ class to reduce code duplication. Simply extend your page object from this class For unit testing there is a `TestCase` class which holds some common features like application creation before each test and application destroy after each test. You can configure a mock application using this class. -`TestCase` is extended from `PHPUnit_Framework_TestCase` so all methods and assertions are available. +`TestCase` is extended from `Codeception\TestCase\Case` so all methods and assertions are available. +You may use codeception modules and fire events in your test, just use methods: + +```php +getModule('CodeHelper'); #or some other module +``` + +You also can use all guy methods by accessing guy instance like: + +```php +codeGuy->someMethodFromModule(); +``` + +to fire event do this: + +```php +fire('myevent', new TestEvent($this)); +} +``` +this event can be catched in modules and helpers. If your test is in the group, then event name will be followed by the groupname, +for example ```myevent.somegroup```. + +Execution of special tests methods is (for example on ```UserTest``` class): + +``` +tests\unit\models\UserTest::setUpBeforeClass(); + + tests\unit\models\UserTest::_before(); + + tests\unit\models\UserTest::setUp(); + + tests\unit\models\UserTest::testSomething(); + + tests\unit\models\UserTest::tearDown(); + + tests\unit\models\UserTest::_after(); + +tests\unit\models\UserTest::tearDownAfterClass(); +``` + +If you use special methods dont forget to call its parent. ```php * @since 2.0 */ -class TestCase extends \PHPUnit_Framework_TestCase +class TestCase extends Test { /** * @var array|string the application configuration that will be used for creating an application instance for each test. diff --git a/extensions/yii/composer/Installer.php b/extensions/yii/composer/Installer.php index 6f69afc..04d9d08 100644 --- a/extensions/yii/composer/Installer.php +++ b/extensions/yii/composer/Installer.php @@ -44,7 +44,7 @@ class Installer extends LibraryInstaller $this->addPackage($package); // ensure the yii2-dev package also provides Yii.php in the same place as yii2 does if ($package->getName() == 'yiisoft/yii2-dev') { - $this->linkYiiBaseFiles(); + $this->linkBaseYiiFiles(); } } @@ -58,7 +58,7 @@ class Installer extends LibraryInstaller $this->addPackage($target); // ensure the yii2-dev package also provides Yii.php in the same place as yii2 does if ($initial->getName() == 'yiisoft/yii2-dev') { - $this->linkYiiBaseFiles(); + $this->linkBaseYiiFiles(); } } @@ -73,7 +73,7 @@ class Installer extends LibraryInstaller $this->removePackage($package); // remove links for Yii.php if ($package->getName() == 'yiisoft/yii2-dev') { - $this->removeYiiBaseFiles(); + $this->removeBaseYiiFiles(); } } @@ -169,13 +169,13 @@ class Installer extends LibraryInstaller } } - protected function linkYiiBaseFiles() + protected function linkBaseYiiFiles() { $yiiDir = $this->vendorDir . '/yiisoft/yii2/yii'; if (!file_exists($yiiDir)) { mkdir($yiiDir, 0777, true); } - foreach(['Yii.php', 'YiiBase.php', 'classes.php'] as $file) { + foreach(['Yii.php', 'BaseYii.php', 'classes.php'] as $file) { file_put_contents($yiiDir . '/' . $file, <<vendorDir . '/yiisoft/yii2/yii'; - foreach(['Yii.php', 'YiiBase.php', 'classes.php'] as $file) { + foreach(['Yii.php', 'BaseYii.php', 'classes.php'] as $file) { if (file_exists($yiiDir . '/' . $file)) { unlink($yiiDir . '/' . $file); } diff --git a/extensions/yii/gii/views/default/view.php b/extensions/yii/gii/views/default/view.php index dabcf39..d600f83 100644 --- a/extensions/yii/gii/views/default/view.php +++ b/extensions/yii/gii/views/default/view.php @@ -33,7 +33,7 @@ foreach ($generator->templates as $name => $path) { 'fieldConfig' => ['class' => ActiveField::className()], ]); ?>
-
+
renderFile($generator->formView(), [ 'generator' => $generator, 'form' => $form, diff --git a/extensions/yii/gii/views/layouts/generator.php b/extensions/yii/gii/views/layouts/generator.php index 245cd29..d4c205a 100644 --- a/extensions/yii/gii/views/layouts/generator.php +++ b/extensions/yii/gii/views/layouts/generator.php @@ -12,7 +12,7 @@ $activeGenerator = Yii::$app->controller->generator; ?> beginContent('@yii/gii/views/layouts/main.php'); ?>
-
+
$generator) { @@ -24,7 +24,7 @@ $activeGenerator = Yii::$app->controller->generator; ?>
-
+
diff --git a/extensions/yii/jui/CHANGELOG.md b/extensions/yii/jui/CHANGELOG.md index 6f3cfb9..b31c34e 100644 --- a/extensions/yii/jui/CHANGELOG.md +++ b/extensions/yii/jui/CHANGELOG.md @@ -4,7 +4,7 @@ Yii Framework 2 jui extension Change Log 2.0.0 beta under development ---------------------------- -- Bug #1550: Ensure active id to options when using models (tonydspaniard) +- Bug #1550: fixed the issue that JUI input widgets did not property input IDs. 2.0.0 alpha, December 1, 2013 ----------------------------- diff --git a/extensions/yii/jui/DatePicker.php b/extensions/yii/jui/DatePicker.php index 06ca356..bab2abe 100644 --- a/extensions/yii/jui/DatePicker.php +++ b/extensions/yii/jui/DatePicker.php @@ -54,14 +54,30 @@ class DatePicker extends InputWidget * @var boolean If true, shows the widget as an inline calendar and the input as a hidden field. */ public $inline = false; + /** + * @var array the HTML attributes for the container tag. This is only used when [[inline]] is true. + */ + public $containerOptions = []; /** + * @inheritdoc + */ + public function init() + { + parent::init(); + if ($this->inline && !isset($this->containerOptions['id'])) { + $this->containerOptions['id'] = $this->options['id'] . '-container'; + } + } + + /** * Renders the widget. */ public function run() { echo $this->renderWidget() . "\n"; + $containerID = $this->inline ? $this->containerOptions['id'] : $this->options['id']; if ($this->language !== false) { $view = $this->getView(); DatePickerRegionalAsset::register($view); @@ -71,10 +87,10 @@ class DatePicker extends InputWidget $options = $this->clientOptions; $this->clientOptions = false; // the datepicker js widget is already registered - $this->registerWidget('datepicker', DatePickerAsset::className()); + $this->registerWidget('datepicker', DatePickerAsset::className(), $containerID); $this->clientOptions = $options; } else { - $this->registerWidget('datepicker', DatePickerAsset::className()); + $this->registerWidget('datepicker', DatePickerAsset::className(), $containerID); } } @@ -101,8 +117,7 @@ class DatePicker extends InputWidget $this->clientOptions['defaultDate'] = $this->value; } $this->clientOptions['altField'] = '#' . $this->options['id']; - $this->options['id'] .= '-container'; - $contents[] = Html::tag('div', null, $this->options); + $contents[] = Html::tag('div', null, $this->containerOptions); } return implode("\n", $contents); diff --git a/extensions/yii/jui/InputWidget.php b/extensions/yii/jui/InputWidget.php index 68334c7..7aae366 100644 --- a/extensions/yii/jui/InputWidget.php +++ b/extensions/yii/jui/InputWidget.php @@ -45,7 +45,10 @@ class InputWidget extends Widget public function init() { if (!$this->hasModel() && $this->name === null) { - throw new InvalidConfigException("Either 'name' or 'model' and 'attribute' properties must be specified."); + throw new InvalidConfigException("Either 'name', or 'model' and 'attribute' properties must be specified."); + } + if ($this->hasModel() && !isset($this->options['id'])) { + $this->options['id'] = Html::getInputId($this->model, $this->attribute); } if($this->hasModel() && !array_key_exists('id', $this->options)) { $this->options['id'] = Html::getInputId($this->model, $this->attribute); diff --git a/extensions/yii/jui/SliderInput.php b/extensions/yii/jui/SliderInput.php index 8ded4e8..8a43eb1 100644 --- a/extensions/yii/jui/SliderInput.php +++ b/extensions/yii/jui/SliderInput.php @@ -50,30 +50,42 @@ class SliderInput extends InputWidget 'start' => 'slidestart', 'stop' => 'slidestop', ]; + /** + * @var array the HTML attributes for the container tag. + */ + public $containerOptions = []; + + /** + * @inheritdoc + */ + public function init() + { + parent::init(); + if (!isset($this->containerOptions['id'])) { + $this->containerOptions['id'] = $this->options['id'] . '-container'; + } + } /** * Executes the widget. */ public function run() { - echo Html::tag('div', '', $this->options); + echo Html::tag('div', '', $this->containerOptions); - $inputId = $this->id.'-input'; - $inputOptions = $this->options; - $inputOptions['id'] = $inputId; if ($this->hasModel()) { - echo Html::activeHiddenInput($this->model, $this->attribute, $inputOptions); + echo Html::activeHiddenInput($this->model, $this->attribute, $this->options); } else { - echo Html::hiddenInput($this->name, $this->value, $inputOptions); + echo Html::hiddenInput($this->name, $this->value, $this->options); } if (!isset($this->clientEvents['slide'])) { $this->clientEvents['slide'] = 'function(event, ui) { - $("#'.$inputId.'").val(ui.value); + $("#' . $this->options['id'] . '").val(ui.value); }'; } - $this->registerWidget('slider', SliderAsset::className()); - $this->getView()->registerJs('$("#'.$inputId.'").val($("#'.$this->id.'").slider("value"));'); + $this->registerWidget('slider', SliderAsset::className(), $this->containerOptions['id']); + $this->getView()->registerJs('$("#' . $this->options['id'] . '").val($("#' . $this->id . '").slider("value"));'); } } diff --git a/extensions/yii/jui/Widget.php b/extensions/yii/jui/Widget.php index 90bad68..8881a77 100644 --- a/extensions/yii/jui/Widget.php +++ b/extensions/yii/jui/Widget.php @@ -76,11 +76,11 @@ class Widget extends \yii\base\Widget /** * Registers a specific jQuery UI widget options * @param string $name the name of the jQuery UI widget + * @param string $id the ID of the widget */ - protected function registerClientOptions($name) + protected function registerClientOptions($name, $id) { if ($this->clientOptions !== false) { - $id = $this->options['id']; $options = empty($this->clientOptions) ? '' : Json::encode($this->clientOptions); $js = "jQuery('#$id').$name($options);"; $this->getView()->registerJs($js); @@ -90,11 +90,11 @@ class Widget extends \yii\base\Widget /** * Registers a specific jQuery UI widget events * @param string $name the name of the jQuery UI widget + * @param string $id the ID of the widget */ - protected function registerClientEvents($name) + protected function registerClientEvents($name, $id) { if (!empty($this->clientEvents)) { - $id = $this->options['id']; $js = []; foreach ($this->clientEvents as $event => $handler) { if (isset($this->clientEventMap[$event])) { @@ -112,11 +112,15 @@ class Widget extends \yii\base\Widget * Registers a specific jQuery UI widget asset bundle, initializes it with client options and registers related events * @param string $name the name of the jQuery UI widget * @param string $assetBundle the asset bundle for the widget + * @param string $id the ID of the widget. If null, it will use the `id` value of [[options]]. */ - protected function registerWidget($name, $assetBundle) + protected function registerWidget($name, $assetBundle, $id = null) { + if ($id === null) { + $id = $this->options['id']; + } $this->registerAssets($assetBundle); - $this->registerClientOptions($name); - $this->registerClientEvents($name); + $this->registerClientOptions($name, $id); + $this->registerClientEvents($name, $id); } } diff --git a/extensions/yii/sphinx/ActiveRecord.php b/extensions/yii/sphinx/ActiveRecord.php index e7bda34..0f9a48e 100644 --- a/extensions/yii/sphinx/ActiveRecord.php +++ b/extensions/yii/sphinx/ActiveRecord.php @@ -29,7 +29,7 @@ use yii\helpers\StringHelper; * @property mixed $oldPrimaryKey The old primary key value. An array (column name => column value) is * returned if the primary key is composite. A string is returned otherwise (null will be returned if the key * value is null). This property is read-only. - * @property array $populatedRelations An array of relation data indexed by relation names. This property is + * @property array $relatedRecords An array of the populated related records indexed by relation names. This property is * read-only. * @property mixed $primaryKey The primary key value. An array (column name => column value) is returned if * the primary key is composite. A string is returned otherwise (null will be returned if the key value is null). @@ -668,4 +668,4 @@ abstract class ActiveRecord extends BaseActiveRecord $transactions = $this->transactions(); return isset($transactions[$scenario]) && ($transactions[$scenario] & $operation); } -} \ No newline at end of file +} diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index 073a3ce..b6c56c6 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -9,21 +9,35 @@ Yii Framework 2 Change Log - Bug #1500: Log messages exported to files are not separated by newlines (omnilight, qiangxue) - Bug #1509: The SQL for creating Postgres RBAC tables is incorrect (qiangxue) - Bug #1545: It was not possible to execute db Query twice, params where missing (cebe) +- Bug #1550: fixed the issue that JUI input widgets did not property input IDs. +- Bug #1582: Error messages shown via client-side validation should not be double encoded (qiangxue) +- Bug #1591: StringValidator is accessing undefined property (qiangxue) +- Bug #1597: Added `enableAutoLogin` to basic and advanced application templates so "remember me" now works properly (samdark) - Bug: Fixed `Call to a member function registerAssetFiles() on a non-object` in case of wrong `sourcePath` for an asset bundle (samdark) - Bug: Fixed incorrect event name for `yii\jui\Spinner` (samdark) - Bug: Json::encode() did not handle objects that implement JsonSerializable interface correctly (cebe) - Bug: Fixed issue with tabular input on ActiveField::radio() and ActiveField::checkbox() (jom) +- Enh #797: Added support for validating multiple columns by `UniqueValidator` and `ExistValidator` (qiangxue) - Enh #1293: Replaced Console::showProgress() with a better approach. See Console::startProgress() for details (cebe) - Enh #1406: DB Schema support for Oracle Database (p0larbeer, qiangxue) - Enh #1437: Added ListView::viewParams (qiangxue) - Enh #1469: ActiveRecord::find() now works with default conditions (default scope) applied by createQuery (cebe) +- Enh #1499: Added `ActionColumn::controller` property to support customizing the controller for handling GridView actions (qiangxue) - Enh #1523: Query conditions now allow to use the NOT operator (cebe) - Enh #1552: It is now possible to use multiple bootstrap NavBar in a single page (Alex-Code) +- Enh #1572: Added `yii\web\Controller::createAbsoluteUrl()` (samdark) +- Enh #1579: throw exception when the given AR relation name does not match in a case sensitive manner (qiangxue) +- Enh #1581: Added `ActiveQuery::joinWith()` and `ActiveQuery::innerJoinWith()` to support joining with relations (qiangxue) +- Enh #1601: Added support for tagName and encodeLabel parameters in ButtonDropdown (omnilight) - Enh: Added `favicon.ico` and `robots.txt` to defauly application templates (samdark) - Enh: Added `Widget::autoIdPrefix` to support prefixing automatically generated widget IDs (qiangxue) - Enh: Support for file aliases in console command 'message' (omnilight) -- Enh: Sort and Paginiation can now create absolute URLs (cebe) -- Chg: Renamed yii\jui\Widget::clientEventsMap to clientEventMap (qiangxue) +- Enh: Sort and Pagination can now create absolute URLs (cebe) +- Chg #1610: `Html::activeCheckboxList()` and `Html::activeRadioList()` will submit an empty string if no checkbox/radio is selected (qiangxue) +- Chg: Renamed `yii\jui\Widget::clientEventsMap` to `clientEventMap` (qiangxue) +- Chg: Renamed `ActiveRecord::getPopulatedRelations()` to `getRelatedRecords()` (qiangxue) +- Chg: Renamed `attributeName` and `className` to `targetAttribute` and `targetClass` for `UniqueValidator` and `ExistValidator` (qiangxue) +- Chg: Added `yii\widgets\InputWidget::options` (qiangxue) - New #1438: [MongoDB integration](https://github.com/yiisoft/yii2-mongodb) ActiveRecord and Query (klimov-paul) - New #1393: [Codeception testing framework integration](https://github.com/yiisoft/yii2-codeception) (Ragazzo) diff --git a/framework/yii/assets/yii.activeForm.js b/framework/yii/assets/yii.activeForm.js index c1d5bf5..e898efc 100644 --- a/framework/yii/assets/yii.activeForm.js +++ b/framework/yii/assets/yii.activeForm.js @@ -348,7 +348,7 @@ $container.removeClass(data.settings.validatingCssClass + ' ' + data.settings.successCssClass) .addClass(data.settings.errorCssClass); } else { - $error.html(''); + $error.text(''); $container.removeClass(data.settings.validatingCssClass + ' ' + data.settings.errorCssClass + ' ') .addClass(data.settings.successCssClass); } @@ -365,15 +365,15 @@ var updateSummary = function ($form, messages) { var data = $form.data('yiiActiveForm'), $summary = $form.find(data.settings.errorSummary), - content = ''; + $ul = $summary.find('ul'); if ($summary.length && messages) { $.each(data.attributes, function () { if ($.isArray(messages[this.name]) && messages[this.name].length) { - content += '
  • ' + messages[this.name][0] + '
  • '; + $ul.append($('
  • ').text(messages[this.name][0])); } }); - $summary.toggle(content !== '').find('ul').html(content); + $summary.toggle($ul.find('li').length > 0); } }; diff --git a/framework/yii/captcha/Captcha.php b/framework/yii/captcha/Captcha.php index 76090a2..18b8765 100644 --- a/framework/yii/captcha/Captcha.php +++ b/framework/yii/captcha/Captcha.php @@ -39,10 +39,6 @@ class Captcha extends InputWidget */ public $captchaAction = 'site/captcha'; /** - * @var array HTML attributes to be applied to the text input field. - */ - public $options = []; - /** * @var array HTML attributes to be applied to the CAPTCHA image tag. */ public $imageOptions = []; @@ -62,9 +58,6 @@ class Captcha extends InputWidget $this->checkRequirements(); - if (!isset($this->options['id'])) { - $this->options['id'] = $this->hasModel() ? Html::getInputId($this->model, $this->attribute) : $this->getId(); - } if (!isset($this->imageOptions['id'])) { $this->imageOptions['id'] = $this->options['id'] . '-image'; } diff --git a/framework/yii/captcha/CaptchaValidator.php b/framework/yii/captcha/CaptchaValidator.php index 83996d5..57665ec 100644 --- a/framework/yii/captcha/CaptchaValidator.php +++ b/framework/yii/captcha/CaptchaValidator.php @@ -93,9 +93,9 @@ class CaptchaValidator extends Validator 'hash' => $hash, 'hashKey' => 'yiiCaptcha/' . $this->captchaAction, 'caseSensitive' => $this->caseSensitive, - 'message' => Html::encode(strtr($this->message, [ + 'message' => strtr($this->message, [ '{attribute}' => $object->getAttributeLabel($attribute), - ])), + ]), ]; if ($this->skipOnEmpty) { $options['skipOnEmpty'] = 1; diff --git a/framework/yii/db/ActiveQuery.php b/framework/yii/db/ActiveQuery.php index e93e9be..26b0c6e 100644 --- a/framework/yii/db/ActiveQuery.php +++ b/framework/yii/db/ActiveQuery.php @@ -68,6 +68,9 @@ class ActiveQuery extends Query implements ActiveQueryInterface $rows = $command->queryAll(); if (!empty($rows)) { $models = $this->createModels($rows); + if (!empty($this->join) && $this->indexBy === null) { + $models = $this->removeDuplicatedModels($models); + } if (!empty($this->with)) { $this->findWith($this->with, $models); } @@ -78,6 +81,47 @@ class ActiveQuery extends Query implements ActiveQueryInterface } /** + * Removes duplicated models by checking their primary key values. + * This method is mainly called when a join query is performed, which may cause duplicated rows being returned. + * @param array $models the models to be checked + * @return array the distinctive models + */ + private function removeDuplicatedModels($models) + { + $hash = []; + /** @var ActiveRecord $class */ + $class = $this->modelClass; + $pks = $class::primaryKey(); + + if (count($pks) > 1) { + foreach ($models as $i => $model) { + $key = []; + foreach ($pks as $pk) { + $key[] = $model[$pk]; + } + $key = serialize($key); + if (isset($hash[$key])) { + unset($models[$i]); + } else { + $hash[$key] = true; + } + } + } else { + $pk = reset($pks); + foreach ($models as $i => $model) { + $key = $model[$pk]; + if (isset($hash[$key])) { + unset($models[$i]); + } else { + $hash[$key] = true; + } + } + } + + return array_values($models); + } + + /** * Executes query and returns a single row of result. * @param Connection $db the DB connection used to create the DB command. * If null, the DB connection returned by [[modelClass]] will be used. @@ -143,4 +187,223 @@ class ActiveQuery extends Query implements ActiveQueryInterface } return $db->createCommand($sql, $params); } + + /** + * Joins with the specified relations. + * + * This method allows you to reuse existing relation definitions to perform JOIN queries. + * Based on the definition of the specified relation(s), the method will append one or multiple + * JOIN statements to the current query. + * + * If the `$eagerLoading` parameter is true, the method will also eager loading the specified relations, + * which is equivalent to calling [[with()]] using the specified relations. + * + * Note that because a JOIN query will be performed, you are responsible to disambiguate column names. + * + * This method differs from [[with()]] in that it will build up and execute a JOIN SQL statement + * for the primary table. And when `$eagerLoading` is true, it will call [[with()]] in addition with the specified relations. + * + * @param array $with the relations to be joined. Each array element represents a single relation. + * The array keys are relation names, and the array values are the corresponding anonymous functions that + * can be used to modify the relation queries on-the-fly. If a relation query does not need modification, + * you may use the relation name as the array value. Sub-relations can also be specified (see [[with()]]). + * For example, + * + * ```php + * // find all orders that contain books, and eager loading "books" + * Order::find()->joinWith('books', true, 'INNER JOIN')->all(); + * // find all orders, eager loading "books", and sort the orders and books by the book names. + * Order::find()->joinWith([ + * 'books' => function ($query) { + * $query->orderBy('tbl_item.name'); + * } + * ])->all(); + * ``` + * + * @param boolean|array $eagerLoading whether to eager load the relations specified in `$with`. + * When this is a boolean, it applies to all relations specified in `$with`. Use an array + * to explicitly list which relations in `$with` need to be eagerly loaded. + * @param string|array $joinType the join type of the relations specified in `$with`. + * When this is a string, it applies to all relations specified in `$with`. Use an array + * in the format of `relationName => joinType` to specify different join types for different relations. + * @return static the query object itself + */ + public function joinWith($with, $eagerLoading = true, $joinType = 'LEFT JOIN') + { + $with = (array)$with; + $this->joinWithRelations(new $this->modelClass, $with, $joinType); + + if (is_array($eagerLoading)) { + foreach ($with as $name => $callback) { + if (is_integer($name)) { + if (!in_array($callback, $eagerLoading, true)) { + unset($with[$name]); + } + } elseif (!in_array($name, $eagerLoading, true)) { + unset($with[$name]); + } + } + $this->with($with); + } elseif ($eagerLoading) { + $this->with($with); + } + return $this; + } + + /** + * Inner joins with the specified relations. + * This is a shortcut method to [[joinWith()]] with the join type set as "INNER JOIN". + * Please refer to [[joinWith()]] for detailed usage of this method. + * @param array $with the relations to be joined with + * @param boolean|array $eagerLoading whether to eager loading the relations + * @return static the query object itself + * @see joinWith() + */ + public function innerJoinWith($with, $eagerLoading = true) + { + return $this->joinWith($with, $eagerLoading, 'INNER JOIN'); + } + + /** + * Modifies the current query by adding join fragments based on the given relations. + * @param ActiveRecord $model the primary model + * @param array $with the relations to be joined + * @param string|array $joinType the join type + */ + private function joinWithRelations($model, $with, $joinType) + { + $relations = []; + + foreach ($with as $name => $callback) { + if (is_integer($name)) { + $name = $callback; + $callback = null; + } + + $primaryModel = $model; + $parent = $this; + $prefix = ''; + while (($pos = strpos($name, '.')) !== false) { + $childName = substr($name, $pos + 1); + $name = substr($name, 0, $pos); + $fullName = $prefix === '' ? $name : "$prefix.$name"; + if (!isset($relations[$fullName])) { + $relations[$fullName] = $relation = $primaryModel->getRelation($name); + $this->joinWithRelation($parent, $relation, $this->getJoinType($joinType, $fullName)); + } else { + $relation = $relations[$fullName]; + } + $primaryModel = new $relation->modelClass; + $parent = $relation; + $prefix = $fullName; + $name = $childName; + } + + $fullName = $prefix === '' ? $name : "$prefix.$name"; + if (!isset($relations[$fullName])) { + $relations[$fullName] = $relation = $primaryModel->getRelation($name); + if ($callback !== null) { + call_user_func($callback, $relation); + } + $this->joinWithRelation($parent, $relation, $this->getJoinType($joinType, $fullName)); + } + } + } + + /** + * Returns the join type based on the given join type parameter and the relation name. + * @param string|array $joinType the given join type(s) + * @param string $name relation name + * @return string the real join type + */ + private function getJoinType($joinType, $name) + { + if (is_array($joinType) && isset($joinType[$name])) { + return $joinType[$name]; + } else { + return is_string($joinType) ? $joinType : 'INNER JOIN'; + } + } + + /** + * Returns the table name used by the specified active query. + * @param ActiveQuery $query + * @return string the table name + */ + private function getQueryTableName($query) + { + if (empty($query->from)) { + /** @var ActiveRecord $modelClass */ + $modelClass = $query->modelClass; + return $modelClass::tableName(); + } else { + return reset($query->from); + } + } + + /** + * Joins a parent query with a child query. + * The current query object will be modified accordingly. + * @param ActiveQuery $parent + * @param ActiveRelation $child + * @param string $joinType + */ + private function joinWithRelation($parent, $child, $joinType) + { + $via = $child->via; + $child->via = null; + if ($via instanceof ActiveRelation) { + // via table + $this->joinWithRelation($parent, $via, $joinType); + $this->joinWithRelation($via, $child, $joinType); + return; + } elseif (is_array($via)) { + // via relation + $this->joinWithRelation($parent, $via[1], $joinType); + $this->joinWithRelation($via[1], $child, $joinType); + return; + } + + $parentTable = $this->getQueryTableName($parent); + $childTable = $this->getQueryTableName($child); + + + if (!empty($child->link)) { + $on = []; + foreach ($child->link as $childColumn => $parentColumn) { + $on[] = '{{' . $parentTable . "}}.[[$parentColumn]] = {{" . $childTable . "}}.[[$childColumn]]"; + } + $on = implode(' AND ', $on); + } else { + $on = ''; + } + $this->join($joinType, $childTable, $on); + + + if (!empty($child->where)) { + $this->andWhere($child->where); + } + if (!empty($child->having)) { + $this->andHaving($child->having); + } + if (!empty($child->orderBy)) { + $this->addOrderBy($child->orderBy); + } + if (!empty($child->groupBy)) { + $this->addGroupBy($child->groupBy); + } + if (!empty($child->params)) { + $this->addParams($child->params); + } + if (!empty($child->join)) { + foreach ($child->join as $join) { + $this->join[] = $join; + } + } + if (!empty($child->union)) { + foreach ($child->union as $union) { + $this->union[] = $union; + } + } + } } diff --git a/framework/yii/db/ActiveRecordInterface.php b/framework/yii/db/ActiveRecordInterface.php index 556384b..73db852 100644 --- a/framework/yii/db/ActiveRecordInterface.php +++ b/framework/yii/db/ActiveRecordInterface.php @@ -70,6 +70,23 @@ interface ActiveRecordInterface public function getPrimaryKey($asArray = false); /** + * Returns the old primary key value(s). + * This refers to the primary key value that is populated into the record + * after executing a find method (e.g. find(), findAll()). + * The value remains unchanged even if the primary key attribute is manually assigned with a different value. + * @param boolean $asArray whether to return the primary key value as an array. If true, + * the return value will be an array with column name as key and column value as value. + * If this is false (default), a scalar value will be returned for non-composite primary key. + * @property mixed The old primary key value. An array (column name => column value) is + * returned if the primary key is composite. A string is returned otherwise (null will be + * returned if the key value is null). + * @return mixed the old primary key value. An array (column name => column value) is returned if the primary key + * is composite or `$asArray` is true. A string is returned otherwise (null will be returned if + * the key value is null). + */ + public function getOldPrimaryKey($asArray = false); + + /** * Creates an [[ActiveQueryInterface|ActiveQuery]] instance for query purpose. * * This method is usually ment to be used like this: @@ -290,4 +307,4 @@ interface ActiveRecordInterface * If true, the model containing the foreign key will be deleted. */ public function unlink($name, $model, $delete = false); -} \ No newline at end of file +} diff --git a/framework/yii/db/ActiveRelationTrait.php b/framework/yii/db/ActiveRelationTrait.php index c885006..dac3028 100644 --- a/framework/yii/db/ActiveRelationTrait.php +++ b/framework/yii/db/ActiveRelationTrait.php @@ -189,26 +189,6 @@ trait ActiveRelationTrait } /** - * @param ActiveRecord|array $model - * @param array $attributes - * @return string - */ - private function getModelKey($model, $attributes) - { - if (count($attributes) > 1) { - $key = []; - foreach ($attributes as $attribute) { - $key[] = $model[$attribute]; - } - return serialize($key); - } else { - $attribute = reset($attributes); - $key = $model[$attribute]; - return is_scalar($key) ? $key : serialize($key); - } - } - - /** * @param array $models */ private function filterByModels($models) @@ -237,6 +217,26 @@ trait ActiveRelationTrait } /** + * @param ActiveRecord|array $model + * @param array $attributes + * @return string + */ + private function getModelKey($model, $attributes) + { + if (count($attributes) > 1) { + $key = []; + foreach ($attributes as $attribute) { + $key[] = $model[$attribute]; + } + return serialize($key); + } else { + $attribute = reset($attributes); + $key = $model[$attribute]; + return is_scalar($key) ? $key : serialize($key); + } + } + + /** * @param array $primaryModels either array of AR instances or arrays * @return array */ diff --git a/framework/yii/db/BaseActiveRecord.php b/framework/yii/db/BaseActiveRecord.php index dae7134..e20501b 100644 --- a/framework/yii/db/BaseActiveRecord.php +++ b/framework/yii/db/BaseActiveRecord.php @@ -30,7 +30,7 @@ use yii\helpers\Inflector; * @property mixed $oldPrimaryKey The old primary key value. An array (column name => column value) is * returned if the primary key is composite. A string is returned otherwise (null will be returned if the key * value is null). This property is read-only. - * @property array $populatedRelations An array of relation data indexed by relation names. This property is + * @property array $relatedRecords An array of the populated related records indexed by relation names. This property is * read-only. * @property mixed $primaryKey The primary key value. An array (column name => column value) is returned if * the primary key is composite. A string is returned otherwise (null will be returned if the key value is null). @@ -232,6 +232,13 @@ abstract class BaseActiveRecord extends Model implements ActiveRecordInterface } $value = parent::__get($name); if ($value instanceof ActiveRelationInterface) { + if (method_exists($this, 'get' . $name)) { + $method = new \ReflectionMethod($this, 'get' . $name); + $realName = lcfirst(substr($method->getName(), 3)); + if ($realName !== $name) { + throw new InvalidParamException('Relation names are case sensitive. ' . get_class($this) . " has a relation named \"$realName\" instead of \"$name\"."); + } + } return $this->_related[$name] = $value->multiple ? $value->all() : $value->one(); } else { return $value; @@ -390,10 +397,10 @@ abstract class BaseActiveRecord extends Model implements ActiveRecordInterface } /** - * Returns all populated relations. - * @return array an array of relation data indexed by relation names. + * Returns all populated related records. + * @return array an array of related records indexed by relation names. */ - public function getPopulatedRelations() + public function getRelatedRecords() { return $this->_related; } @@ -999,15 +1006,25 @@ abstract class BaseActiveRecord extends Model implements ActiveRecordInterface { $getter = 'get' . $name; try { + // the relation could be defined in a behavior $relation = $this->$getter(); - if ($relation instanceof ActiveRelationInterface) { - return $relation; - } else { - throw new InvalidParamException(get_class($this) . ' has no relation named "' . $name . '".'); - } } catch (UnknownMethodException $e) { throw new InvalidParamException(get_class($this) . ' has no relation named "' . $name . '".', 0, $e); } + if (!$relation instanceof ActiveRelationInterface) { + throw new InvalidParamException(get_class($this) . ' has no relation named "' . $name . '".'); + } + + if (method_exists($this, $getter)) { + // relation name is case sensitive, trying to validate it when the relation is defined within this class + $method = new \ReflectionMethod($this, $getter); + $realName = lcfirst(substr($method->getName(), 3)); + if ($realName !== $name) { + throw new InvalidParamException('Relation names are case sensitive. ' . get_class($this) . " has a relation named \"$realName\" instead of \"$name\"."); + } + } + + return $relation; } /** @@ -1217,11 +1234,10 @@ abstract class BaseActiveRecord extends Model implements ActiveRecordInterface public static function isPrimaryKey($keys) { $pks = static::primaryKey(); - foreach ($keys as $key) { - if (!in_array($key, $pks, true)) { - return false; - } + if (count($keys) === count($pks)) { + return count(array_intersect($keys, $pks)) === count($pks); + } else { + return false; } - return count($keys) === count($pks); } } diff --git a/framework/yii/db/Query.php b/framework/yii/db/Query.php index ee24c2f..2baa78c 100644 --- a/framework/yii/db/Query.php +++ b/framework/yii/db/Query.php @@ -148,7 +148,7 @@ class Query extends Component implements QueryInterface * Executes the query and returns a single row of result. * @param Connection $db the database connection used to generate the SQL statement. * If this parameter is not given, the `db` application component will be used. - * @return array|boolean the first row (in terms of an array) of the query result. False is returned if the query + * @return array|boolean the first row (in terms of an array) of the query result. Null is returned if the query * results in nothing. */ public function one($db = null) diff --git a/framework/yii/db/mssql/QueryBuilder.php b/framework/yii/db/mssql/QueryBuilder.php index 338a74b..77b9532 100644 --- a/framework/yii/db/mssql/QueryBuilder.php +++ b/framework/yii/db/mssql/QueryBuilder.php @@ -60,6 +60,47 @@ class QueryBuilder extends \yii\db\QueryBuilder // } /** + * Builds a SQL statement for renaming a DB table. + * @param string $table the table to be renamed. The name will be properly quoted by the method. + * @param string $newName the new table name. The name will be properly quoted by the method. + * @return string the SQL statement for renaming a DB table. + */ + public function renameTable($table, $newName) + { + return "sp_rename '$table', '$newName'"; + } + + /** + * Builds a SQL statement for renaming a column. + * @param string $table the table whose column is to be renamed. The name will be properly quoted by the method. + * @param string $name the old name of the column. The name will be properly quoted by the method. + * @param string $newName the new name of the column. The name will be properly quoted by the method. + * @return string the SQL statement for renaming a DB column. + */ + public function renameColumn($table, $name, $newName) + { + return "sp_rename '$table.$name', '$newName', 'COLUMN'"; + } + + /** + * Builds a SQL statement for changing the definition of a column. + * @param string $table the table whose column is to be changed. The table name will be properly quoted by the method. + * @param string $column the name of the column to be changed. The name will be properly quoted by the method. + * @param string $type the new column type. The {@link getColumnType} method will be invoked to convert abstract column type (if any) + * into the physical one. Anything that is not recognized as abstract type will be kept in the generated SQL. + * For example, 'string' will be turned into 'varchar(255)', while 'string not null' will become 'varchar(255) not null'. + * @return string the SQL statement for changing the definition of a column. + */ + public function alterColumn($table, $column, $type) + { + $type=$this->getColumnType($type); + $sql='ALTER TABLE ' . $this->db->quoteTableName($table) . ' ALTER COLUMN ' + . $this->db->quoteColumnName($column) . ' ' + . $this->getColumnType($type); + return $sql; + } + + /** * Builds a SQL statement for enabling or disabling integrity check. * @param boolean $check whether to turn on or off the integrity check. * @param string $schema the schema of the tables. Defaults to empty string, meaning the current or default schema. diff --git a/framework/yii/db/pgsql/Schema.php b/framework/yii/db/pgsql/Schema.php index f6c7298..eb7de37 100644 --- a/framework/yii/db/pgsql/Schema.php +++ b/framework/yii/db/pgsql/Schema.php @@ -299,7 +299,7 @@ SQL; $table->columns[$column->name] = $column; if ($column->isPrimaryKey === true) { $table->primaryKey[] = $column->name; - if ($table->sequenceName === null && preg_match("/nextval\\('\"?\\w+\"?'(::regclass)?\\)/", $column->defaultValue) === 1) { + if ($table->sequenceName === null && preg_match("/nextval\\('\"?\\w+\"?\.?\"?\\w+\"?'(::regclass)?\\)/", $column->defaultValue) === 1) { $table->sequenceName = preg_replace(['/nextval/', '/::/', '/regclass/', '/\'\)/', '/\(\'/'], '', $column->defaultValue); } } diff --git a/framework/yii/grid/ActionColumn.php b/framework/yii/grid/ActionColumn.php index 707d411..b53b606 100644 --- a/framework/yii/grid/ActionColumn.php +++ b/framework/yii/grid/ActionColumn.php @@ -19,6 +19,13 @@ use yii\helpers\Html; */ class ActionColumn extends Column { + /** + * @var string the ID of the controller that should handle the actions specified here. + * If not set, it will use the currently active controller. This property is mainly used by + * [[urlCreator]] to create URLs for different actions. The value of this property will be prefixed + * to each action name to form the route of the action. + */ + public $controller; public $template = '{view} {update} {delete}'; public $buttons = []; public $urlCreator; @@ -75,7 +82,8 @@ class ActionColumn extends Column return call_user_func($this->urlCreator, $model, $key, $index, $action); } else { $params = is_array($key) ? $key : ['id' => $key]; - return Yii::$app->controller->createUrl($action, $params); + $route = $this->controller ? $this->controller . '/' . $action : $action; + return Yii::$app->controller->createUrl($route, $params); } } diff --git a/framework/yii/helpers/BaseHtml.php b/framework/yii/helpers/BaseHtml.php index 2cfcb15..49fe832 100644 --- a/framework/yii/helpers/BaseHtml.php +++ b/framework/yii/helpers/BaseHtml.php @@ -1281,7 +1281,8 @@ class BaseHtml * @param array $options options (name => config) for the checkbox list. The following options are specially handled: * * - unselect: string, the value that should be submitted when none of the checkboxes is selected. - * By setting this option, a hidden input will be generated. + * You may set this option to be null to prevent default value submission. + * If this option is not set, an empty string will be submitted. * - separator: string, the HTML code that separates items. * - item: callable, a callback that can be used to customize the generation of the HTML code * corresponding to a single item in $items. The signature of this callback must be: @@ -1300,7 +1301,7 @@ class BaseHtml $name = isset($options['name']) ? $options['name'] : static::getInputName($model, $attribute); $selection = static::getAttributeValue($model, $attribute); if (!array_key_exists('unselect', $options)) { - $options['unselect'] = '0'; + $options['unselect'] = ''; } if (!array_key_exists('id', $options)) { $options['id'] = static::getInputId($model, $attribute); @@ -1321,7 +1322,8 @@ class BaseHtml * @param array $options options (name => config) for the radio button list. The following options are specially handled: * * - unselect: string, the value that should be submitted when none of the radio buttons is selected. - * By setting this option, a hidden input will be generated. + * You may set this option to be null to prevent default value submission. + * If this option is not set, an empty string will be submitted. * - separator: string, the HTML code that separates items. * - item: callable, a callback that can be used to customize the generation of the HTML code * corresponding to a single item in $items. The signature of this callback must be: @@ -1340,7 +1342,7 @@ class BaseHtml $name = isset($options['name']) ? $options['name'] : static::getInputName($model, $attribute); $selection = static::getAttributeValue($model, $attribute); if (!array_key_exists('unselect', $options)) { - $options['unselect'] = '0'; + $options['unselect'] = ''; } if (!array_key_exists('id', $options)) { $options['id'] = static::getInputId($model, $attribute); diff --git a/framework/yii/validators/BooleanValidator.php b/framework/yii/validators/BooleanValidator.php index 961ed14..8bca827 100644 --- a/framework/yii/validators/BooleanValidator.php +++ b/framework/yii/validators/BooleanValidator.php @@ -72,11 +72,11 @@ class BooleanValidator extends Validator $options = [ 'trueValue' => $this->trueValue, 'falseValue' => $this->falseValue, - 'message' => Html::encode(strtr($this->message, [ + 'message' => strtr($this->message, [ '{attribute}' => $object->getAttributeLabel($attribute), '{true}' => $this->trueValue, '{false}' => $this->falseValue, - ])), + ]), ]; if ($this->skipOnEmpty) { $options['skipOnEmpty'] = 1; diff --git a/framework/yii/validators/CompareValidator.php b/framework/yii/validators/CompareValidator.php index 69bd6d5..cbd12d2 100644 --- a/framework/yii/validators/CompareValidator.php +++ b/framework/yii/validators/CompareValidator.php @@ -195,11 +195,11 @@ class CompareValidator extends Validator $options['skipOnEmpty'] = 1; } - $options['message'] = Html::encode(strtr($this->message, [ + $options['message'] = strtr($this->message, [ '{attribute}' => $object->getAttributeLabel($attribute), '{compareAttribute}' => $compareValue, '{compareValue}' => $compareValue, - ])); + ]); ValidationAsset::register($view); return 'yii.validation.compare(value, messages, ' . json_encode($options) . ');'; diff --git a/framework/yii/validators/EmailValidator.php b/framework/yii/validators/EmailValidator.php index 24eeaec..e5d9b75 100644 --- a/framework/yii/validators/EmailValidator.php +++ b/framework/yii/validators/EmailValidator.php @@ -98,9 +98,9 @@ class EmailValidator extends Validator 'pattern' => new JsExpression($this->pattern), 'fullPattern' => new JsExpression($this->fullPattern), 'allowName' => $this->allowName, - 'message' => Html::encode(strtr($this->message, [ + 'message' => strtr($this->message, [ '{attribute}' => $object->getAttributeLabel($attribute), - ])), + ]), 'enableIDN' => (boolean)$this->enableIDN, ]; if ($this->skipOnEmpty) { diff --git a/framework/yii/validators/ExistValidator.php b/framework/yii/validators/ExistValidator.php index 585b82f..7e783a8 100644 --- a/framework/yii/validators/ExistValidator.php +++ b/framework/yii/validators/ExistValidator.php @@ -13,29 +13,47 @@ use yii\base\InvalidConfigException; /** * ExistValidator validates that the attribute value exists in a table. * + * ExistValidator checks if the value being validated can be found in the table column specified by + * the ActiveRecord class [[targetClass]] and the attribute [[targetAttribute]]. + * * This validator is often used to verify that a foreign key contains a value * that can be found in the foreign table. * + * The followings are examples of validation rules using this validator: + * + * ```php + * // a1 needs to exist + * ['a1', 'exist'] + * // a1 needs to exist, but its value will use a2 to check for the existence + * ['a1', 'exist', 'targetAttribute' => 'a2'] + * // a1 and a2 need to exist together, and they both will receive error message + * ['a1, a2', 'exist', 'targetAttribute' => ['a1', 'a2']] + * // a1 and a2 need to exist together, only a1 will receive error message + * ['a1', 'exist', 'targetAttribute' => ['a1', 'a2']] + * // a1 needs to exist by checking the existence of both a2 and a3 (using a1 value) + * ['a1', 'exist', 'targetAttribute' => ['a2', 'a1' => 'a3']] + * ``` + * * @author Qiang Xue * @since 2.0 */ class ExistValidator extends Validator { /** - * @var string the ActiveRecord class name or alias of the class - * that should be used to look for the attribute value being validated. - * Defaults to null, meaning using the ActiveRecord class of - * the attribute being validated. - * @see attributeName + * @var string the name of the ActiveRecord class that should be used to validate the existence + * of the current attribute value. It not set, it will use the ActiveRecord class of the attribute being validated. + * @see targetAttribute */ - public $className; + public $targetClass; /** - * @var string the yii\db\ActiveRecord class attribute name that should be - * used to look for the attribute value being validated. Defaults to null, - * meaning using the name of the attribute being validated. - * @see className + * @var string|array the name of the ActiveRecord attribute that should be used to + * validate the existence 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 existence + * of multiple columns at the same time. The array values are the attributes that will be + * used to validate the existence, 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. */ - public $attributeName; + public $targetAttribute; /** @@ -54,19 +72,28 @@ class ExistValidator extends Validator */ public function validateAttribute($object, $attribute) { - $value = $object->$attribute; + /** @var \yii\db\ActiveRecordInterface $targetClass */ + $targetClass = $this->targetClass === null ? get_class($object) : $this->targetClass; + $targetAttribute = $this->targetAttribute === null ? $attribute : $this->targetAttribute; - if (is_array($value)) { - $this->addError($object, $attribute, $this->message); - return; + if (is_array($targetAttribute)) { + $params = []; + foreach ($targetAttribute as $k => $v) { + $params[$v] = is_integer($k) ? $object->$v : $object->$k; + } + } else { + $params = [$targetAttribute => $object->$attribute]; + } + + foreach ($params as $value) { + if (is_array($value)) { + $this->addError($object, $attribute, Yii::t('yii', '{attribute} is invalid.')); + return; + } } - /** @var \yii\db\ActiveRecord $className */ - $className = $this->className === null ? get_class($object) : $this->className; - $attributeName = $this->attributeName === null ? $attribute : $this->attributeName; - $query = $className::find(); - $query->where([$attributeName => $value]); - if (!$query->exists()) { + /** @var \yii\db\ActiveRecordInterface $className */ + if (!$targetClass::find()->where($params)->exists()) { $this->addError($object, $attribute, $this->message); } } @@ -79,16 +106,17 @@ class ExistValidator extends Validator if (is_array($value)) { return [$this->message, []]; } - if ($this->className === null) { + if ($this->targetClass === null) { throw new InvalidConfigException('The "className" property must be set.'); } - if ($this->attributeName === null) { - throw new InvalidConfigException('The "attributeName" property must be set.'); + if (!is_string($this->targetAttribute)) { + throw new InvalidConfigException('The "attributeName" property must be configured as a string.'); } - /** @var \yii\db\ActiveRecord $className */ - $className = $this->className; - $query = $className::find(); - $query->where([$this->attributeName => $value]); + + /** @var \yii\db\ActiveRecordInterface $targetClass */ + $targetClass = $this->targetClass; + $query = $targetClass::find(); + $query->where([$this->targetAttribute => $value]); return $query->exists() ? null : [$this->message, []]; } } diff --git a/framework/yii/validators/NumberValidator.php b/framework/yii/validators/NumberValidator.php index 60e920a..1bb2360 100644 --- a/framework/yii/validators/NumberValidator.php +++ b/framework/yii/validators/NumberValidator.php @@ -124,24 +124,24 @@ class NumberValidator extends Validator $options = [ 'pattern' => new JsExpression($this->integerOnly ? $this->integerPattern : $this->numberPattern), - 'message' => Html::encode(strtr($this->message, [ + 'message' => strtr($this->message, [ '{attribute}' => $label, - ])), + ]), ]; if ($this->min !== null) { $options['min'] = $this->min; - $options['tooSmall'] = Html::encode(strtr($this->tooSmall, [ + $options['tooSmall'] = strtr($this->tooSmall, [ '{attribute}' => $label, '{min}' => $this->min, - ])); + ]); } if ($this->max !== null) { $options['max'] = $this->max; - $options['tooBig'] = Html::encode(strtr($this->tooBig, [ + $options['tooBig'] = strtr($this->tooBig, [ '{attribute}' => $label, '{max}' => $this->max, - ])); + ]); } if ($this->skipOnEmpty) { $options['skipOnEmpty'] = 1; diff --git a/framework/yii/validators/RangeValidator.php b/framework/yii/validators/RangeValidator.php index cfd1f51..a4da139 100644 --- a/framework/yii/validators/RangeValidator.php +++ b/framework/yii/validators/RangeValidator.php @@ -73,9 +73,9 @@ class RangeValidator extends Validator $options = [ 'range' => $range, 'not' => $this->not, - 'message' => Html::encode(strtr($this->message, [ + 'message' => strtr($this->message, [ '{attribute}' => $object->getAttributeLabel($attribute), - ])), + ]), ]; if ($this->skipOnEmpty) { $options['skipOnEmpty'] = 1; diff --git a/framework/yii/validators/RegularExpressionValidator.php b/framework/yii/validators/RegularExpressionValidator.php index 7b02381..28e9bdc 100644 --- a/framework/yii/validators/RegularExpressionValidator.php +++ b/framework/yii/validators/RegularExpressionValidator.php @@ -80,9 +80,9 @@ class RegularExpressionValidator extends Validator $options = [ 'pattern' => new JsExpression($pattern), 'not' => $this->not, - 'message' => Html::encode(strtr($this->message, [ + 'message' => strtr($this->message, [ '{attribute}' => $object->getAttributeLabel($attribute), - ])), + ]), ]; if ($this->skipOnEmpty) { $options['skipOnEmpty'] = 1; diff --git a/framework/yii/validators/RequiredValidator.php b/framework/yii/validators/RequiredValidator.php index 43b40cf..f291f39 100644 --- a/framework/yii/validators/RequiredValidator.php +++ b/framework/yii/validators/RequiredValidator.php @@ -101,9 +101,9 @@ class RequiredValidator extends Validator $options['strict'] = 1; } - $options['message'] = Html::encode(strtr($options['message'], [ + $options['message'] = strtr($options['message'], [ '{attribute}' => $object->getAttributeLabel($attribute), - ])); + ]); ValidationAsset::register($view); return 'yii.validation.required(value, messages, ' . json_encode($options) . ');'; diff --git a/framework/yii/validators/StringValidator.php b/framework/yii/validators/StringValidator.php index a93fb72..279a189 100644 --- a/framework/yii/validators/StringValidator.php +++ b/framework/yii/validators/StringValidator.php @@ -151,31 +151,31 @@ class StringValidator extends Validator $label = $object->getAttributeLabel($attribute); $options = [ - 'message' => Html::encode(strtr($this->message, [ + 'message' => strtr($this->message, [ '{attribute}' => $label, - ])), + ]), ]; if ($this->min !== null) { $options['min'] = $this->min; - $options['tooShort'] = Html::encode(strtr($this->tooShort, [ + $options['tooShort'] = strtr($this->tooShort, [ '{attribute}' => $label, '{min}' => $this->min, - ])); + ]); } if ($this->max !== null) { $options['max'] = $this->max; - $options['tooLong'] = Html::encode(strtr($this->tooLong, [ + $options['tooLong'] = strtr($this->tooLong, [ '{attribute}' => $label, '{max}' => $this->max, - ])); + ]); } if ($this->length !== null) { $options['is'] = $this->length; - $options['notEqual'] = Html::encode(strtr($this->notEqual, [ + $options['notEqual'] = strtr($this->notEqual, [ '{attribute}' => $label, - '{length}' => $this->is, - ])); + '{length}' => $this->length, + ]); } if ($this->skipOnEmpty) { $options['skipOnEmpty'] = 1; diff --git a/framework/yii/validators/UniqueValidator.php b/framework/yii/validators/UniqueValidator.php index d9cd587..1136f02 100644 --- a/framework/yii/validators/UniqueValidator.php +++ b/framework/yii/validators/UniqueValidator.php @@ -8,12 +8,28 @@ namespace yii\validators; use Yii; -use yii\base\InvalidConfigException; -use yii\db\ActiveRecord; use yii\db\ActiveRecordInterface; /** - * UniqueValidator validates that the attribute value is unique in the corresponding database table. + * UniqueValidator validates that the attribute value is unique in the specified database table. + * + * UniqueValidator checks if the value being validated is unique in the table column specified by + * the ActiveRecord class [[targetClass]] and the attribute [[targetAttribute]]. + * + * The followings are examples of validation rules using this validator: + * + * ```php + * // a1 needs to be unique + * ['a1', 'unique'] + * // a1 needs to be unique, but column a2 will be used to check the uniqueness of the a1 value + * ['a1', 'unique', 'targetAttribute' => 'a2'] + * // a1 and a2 need to unique together, and they both will receive error message + * ['a1, a2', 'unique', 'targetAttribute' => ['a1', 'a2']] + * // a1 and a2 need to unique together, only a1 will receive error message + * ['a1', 'unique', 'targetAttribute' => ['a1', 'a2']] + * // a1 needs to be unique by checking the uniqueness of both a2 and a3 (using a1 value) + * ['a1', 'unique', 'targetAttribute' => ['a2', 'a1' => 'a3']] + * ``` * * @author Qiang Xue * @since 2.0 @@ -21,18 +37,20 @@ use yii\db\ActiveRecordInterface; class UniqueValidator extends Validator { /** - * @var string the ActiveRecord class name or alias of the class - * that should be used to look for the attribute value being validated. - * Defaults to null, meaning using the ActiveRecord class of the attribute being validated. - * @see attributeName + * @var string the name of the ActiveRecord class that should be used to validate the uniqueness + * of the current attribute value. It not set, it will use the ActiveRecord class of the attribute being validated. + * @see targetAttribute */ - public $className; + public $targetClass; /** - * @var string the ActiveRecord class attribute name that should be - * used to look for the attribute value being validated. Defaults to null, - * meaning using the name of the attribute being validated. + * @var string|array the name of the 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 + * 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. */ - public $attributeName; + public $targetAttribute; /** * @inheritdoc @@ -50,36 +68,48 @@ class UniqueValidator extends Validator */ public function validateAttribute($object, $attribute) { - $value = $object->$attribute; + /** @var ActiveRecordInterface $targetClass */ + $targetClass = $this->targetClass === null ? get_class($object) : $this->targetClass; + $targetAttribute = $this->targetAttribute === null ? $attribute : $this->targetAttribute; - if (is_array($value)) { - $this->addError($object, $attribute, Yii::t('yii', '{attribute} is invalid.')); - return; + if (is_array($targetAttribute)) { + $params = []; + foreach ($targetAttribute as $k => $v) { + $params[$v] = is_integer($k) ? $object->$v : $object->$k; + } + } else { + $params = [$targetAttribute => $object->$attribute]; } - /** @var \yii\db\ActiveRecord $className */ - $className = $this->className === null ? get_class($object) : $this->className; - $attributeName = $this->attributeName === null ? $attribute : $this->attributeName; + foreach ($params as $value) { + if (is_array($value)) { + $this->addError($object, $attribute, Yii::t('yii', '{attribute} is invalid.')); + return; + } + } - $query = $className::find(); - $query->where([$attributeName => $value]); + $query = $targetClass::find(); + $query->where($params); if (!$object instanceof ActiveRecordInterface || $object->getIsNewRecord()) { // if current $object isn't in the database yet then it's OK just to call exists() $exists = $query->exists(); } else { // if current $object is in the database already we can't use exists() - $query->limit(2); - $objects = $query->all(); - + /** @var ActiveRecordInterface[] $objects */ + $objects = $query->limit(2)->all(); $n = count($objects); if ($n === 1) { - if (in_array($attributeName, $className::primaryKey())) { + $keys = array_keys($params); + $pks = $targetClass::primaryKey(); + sort($keys); + sort($pks); + if ($keys === $pks) { // primary key is modified and not unique $exists = $object->getOldPrimaryKey() != $object->getPrimaryKey(); } else { // non-primary key, need to exclude the current record based on PK - $exists = array_shift($objects)->getPrimaryKey() != $object->getOldPrimaryKey(); + $exists = $objects[0]->getPrimaryKey() != $object->getOldPrimaryKey(); } } else { $exists = $n > 1; diff --git a/framework/yii/validators/UrlValidator.php b/framework/yii/validators/UrlValidator.php index 4023e2a..4cb20f6 100644 --- a/framework/yii/validators/UrlValidator.php +++ b/framework/yii/validators/UrlValidator.php @@ -121,9 +121,9 @@ class UrlValidator extends Validator $options = [ 'pattern' => new JsExpression($pattern), - 'message' => Html::encode(strtr($this->message, [ + 'message' => strtr($this->message, [ '{attribute}' => $object->getAttributeLabel($attribute), - ])), + ]), 'enableIDN' => (boolean)$this->enableIDN, ]; if ($this->skipOnEmpty) { diff --git a/framework/yii/web/Controller.php b/framework/yii/web/Controller.php index 0df48bd..540140f 100644 --- a/framework/yii/web/Controller.php +++ b/framework/yii/web/Controller.php @@ -101,9 +101,9 @@ class Controller extends \yii\base\Controller } /** - * Creates a URL using the given route and parameters. + * Normalizes route making it suitable for UrlManager. Absolute routes are staying as is + * while relative routes are converted to absolute routes. * - * This method enhances [[UrlManager::createUrl()]] by supporting relative routes. * A relative route is a route without a leading slash, such as "view", "post/view". * * - If the route is an empty string, the current [[route]] will be used; @@ -112,13 +112,10 @@ class Controller extends \yii\base\Controller * - If the route has no leading slash, it is considered to be a route relative * to the current module and will be prepended with the module's uniqueId. * - * After this route conversion, the method calls [[UrlManager::createUrl()]] to create a URL. - * * @param string $route the route. This can be either an absolute route or a relative route. - * @param array $params the parameters (name-value pairs) to be included in the generated URL - * @return string the created URL + * @return string normalized route suitable for UrlManager */ - public function createUrl($route, $params = []) + protected function getNormalizedRoute($route) { if (strpos($route, '/') === false) { // empty or an action ID @@ -127,10 +124,58 @@ class Controller extends \yii\base\Controller // relative to module $route = ltrim($this->module->getUniqueId() . '/' . $route, '/'); } + return $route; + } + + /** + * Creates a relative URL using the given route and parameters. + * + * This method enhances [[UrlManager::createUrl()]] by supporting relative routes. + * A relative route is a route without a leading slash, such as "view", "post/view". + * + * - If the route is an empty string, the current [[route]] will be used; + * - If the route contains no slashes at all, it is considered to be an action ID + * of the current controller and will be prepended with [[uniqueId]]; + * - If the route has no leading slash, it is considered to be a route relative + * to the current module and will be prepended with the module's uniqueId. + * + * After this route conversion, the method calls [[UrlManager::createUrl()]] to create a URL. + * + * @param string $route the route. This can be either an absolute route or a relative route. + * @param array $params the parameters (name-value pairs) to be included in the generated URL + * @return string the created relative URL + */ + public function createUrl($route, $params = []) + { + $route = $this->getNormalizedRoute($route); return Yii::$app->getUrlManager()->createUrl($route, $params); } /** + * Creates an absolute URL using the given route and parameters. + * + * This method enhances [[UrlManager::createAbsoluteUrl()]] by supporting relative routes. + * A relative route is a route without a leading slash, such as "view", "post/view". + * + * - If the route is an empty string, the current [[route]] will be used; + * - If the route contains no slashes at all, it is considered to be an action ID + * of the current controller and will be prepended with [[uniqueId]]; + * - If the route has no leading slash, it is considered to be a route relative + * to the current module and will be prepended with the module's uniqueId. + * + * After this route conversion, the method calls [[UrlManager::createUrl()]] to create a URL. + * + * @param string $route the route. This can be either an absolute route or a relative route. + * @param array $params the parameters (name-value pairs) to be included in the generated URL + * @return string the created absolute URL + */ + public function createAbsoluteUrl($route, $params = []) + { + $route = $this->getNormalizedRoute($route); + return Yii::$app->getUrlManager()->createAbsoluteUrl($route, $params); + } + + /** * Returns the canonical URL of the currently requested page. * The canonical URL is constructed using [[route]] and [[actionParams]]. You may use the following code * in the layout view to add a link tag about canonical URL: diff --git a/framework/yii/widgets/ActiveField.php b/framework/yii/widgets/ActiveField.php index 4228ea9..bd26237 100644 --- a/framework/yii/widgets/ActiveField.php +++ b/framework/yii/widgets/ActiveField.php @@ -112,8 +112,8 @@ class ActiveField extends Component /** * @var array different parts of the field (e.g. input, label). This will be used together with * [[template]] to generate the final field HTML code. The keys are the token names in [[template]], - * while the values are the corresponding HTML code. Valid tokens include `{input}`, `{label}`, - * `{error}`, and `{error}`. Note that you normally don't need to access this property directly as + * while the values are the corresponding HTML code. Valid tokens include `{input}`, `{label}` and `{error}`. + * Note that you normally don't need to access this property directly as * it is maintained by various methods of this class. */ public $parts = []; diff --git a/framework/yii/widgets/InputWidget.php b/framework/yii/widgets/InputWidget.php index e1981c9..0a4b5b7 100644 --- a/framework/yii/widgets/InputWidget.php +++ b/framework/yii/widgets/InputWidget.php @@ -11,6 +11,7 @@ use Yii; use yii\base\Widget; use yii\base\Model; use yii\base\InvalidConfigException; +use yii\helpers\Html; /** * InputWidget is the base class for widgets that collect user inputs. @@ -40,6 +41,10 @@ class InputWidget extends Widget * @var string the input value. */ public $value; + /** + * @var array the HTML attributes for the input tag. + */ + public $options = []; /** @@ -49,7 +54,10 @@ class InputWidget extends Widget public function init() { if (!$this->hasModel() && $this->name === null) { - throw new InvalidConfigException("Either 'name' or 'model' and 'attribute' properties must be specified."); + throw new InvalidConfigException("Either 'name', or 'model' and 'attribute' properties must be specified."); + } + if (!isset($this->options['id'])) { + $this->options['id'] = $this->hasModel() ? Html::getInputId($this->model, $this->attribute) : $this->getId(); } parent::init(); } diff --git a/framework/yii/widgets/MaskedInput.php b/framework/yii/widgets/MaskedInput.php index fc21cef..7eb42a7 100644 --- a/framework/yii/widgets/MaskedInput.php +++ b/framework/yii/widgets/MaskedInput.php @@ -61,10 +61,6 @@ class MaskedInput extends InputWidget * @var string a JavaScript function callback that will be invoked when user finishes the input. */ public $completed; - /** - * @var array the HTML attributes for the input tag. - */ - public $options = []; /** @@ -77,10 +73,6 @@ class MaskedInput extends InputWidget if (empty($this->mask)) { throw new InvalidConfigException('The "mask" property must be set.'); } - - if (!isset($this->options['id'])) { - $this->options['id'] = $this->hasModel() ? Html::getInputId($this->model, $this->attribute) : $this->getId(); - } } /** diff --git a/tests/unit/data/ar/Category.php b/tests/unit/data/ar/Category.php new file mode 100644 index 0000000..cebacb0 --- /dev/null +++ b/tests/unit/data/ar/Category.php @@ -0,0 +1,27 @@ +hasMany(Item::className(), ['category_id' => 'id']); + } +} diff --git a/tests/unit/data/ar/Item.php b/tests/unit/data/ar/Item.php index e725be9..2d04f9e 100644 --- a/tests/unit/data/ar/Item.php +++ b/tests/unit/data/ar/Item.php @@ -15,4 +15,9 @@ class Item extends ActiveRecord { return 'tbl_item'; } + + public function getCategory() + { + return $this->hasOne(Category::className(), ['id' => 'category_id']); + } } diff --git a/tests/unit/extensions/mongodb/ActiveRelationTest.php b/tests/unit/extensions/mongodb/ActiveRelationTest.php index 8736d52..2baeab4 100644 --- a/tests/unit/extensions/mongodb/ActiveRelationTest.php +++ b/tests/unit/extensions/mongodb/ActiveRelationTest.php @@ -69,7 +69,7 @@ class ActiveRelationTest extends MongoDbTestCase $this->assertTrue($order->isRelationPopulated('customer')); $this->assertTrue($customer instanceof Customer); $this->assertEquals((string)$customer->_id, (string)$order->customer_id); - $this->assertEquals(1, count($order->populatedRelations)); + $this->assertEquals(1, count($order->relatedRecords)); } public function testFindEager() @@ -83,4 +83,4 @@ class ActiveRelationTest extends MongoDbTestCase $this->assertTrue($orders[1]->customer instanceof Customer); $this->assertEquals((string)$orders[1]->customer->_id, (string)$orders[1]->customer_id); } -} \ No newline at end of file +} diff --git a/tests/unit/extensions/sphinx/ActiveRelationTest.php b/tests/unit/extensions/sphinx/ActiveRelationTest.php index cd58035..d85c6b9 100644 --- a/tests/unit/extensions/sphinx/ActiveRelationTest.php +++ b/tests/unit/extensions/sphinx/ActiveRelationTest.php @@ -29,7 +29,7 @@ class ActiveRelationTest extends SphinxTestCase $index = $article->index; $this->assertTrue($article->isRelationPopulated('index')); $this->assertTrue($index instanceof ArticleIndex); - $this->assertEquals(1, count($article->populatedRelations)); + $this->assertEquals(1, count($article->relatedRecords)); $this->assertEquals($article->id, $index->id); } @@ -42,4 +42,4 @@ class ActiveRelationTest extends SphinxTestCase $this->assertTrue($articles[0]->index instanceof ArticleIndex); $this->assertTrue($articles[1]->index instanceof ArticleIndex); } -} \ No newline at end of file +} diff --git a/tests/unit/extensions/sphinx/ExternalActiveRelationTest.php b/tests/unit/extensions/sphinx/ExternalActiveRelationTest.php index e30a0cf..1740c42 100644 --- a/tests/unit/extensions/sphinx/ExternalActiveRelationTest.php +++ b/tests/unit/extensions/sphinx/ExternalActiveRelationTest.php @@ -32,7 +32,7 @@ class ExternalActiveRelationTest extends SphinxTestCase $source = $article->source; $this->assertTrue($article->isRelationPopulated('source')); $this->assertTrue($source instanceof ArticleDb); - $this->assertEquals(1, count($article->populatedRelations)); + $this->assertEquals(1, count($article->relatedRecords)); // has many : /*$this->assertFalse($article->isRelationPopulated('tags')); @@ -71,4 +71,4 @@ class ExternalActiveRelationTest extends SphinxTestCase ->all(); $this->assertEquals(2, count($articles)); } -} \ No newline at end of file +} diff --git a/tests/unit/framework/ar/ActiveRecordTestTrait.php b/tests/unit/framework/ar/ActiveRecordTestTrait.php index 50a5f81..95def9d 100644 --- a/tests/unit/framework/ar/ActiveRecordTestTrait.php +++ b/tests/unit/framework/ar/ActiveRecordTestTrait.php @@ -392,14 +392,14 @@ trait ActiveRecordTestTrait $orders = $customer->orders; $this->assertTrue($customer->isRelationPopulated('orders')); $this->assertEquals(2, count($orders)); - $this->assertEquals(1, count($customer->populatedRelations)); + $this->assertEquals(1, count($customer->relatedRecords)); /** @var Customer $customer */ $customer = $this->callCustomerFind(2); $this->assertFalse($customer->isRelationPopulated('orders')); $orders = $customer->getOrders()->where(['id' => 3])->all(); $this->assertFalse($customer->isRelationPopulated('orders')); - $this->assertEquals(0, count($customer->populatedRelations)); + $this->assertEquals(0, count($customer->relatedRecords)); $this->assertEquals(1, count($orders)); $this->assertEquals(3, $orders[0]->id); @@ -421,7 +421,7 @@ trait ActiveRecordTestTrait $customer = $this->callCustomerFind()->where(['id' => 1])->with('orders')->one(); $this->assertTrue($customer->isRelationPopulated('orders')); $this->assertEquals(1, count($customer->orders)); - $this->assertEquals(1, count($customer->populatedRelations)); + $this->assertEquals(1, count($customer->relatedRecords)); } public function testFindLazyVia() diff --git a/tests/unit/framework/db/ActiveRecordTest.php b/tests/unit/framework/db/ActiveRecordTest.php index 15462b5..40050e5 100644 --- a/tests/unit/framework/db/ActiveRecordTest.php +++ b/tests/unit/framework/db/ActiveRecordTest.php @@ -217,4 +217,64 @@ class ActiveRecordTest extends DatabaseTestCase $this->assertTrue(OrderItem::isPrimaryKey(['order_id', 'item_id'])); $this->assertFalse(OrderItem::isPrimaryKey(['order_id', 'item_id', 'quantity'])); } + + public function testJoinWith() + { + // left join and eager loading + $orders = Order::find()->joinWith('customer')->orderBy('tbl_customer.id DESC, tbl_order.id')->all(); + $this->assertEquals(3, count($orders)); + $this->assertEquals(2, $orders[0]->id); + $this->assertEquals(3, $orders[1]->id); + $this->assertEquals(1, $orders[2]->id); + $this->assertTrue($orders[0]->isRelationPopulated('customer')); + $this->assertTrue($orders[1]->isRelationPopulated('customer')); + $this->assertTrue($orders[2]->isRelationPopulated('customer')); + + // inner join filtering and eager loading + $orders = Order::find()->innerJoinWith([ + 'customer' => function ($query) { + $query->where('tbl_customer.id=2'); + }, + ])->orderBy('tbl_order.id')->all(); + $this->assertEquals(2, count($orders)); + $this->assertEquals(2, $orders[0]->id); + $this->assertEquals(3, $orders[1]->id); + $this->assertTrue($orders[0]->isRelationPopulated('customer')); + $this->assertTrue($orders[1]->isRelationPopulated('customer')); + + // inner join filtering without eager loading + $orders = Order::find()->innerJoinWith([ + 'customer' => function ($query) { + $query->where('tbl_customer.id=2'); + }, + ], false)->orderBy('tbl_order.id')->all(); + $this->assertEquals(2, count($orders)); + $this->assertEquals(2, $orders[0]->id); + $this->assertEquals(3, $orders[1]->id); + $this->assertFalse($orders[0]->isRelationPopulated('customer')); + $this->assertFalse($orders[1]->isRelationPopulated('customer')); + + // join with via-relation + $orders = Order::find()->innerJoinWith('books')->orderBy('tbl_order.id')->all(); + $this->assertEquals(2, count($orders)); + $this->assertEquals(1, $orders[0]->id); + $this->assertEquals(3, $orders[1]->id); + $this->assertTrue($orders[0]->isRelationPopulated('books')); + $this->assertTrue($orders[1]->isRelationPopulated('books')); + $this->assertEquals(2, count($orders[0]->books)); + $this->assertEquals(1, count($orders[1]->books)); + + // join with sub-relation + $orders = Order::find()->innerJoinWith([ + 'items.category' => function ($q) { + $q->where('tbl_category.id = 2'); + }, + ])->orderBy('tbl_order.id')->all(); + $this->assertEquals(1, count($orders)); + $this->assertTrue($orders[0]->isRelationPopulated('items')); + $this->assertEquals(2, $orders[0]->id); + $this->assertEquals(3, count($orders[0]->items)); + $this->assertTrue($orders[0]->items[0]->isRelationPopulated('category')); + $this->assertEquals(2, $orders[0]->items[0]->category->id); + } } diff --git a/tests/unit/framework/validators/ExistValidatorTest.php b/tests/unit/framework/validators/ExistValidatorTest.php index 45ff5d5..8f1a054 100644 --- a/tests/unit/framework/validators/ExistValidatorTest.php +++ b/tests/unit/framework/validators/ExistValidatorTest.php @@ -7,6 +7,8 @@ use Yii; use yii\base\Exception; use yii\validators\ExistValidator; use yiiunit\data\ar\ActiveRecord; +use yiiunit\data\ar\Order; +use yiiunit\data\ar\OrderItem; use yiiunit\data\validators\models\ValidatorTestMainModel; use yiiunit\data\validators\models\ValidatorTestRefModel; use yiiunit\framework\db\DatabaseTestCase; @@ -34,18 +36,18 @@ class ExistValidatorTest extends DatabaseTestCase } // combine to save the time creating a new db-fixture set (likely ~5 sec) try { - $val = new ExistValidator(['className' => ValidatorTestMainModel::className()]); + $val = new ExistValidator(['targetClass' => ValidatorTestMainModel::className()]); $val->validate('ref'); $this->fail('Exception should have been thrown at this time'); } catch (Exception $e) { $this->assertInstanceOf('yii\base\InvalidConfigException', $e); - $this->assertEquals('The "attributeName" property must be set.', $e->getMessage()); + $this->assertEquals('The "attributeName" property must be configured as a string.', $e->getMessage()); } } public function testValidateValue() { - $val = new ExistValidator(['className' => ValidatorTestRefModel::className(), 'attributeName' => 'id']); + $val = new ExistValidator(['targetClass' => ValidatorTestRefModel::className(), 'targetAttribute' => 'id']); $this->assertTrue($val->validate(2)); $this->assertTrue($val->validate(5)); $this->assertFalse($val->validate(99)); @@ -55,22 +57,22 @@ class ExistValidatorTest extends DatabaseTestCase public function testValidateAttribute() { // existing value on different table - $val = new ExistValidator(['className' => ValidatorTestMainModel::className(), 'attributeName' => 'id']); + $val = new ExistValidator(['targetClass' => ValidatorTestMainModel::className(), 'targetAttribute' => 'id']); $m = ValidatorTestRefModel::find(['id' => 1]); $val->validateAttribute($m, 'ref'); $this->assertFalse($m->hasErrors()); // non-existing value on different table - $val = new ExistValidator(['className' => ValidatorTestMainModel::className(), 'attributeName' => 'id']); + $val = new ExistValidator(['targetClass' => ValidatorTestMainModel::className(), 'targetAttribute' => 'id']); $m = ValidatorTestRefModel::find(['id' => 6]); $val->validateAttribute($m, 'ref'); $this->assertTrue($m->hasErrors('ref')); // existing value on same table - $val = new ExistValidator(['attributeName' => 'ref']); + $val = new ExistValidator(['targetAttribute' => 'ref']); $m = ValidatorTestRefModel::find(['id' => 2]); $val->validateAttribute($m, 'test_val'); $this->assertFalse($m->hasErrors()); // non-existing value on same table - $val = new ExistValidator(['attributeName' => 'ref']); + $val = new ExistValidator(['targetAttribute' => 'ref']); $m = ValidatorTestRefModel::find(['id' => 5]); $val->validateAttribute($m, 'test_val_fail'); $this->assertTrue($m->hasErrors('test_val_fail')); @@ -86,10 +88,50 @@ class ExistValidatorTest extends DatabaseTestCase $val->validateAttribute($m, 'a_field'); $this->assertTrue($m->hasErrors('a_field')); // check array - $val = new ExistValidator(['attributeName' => 'ref']); + $val = new ExistValidator(['targetAttribute' => 'ref']); $m = ValidatorTestRefModel::find(['id' => 2]); $m->test_val = [1,2,3]; $val->validateAttribute($m, 'test_val'); $this->assertTrue($m->hasErrors('test_val')); } + + public function testValidateCompositeKeys() + { + $val = new ExistValidator([ + 'targetClass' => OrderItem::className(), + 'targetAttribute' => ['order_id', 'item_id'], + ]); + // validate old record + $m = OrderItem::find(['order_id' => 1, 'item_id' => 2]); + $val->validateAttribute($m, 'order_id'); + $this->assertFalse($m->hasErrors('order_id')); + + // validate new record + $m = new OrderItem(['order_id' => 1, 'item_id' => 2]); + $val->validateAttribute($m, 'order_id'); + $this->assertFalse($m->hasErrors('order_id')); + $m = new OrderItem(['order_id' => 10, 'item_id' => 2]); + $val->validateAttribute($m, 'order_id'); + $this->assertTrue($m->hasErrors('order_id')); + + $val = new ExistValidator([ + 'targetClass' => OrderItem::className(), + 'targetAttribute' => ['id' => 'order_id'], + ]); + // validate old record + $m = Order::find(1); + $val->validateAttribute($m, 'id'); + $this->assertFalse($m->hasErrors('id')); + $m = Order::find(1); + $m->id = 10; + $val->validateAttribute($m, 'id'); + $this->assertTrue($m->hasErrors('id')); + + $m = new Order(['id' => 1]); + $val->validateAttribute($m, 'id'); + $this->assertFalse($m->hasErrors('id')); + $m = new Order(['id' => 10]); + $val->validateAttribute($m, 'id'); + $this->assertTrue($m->hasErrors('id')); + } } diff --git a/tests/unit/framework/validators/UniqueValidatorTest.php b/tests/unit/framework/validators/UniqueValidatorTest.php index 707239c..4af3d29 100644 --- a/tests/unit/framework/validators/UniqueValidatorTest.php +++ b/tests/unit/framework/validators/UniqueValidatorTest.php @@ -6,6 +6,8 @@ namespace yiiunit\framework\validators; use yii\validators\UniqueValidator; use Yii; use yiiunit\data\ar\ActiveRecord; +use yiiunit\data\ar\Order; +use yiiunit\data\ar\OrderItem; use yiiunit\data\validators\models\FakedValidationModel; use yiiunit\data\validators\models\ValidatorTestMainModel; use yiiunit\data\validators\models\ValidatorTestRefModel; @@ -58,7 +60,7 @@ class UniqueValidatorTest extends DatabaseTestCase public function testValidateAttributeOfNonARModel() { - $val = new UniqueValidator(['className' => ValidatorTestRefModel::className(), 'attributeName' => 'ref']); + $val = new UniqueValidator(['targetClass' => ValidatorTestRefModel::className(), 'targetAttribute' => 'ref']); $m = FakedValidationModel::createWithAttributes(['attr_1' => 5, 'attr_2' => 1313]); $val->validateAttribute($m, 'attr_1'); $this->assertTrue($m->hasErrors('attr_1')); @@ -68,7 +70,7 @@ class UniqueValidatorTest extends DatabaseTestCase public function testValidateNonDatabaseAttribute() { - $val = new UniqueValidator(['className' => ValidatorTestRefModel::className(), 'attributeName' => 'ref']); + $val = new UniqueValidator(['targetClass' => ValidatorTestRefModel::className(), 'targetAttribute' => 'ref']); $m = ValidatorTestMainModel::find(1); $val->validateAttribute($m, 'testMainVal'); $this->assertFalse($m->hasErrors('testMainVal')); @@ -85,4 +87,51 @@ class UniqueValidatorTest extends DatabaseTestCase $m = new ValidatorTestMainModel(); $val->validateAttribute($m, 'testMainVal'); } + + public function testValidateCompositeKeys() + { + $val = new UniqueValidator([ + 'targetClass' => OrderItem::className(), + 'targetAttribute' => ['order_id', 'item_id'], + ]); + // validate old record + $m = OrderItem::find(['order_id' => 1, 'item_id' => 2]); + $val->validateAttribute($m, 'order_id'); + $this->assertFalse($m->hasErrors('order_id')); + $m->item_id = 1; + $val->validateAttribute($m, 'order_id'); + $this->assertTrue($m->hasErrors('order_id')); + + // validate new record + $m = new OrderItem(['order_id' => 1, 'item_id' => 2]); + $val->validateAttribute($m, 'order_id'); + $this->assertTrue($m->hasErrors('order_id')); + $m = new OrderItem(['order_id' => 10, 'item_id' => 2]); + $val->validateAttribute($m, 'order_id'); + $this->assertFalse($m->hasErrors('order_id')); + + $val = new UniqueValidator([ + 'targetClass' => OrderItem::className(), + 'targetAttribute' => ['id' => 'order_id'], + ]); + // validate old record + $m = Order::find(1); + $val->validateAttribute($m, 'id'); + $this->assertTrue($m->hasErrors('id')); + $m = Order::find(1); + $m->id = 2; + $val->validateAttribute($m, 'id'); + $this->assertTrue($m->hasErrors('id')); + $m = Order::find(1); + $m->id = 10; + $val->validateAttribute($m, 'id'); + $this->assertFalse($m->hasErrors('id')); + + $m = new Order(['id' => 1]); + $val->validateAttribute($m, 'id'); + $this->assertTrue($m->hasErrors('id')); + $m = new Order(['id' => 10]); + $val->validateAttribute($m, 'id'); + $this->assertFalse($m->hasErrors('id')); + } }