diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 30b988edb4..6c3b0dcc34 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -71,7 +71,7 @@ jobs: run: sudo locale-gen fr_FR.UTF-8 - name: Checkout. - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install PHP. uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/ci-mssql.yml b/.github/workflows/ci-mssql.yml index 94255d93eb..777f91fd30 100644 --- a/.github/workflows/ci-mssql.yml +++ b/.github/workflows/ci-mssql.yml @@ -43,7 +43,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Create MS SQL Database run: docker exec -i mssql /opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P 'YourStrong!Passw0rd' -Q 'CREATE DATABASE yiitest' diff --git a/.github/workflows/ci-mysql.yml b/.github/workflows/ci-mysql.yml index a948a9e4ca..a1c201b07c 100644 --- a/.github/workflows/ci-mysql.yml +++ b/.github/workflows/ci-mysql.yml @@ -44,7 +44,7 @@ jobs: steps: - name: Checkout. - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install PHP with extensions. uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/ci-node.yml b/.github/workflows/ci-node.yml index 0d93773355..3f49942b84 100644 --- a/.github/workflows/ci-node.yml +++ b/.github/workflows/ci-node.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Checkout. - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install dependencies. run: composer update $DEFAULT_COMPOSER_FLAGS diff --git a/.github/workflows/ci-oracle.yml b/.github/workflows/ci-oracle.yml index 21520a1045..be139ff540 100644 --- a/.github/workflows/ci-oracle.yml +++ b/.github/workflows/ci-oracle.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Checkout. - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install PHP with extensions. uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/ci-pgsql.yml b/.github/workflows/ci-pgsql.yml index edaf4a5df5..8c623c283b 100644 --- a/.github/workflows/ci-pgsql.yml +++ b/.github/workflows/ci-pgsql.yml @@ -48,7 +48,7 @@ jobs: steps: - name: Checkout. - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install PHP with extensions uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/ci-sqlite.yml b/.github/workflows/ci-sqlite.yml index b82d187ef9..e163da1100 100644 --- a/.github/workflows/ci-sqlite.yml +++ b/.github/workflows/ci-sqlite.yml @@ -31,7 +31,7 @@ jobs: steps: - name: Checkout. - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install PHP with extensions. uses: shivammathur/setup-php@v2 diff --git a/build/controllers/MimeTypeController.php b/build/controllers/MimeTypeController.php index 9da23f67fc..7986dd56cc 100644 --- a/build/controllers/MimeTypeController.php +++ b/build/controllers/MimeTypeController.php @@ -121,10 +121,14 @@ class MimeTypeController extends Controller * Its content is generated from the apache http mime.types file. * https://svn.apache.org/viewvc/httpd/httpd/trunk/docs/conf/mime.types?view=markup * This file has been placed in the public domain for unlimited redistribution. + * + * All extra changes made to this file must be comitted to /build/controllers/MimeTypeController.php + * otherwise they will be lost on next build. */ \$mimeTypes = $array; -if (PHP_VERSION_ID >= 80100) { +# fix for bundled libmagic bug, see also https://github.com/yiisoft/yii2/issues/19925 +if ((PHP_VERSION_ID >= 80100 && PHP_VERSION_ID < 80122) || (PHP_VERSION_ID >= 80200 && PHP_VERSION_ID < 80209)) { \$mimeTypes = array_replace(\$mimeTypes, array('xz' => 'application/octet-stream')); } @@ -148,6 +152,9 @@ EOD; * MIME aliases. * * This file contains aliases for MIME types. + * + * All extra changes made to this file must be comitted to /build/controllers/MimeTypeController.php + * otherwise they will be lost on next build. */ return $array; @@ -209,6 +216,9 @@ EOD; * Its content is generated from the apache http mime.types file. * https://svn.apache.org/viewvc/httpd/httpd/trunk/docs/conf/mime.types?view=markup * This file has been placed in the public domain for unlimited redistribution. + * + * All extra changes made to this file must be comitted to /build/controllers/MimeTypeController.php + * otherwise they will be lost on next build. */ return $array; diff --git a/composer.json b/composer.json index ed19e7cce4..ef5d068f59 100644 --- a/composer.json +++ b/composer.json @@ -76,8 +76,8 @@ "ezyang/htmlpurifier": "^4.6", "cebe/markdown": "~1.0.0 | ~1.1.0 | ~1.2.0", "bower-asset/jquery": "3.7.*@stable | 3.6.*@stable | 3.5.*@stable | 3.4.*@stable | 3.3.*@stable | 3.2.*@stable | 3.1.*@stable | 2.2.*@stable | 2.1.*@stable | 1.11.*@stable | 1.12.*@stable", - "bower-asset/inputmask": "~3.2.2 | ~3.3.5 | ~5.0.8 ", - "bower-asset/punycode": "1.3.* | 2.2.*", + "bower-asset/inputmask": "^5.0.8 ", + "bower-asset/punycode": "^2.2", "bower-asset/yii2-pjax": "~2.0.1", "paragonie/random_compat": ">=1" }, diff --git a/docs/guide-ru/concept-di-container.md b/docs/guide-ru/concept-di-container.md index 80c2c723d8..2812d4bd8f 100644 --- a/docs/guide-ru/concept-di-container.md +++ b/docs/guide-ru/concept-di-container.md @@ -498,7 +498,7 @@ class HotelController extends Controller как можно раньше. Ниже приведены рекомендуемые практики: * Если вы разработчик приложения, то вы можете зарегистрировать зависимости в конфигурации вашего приложения. - Как это сделать описано в подразделе [Конфигурация приложения](concept-service-locator.md#application-configurations) + Как это сделать описано в подразделе [Конфигурация приложения](concept-configurations.md#application-configurations) раздела [Конфигурации](concept-configurations.md). * Если вы разработчик распространяемого [расширения](structure-extensions.md), то вы можете зарегистрировать зависимости в загрузочном классе расширения. diff --git a/docs/guide-ru/structure-controllers.md b/docs/guide-ru/structure-controllers.md index c55a8cf151..5765f26c16 100644 --- a/docs/guide-ru/structure-controllers.md +++ b/docs/guide-ru/structure-controllers.md @@ -310,7 +310,7 @@ class HelloWorldAction extends Action [[yii\console\Response::exitStatus|статус выхода]] исполнения команды. В вышеприведенных примерах, все результаты действий являются строками, которые будут использованы в качестве тела ответа, -высланного пользователю. Следующий пример, показывает действие может перенаправить браузер пользователя на новый URL, с помощью +высланного пользователю. Следующий пример, показывает как действие может перенаправить браузер пользователя на новый URL, с помощью возврата response объекта (т. к. [[yii\web\Controller::redirect()|redirect()]] метод возвращает response объект): ```php diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index 8eda591cfe..64e997ee82 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -5,12 +5,26 @@ Yii Framework 2 Change Log ------------------------ - Bug #13920: Fixed erroneous validation for specific cases (tim-fischer-maschinensucher) +- Bug #19927: Fixed `console\controllers\MessageController` when saving translations to database: fixed FK error when adding new string and language at the same time, checking/regenerating all missing messages and dropping messages for unused languages (atrandafir) +- Enh #12743: Added new methods `BaseActiveRecord::loadRelations()` and `BaseActiveRecord::loadRelationsFor()` to eager load related models for existing primary model instances (PowerGamer1) + + +2.0.49.2 October 12, 2023 +------------------------- + - Bug #19925: Improved PHP version check when handling MIME types (schmunk42) + + +2.0.49.1 October 05, 2023 +------------------------- + - Bug #19940: File Log writer without newline (terabytesoftw) -- Bug #19951: Removed unneeded MIME file tests (schmunk42) - Bug #19950: Fix `Query::groupBy(null)` causes error for PHP 8.1: `trim(): Passing null to parameter #1 ($string) of type string is deprecated` (uaoleg) +- Bug #19951: Removed unneeded MIME file tests (schmunk42) +- Bug #19984: Do not duplicate log messages in memory (lubosdz) - Enh #19780: added pcntl to requirements check (schmunk42) + 2.0.49 August 29, 2023 ---------------------- diff --git a/framework/composer.json b/framework/composer.json index 951679faec..ff36473707 100644 --- a/framework/composer.json +++ b/framework/composer.json @@ -71,8 +71,8 @@ "ezyang/htmlpurifier": "^4.6", "cebe/markdown": "~1.0.0 | ~1.1.0 | ~1.2.0", "bower-asset/jquery": "3.7.*@stable | 3.6.*@stable | 3.5.*@stable | 3.4.*@stable | 3.3.*@stable | 3.2.*@stable | 3.1.*@stable | 2.2.*@stable | 2.1.*@stable | 1.11.*@stable | 1.12.*@stable", - "bower-asset/inputmask": "~3.2.2 | ~3.3.5 | ~5.0.8 ", - "bower-asset/punycode": "1.3.* | 2.2.*", + "bower-asset/inputmask": "^5.0.8 ", + "bower-asset/punycode": "^2.2", "bower-asset/yii2-pjax": "~2.0.1", "paragonie/random_compat": ">=1" }, diff --git a/framework/console/controllers/MessageController.php b/framework/console/controllers/MessageController.php index e1a7357690..6d5a7d6448 100644 --- a/framework/console/controllers/MessageController.php +++ b/framework/console/controllers/MessageController.php @@ -353,17 +353,7 @@ EOD; foreach ($rows as $row) { $currentMessages[$row['category']][$row['id']] = $row['message']; } - - $currentLanguages = []; - $rows = (new Query())->select(['language'])->from($messageTable)->groupBy('language')->all($db); - foreach ($rows as $row) { - $currentLanguages[] = $row['language']; - } - $missingLanguages = []; - if (!empty($currentLanguages)) { - $missingLanguages = array_diff($languages, $currentLanguages); - } - + $new = []; $obsolete = []; @@ -372,89 +362,130 @@ EOD; if (isset($currentMessages[$category])) { $new[$category] = array_diff($msgs, $currentMessages[$category]); + // obsolete messages per category $obsolete += array_diff($currentMessages[$category], $msgs); } else { $new[$category] = $msgs; } } - + + // obsolete categories foreach (array_diff(array_keys($currentMessages), array_keys($messages)) as $category) { $obsolete += $currentMessages[$category]; } if (!$removeUnused) { foreach ($obsolete as $pk => $msg) { + // skip already marked unused if (strncmp($msg, '@@', 2) === 0 && substr($msg, -2) === '@@') { unset($obsolete[$pk]); } } - } - - $obsolete = array_keys($obsolete); + } + $this->stdout('Inserting new messages...'); - $savedFlag = false; + $insertCount = 0; foreach ($new as $category => $msgs) { foreach ($msgs as $msg) { - $savedFlag = true; - $lastPk = $db->schema->insert($sourceMessageTable, ['category' => $category, 'message' => $msg]); - foreach ($languages as $language) { - $db->createCommand() - ->insert($messageTable, ['id' => $lastPk['id'], 'language' => $language]) - ->execute(); - } + $insertCount++; + $db->schema->insert($sourceMessageTable, ['category' => $category, 'message' => $msg]); } } - - if (!empty($missingLanguages)) { - $updatedMessages = []; - $rows = (new Query())->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db); - foreach ($rows as $row) { - $updatedMessages[$row['category']][$row['id']] = $row['message']; - } - foreach ($updatedMessages as $category => $msgs) { - foreach ($msgs as $id => $msg) { - $savedFlag = true; - foreach ($missingLanguages as $language) { - $db->createCommand() - ->insert($messageTable, ['id' => $id, 'language' => $language]) - ->execute(); - } - } - } - } - - $this->stdout($savedFlag ? "saved.\n" : "Nothing to save.\n"); + + $this->stdout($insertCount ? "{$insertCount} saved.\n" : "Nothing to save.\n"); + $this->stdout($removeUnused ? 'Deleting obsoleted messages...' : 'Updating obsoleted messages...'); if (empty($obsolete)) { $this->stdout("Nothing obsoleted...skipped.\n"); - return; } - if ($removeUnused) { - $db->createCommand() - ->delete($sourceMessageTable, ['in', 'id', $obsolete]) - ->execute(); - $this->stdout("deleted.\n"); - } elseif ($markUnused) { - $rows = (new Query()) - ->select(['id', 'message']) - ->from($sourceMessageTable) - ->where(['in', 'id', $obsolete]) - ->all($db); - - foreach ($rows as $row) { - $db->createCommand()->update( - $sourceMessageTable, - ['message' => '@@' . $row['message'] . '@@'], - ['id' => $row['id']] - )->execute(); + if ($obsolete) { + if ($removeUnused) { + $affected = $db->createCommand() + ->delete($sourceMessageTable, ['in', 'id', array_keys($obsolete)]) + ->execute(); + $this->stdout("{$affected} deleted.\n"); + } elseif ($markUnused) { + $marked=0; + $rows = (new Query()) + ->select(['id', 'message']) + ->from($sourceMessageTable) + ->where(['in', 'id', array_keys($obsolete)]) + ->all($db); + + foreach ($rows as $row) { + $marked++; + $db->createCommand()->update( + $sourceMessageTable, + ['message' => '@@' . $row['message'] . '@@'], + ['id' => $row['id']] + )->execute(); + } + $this->stdout("{$marked} updated.\n"); + } else { + $this->stdout("kept untouched.\n"); } - $this->stdout("updated.\n"); - } else { - $this->stdout("kept untouched.\n"); } + + // get fresh message id list + $freshMessagesIds = []; + $rows = (new Query())->select(['id'])->from($sourceMessageTable)->all($db); + foreach ($rows as $row) { + $freshMessagesIds[] = $row['id']; + } + + $this->stdout("Generating missing rows..."); + $generatedMissingRows = []; + + foreach ($languages as $language) { + $count = 0; + + // get list of ids of translations for this language + $msgRowsIds = []; + $msgRows = (new Query())->select(['id'])->from($messageTable)->where([ + 'language'=>$language, + ])->all($db); + foreach ($msgRows as $row) { + $msgRowsIds[] = $row['id']; + } + + // insert missing + foreach ($freshMessagesIds as $id) { + if (!in_array($id, $msgRowsIds)) { + $db->createCommand() + ->insert($messageTable, ['id' => $id, 'language' => $language]) + ->execute(); + $count++; + } + } + if ($count) { + $generatedMissingRows[] = "{$count} for {$language}"; + } + } + + $this->stdout($generatedMissingRows ? implode(", ", $generatedMissingRows).".\n" : "Nothing to do.\n"); + + $this->stdout("Dropping unused languages..."); + $droppedLanguages=[]; + + $currentLanguages = []; + $rows = (new Query())->select(['language'])->from($messageTable)->groupBy('language')->all($db); + foreach ($rows as $row) { + $currentLanguages[] = $row['language']; + } + + foreach ($currentLanguages as $currentLanguage) { + if (!in_array($currentLanguage, $languages)) { + $deleted=$db->createCommand()->delete($messageTable, "language=:language", [ + 'language'=>$currentLanguage, + ])->execute(); + $droppedLanguages[] = "removed {$deleted} rows for $currentLanguage"; + } + } + + $this->stdout($droppedLanguages ? implode(", ", $droppedLanguages).".\n" : "Nothing to do.\n"); } /** diff --git a/framework/console/controllers/ServeController.php b/framework/console/controllers/ServeController.php index 68a7e50c28..d02c982042 100644 --- a/framework/console/controllers/ServeController.php +++ b/framework/console/controllers/ServeController.php @@ -80,7 +80,7 @@ class ServeController extends Controller } $this->stdout("Quit the server with CTRL-C or COMMAND-C.\n"); - passthru('"' . PHP_BINARY . '"' . " -S {$address} -t \"{$documentRoot}\" $router"); + passthru('"' . PHP_BINARY . '"' . " -S {$address} -t \"{$documentRoot}\" \"$router\""); } /** diff --git a/framework/db/BaseActiveRecord.php b/framework/db/BaseActiveRecord.php index 61cf377409..ab5414416c 100644 --- a/framework/db/BaseActiveRecord.php +++ b/framework/db/BaseActiveRecord.php @@ -1787,4 +1787,57 @@ abstract class BaseActiveRecord extends Model implements ActiveRecordInterface return $newValue !== $oldValue; } + + /** + * Eager loads related models for the already loaded primary models. + * + * Helps to reduce the number of queries performed against database if some related models are only used + * when a specific condition is met. For example: + * + * ```php + * $customers = Customer::find()->where(['country_id' => 123])->all(); + * if (Yii:app()->getUser()->getIdentity()->canAccessOrders()) { + * Customer::loadRelationsFor($customers, 'orders.items'); + * } + * ``` + * + * @param array|ActiveRecordInterface[] $models array of primary models. Each model should have the same type and can be: + * - an active record instance; + * - active record instance represented by array (i.e. active record was loaded using [[ActiveQuery::asArray()]]). + * @param string|array $relationNames the names of the relations of primary models to be loaded from database. See [[ActiveQueryInterface::with()]] on how to specify this argument. + * @param bool $asArray whether to load each related model as an array or an object (if the relation itself does not specify that). + * @since 2.0.49 + */ + public static function loadRelationsFor(&$models, $relationNames, $asArray = false) + { + // ActiveQueryTrait::findWith() called below assumes $models array is non-empty. + if (empty($models)) { + return; + } + + static::find()->asArray($asArray)->findWith((array)$relationNames, $models); + } + + /** + * Eager loads related models for the already loaded primary model. + * + * Helps to reduce the number of queries performed against database if some related models are only used + * when a specific condition is met. For example: + * + * ```php + * $customer = Customer::find()->where(['id' => 123])->one(); + * if (Yii:app()->getUser()->getIdentity()->canAccessOrders()) { + * $customer->loadRelations('orders.items'); + * } + * ``` + * + * @param string|array $relationNames the names of the relations of this model to be loaded from database. See [[ActiveQueryInterface::with()]] on how to specify this argument. + * @param bool $asArray whether to load each relation as an array or an object (if the relation itself does not specify that). + * @since 2.0.49 + */ + public function loadRelations($relationNames, $asArray = false) + { + $models = [$this]; + static::loadRelationsFor($models, $relationNames, $asArray); + } } diff --git a/framework/helpers/mimeAliases.php b/framework/helpers/mimeAliases.php index 4cd89888a7..a9e677adcb 100644 --- a/framework/helpers/mimeAliases.php +++ b/framework/helpers/mimeAliases.php @@ -3,6 +3,9 @@ * MIME aliases. * * This file contains aliases for MIME types. + * + * All extra changes made to this file must be comitted to /build/controllers/MimeTypeController.php + * otherwise they will be lost on next build. */ return [ 'text/rtf' => 'application/rtf', diff --git a/framework/helpers/mimeExtensions.php b/framework/helpers/mimeExtensions.php index 946d61cd0c..e4936030fd 100644 --- a/framework/helpers/mimeExtensions.php +++ b/framework/helpers/mimeExtensions.php @@ -8,6 +8,9 @@ * Its content is generated from the apache http mime.types file. * https://svn.apache.org/viewvc/httpd/httpd/trunk/docs/conf/mime.types?view=markup * This file has been placed in the public domain for unlimited redistribution. + * + * All extra changes made to this file must be comitted to /build/controllers/MimeTypeController.php + * otherwise they will be lost on next build. */ return [ 'application/andrew-inset' => 'ez', diff --git a/framework/helpers/mimeTypes.php b/framework/helpers/mimeTypes.php index e91f80f95f..f895e8d072 100644 --- a/framework/helpers/mimeTypes.php +++ b/framework/helpers/mimeTypes.php @@ -7,6 +7,9 @@ * Its content is generated from the apache http mime.types file. * https://svn.apache.org/viewvc/httpd/httpd/trunk/docs/conf/mime.types?view=markup * This file has been placed in the public domain for unlimited redistribution. + * + * All extra changes made to this file must be comitted to /build/controllers/MimeTypeController.php + * otherwise they will be lost on next build. */ $mimeTypes = [ 123 => 'application/vnd.lotus-1-2-3', diff --git a/framework/log/FileTarget.php b/framework/log/FileTarget.php index d816f73a8d..d4c267e2ed 100644 --- a/framework/log/FileTarget.php +++ b/framework/log/FileTarget.php @@ -107,9 +107,8 @@ class FileTarget extends Target public function export() { $text = implode("\n", array_map([$this, 'formatMessage'], $this->messages)) . "\n"; - $trimmedText = trim($text); - if (empty($trimmedText)) { + if (trim($text) === '') { return; // No messages to export, so we exit the function early } diff --git a/framework/messages/ko/yii.php b/framework/messages/ko/yii.php index 4fe82311fe..b0ad872fe7 100644 --- a/framework/messages/ko/yii.php +++ b/framework/messages/ko/yii.php @@ -75,8 +75,8 @@ return [ '{attribute} must be greater than or equal to "{compareValue}".' => '{attribute}는 "{compareValue}" 보다 크거나 같아야 합니다.', '{attribute} must be less than "{compareValue}".' => '{attribute}는 "{compareValue}" 보다 작아야 합니다.', '{attribute} must be less than or equal to "{compareValue}".' => '{attribute}는 "{compareValue}" 보다 작거나 같아야 합니다.', - '{attribute} must be no greater than {max}.' => '{attribute}는 "{compareValue}" 보다 클 수 없습니다.', - '{attribute} must be no less than {min}.' => '{attribute}는 "{compareValue}" 보다 작을 수 없습니다.', + '{attribute} must be no greater than {max}.' => '{attribute}는 "{max}" 보다 클 수 없습니다.', + '{attribute} must be no less than {min}.' => '{attribute}는 "{min}" 보다 작을 수 없습니다.', '{attribute} must be repeated exactly.' => '{attribute}는 정확하게 반복합니다.', '{attribute} must not be equal to "{compareValue}".' => '{attribute}는 "{compareValue}"와 같을 수 없습니다.', '{attribute} should contain at least {min, number} {min, plural, one{character} other{characters}}.' => '{attribute}는 최소 {min}자 이어야합니다.', diff --git a/framework/widgets/MaskedInputAsset.php b/framework/widgets/MaskedInputAsset.php index 57748be86f..473f4315ff 100644 --- a/framework/widgets/MaskedInputAsset.php +++ b/framework/widgets/MaskedInputAsset.php @@ -21,7 +21,7 @@ class MaskedInputAsset extends AssetBundle { public $sourcePath = '@bower/inputmask/dist'; public $js = [ - 'jquery.inputmask.bundle.js', + 'jquery.inputmask.js', ]; public $depends = [ 'yii\web\YiiAsset', diff --git a/tests/framework/db/ActiveRecordTest.php b/tests/framework/db/ActiveRecordTest.php index b74803b94d..e50cca3e02 100644 --- a/tests/framework/db/ActiveRecordTest.php +++ b/tests/framework/db/ActiveRecordTest.php @@ -2225,6 +2225,53 @@ abstract class ActiveRecordTest extends DatabaseTestCase $attr = 'model2.doesNotExist.attr1'; $this->assertEquals($model->generateAttributeLabel($attr), $model->getAttributeLabel($attr)); } + + public function testLoadRelations() + { + // Test eager loading relations for multiple primary models using loadRelationsFor(). + /** @var Customer[] $customers */ + $customers = Customer::find()->all(); + Customer::loadRelationsFor($customers, ['orders.items']); + foreach ($customers as $customer) { + $this->assertTrue($customer->isRelationPopulated('orders')); + foreach ($customer->orders as $order) { + $this->assertTrue($order->isRelationPopulated('items')); + } + } + + // Test eager loading relations as arrays. + /** @var array $customers */ + $customers = Customer::find()->asArray(true)->all(); + Customer::loadRelationsFor($customers, ['orders.items' => function ($query) { $query->asArray(false); }], true); + foreach ($customers as $customer) { + $this->assertTrue(isset($customer['orders'])); + $this->assertTrue(is_array($customer['orders'])); + foreach ($customer['orders'] as $order) { + $this->assertTrue(is_array($order)); + $this->assertTrue(isset($order['items'])); + $this->assertTrue(is_array($order['items'])); + foreach ($order['items'] as $item) { + $this->assertFalse(is_array($item)); + } + } + } + + // Test eager loading relations for a single primary model using loadRelations(). + /** @var Customer $customer */ + $customer = Customer::find()->where(['id' => 1])->one(); + $customer->loadRelations('orders.items'); + $this->assertTrue($customer->isRelationPopulated('orders')); + foreach ($customer->orders as $order) { + $this->assertTrue($order->isRelationPopulated('items')); + } + + // Test eager loading previously loaded relation (relation value should be replaced with a new value loaded from database). + /** @var Customer $customer */ + $customer = Customer::find()->where(['id' => 2])->with(['orders' => function ($query) { $query->orderBy(['id' => SORT_ASC]); }])->one(); + $this->assertTrue($customer->orders[0]->id < $customer->orders[1]->id, 'Related models should be sorted by ID in ascending order.'); + $customer->loadRelations(['orders' => function ($query) { $query->orderBy(['id' => SORT_DESC]); }]); + $this->assertTrue($customer->orders[0]->id > $customer->orders[1]->id, 'Related models should be sorted by ID in descending order.'); + } } class LabelTestModel1 extends \yii\db\ActiveRecord