Enregistrement actif (Active Record)

L'enregistrement actif fournit une interface orientée objet pour accéder aux données stockées dans une base de données et les manipuler. Une classe d'enregistrement actif (ActiveRecord) est associée à une table de base de données, une instance de cette classe représente une ligne de cette table, et un attribut d'une instance d'enregistrement actif représente la valeur d'une colonne particulière dans cette ligne. Au lieu d'écrire des instructions SQL brutes, vous pouvez accéder aux attributs de l'objet enregistrement actif et appeler ses méthodes pour accéder aux données stockées dans les tables de la base de données et les manipuler.

Par exemple, supposons que Customer soit une classe d'enregistrement actif associée à la table customer et que name soit une colonne de la table customer. Vous pouvez écrire le code suivant pour insérer une nouvelle ligne dans la table customer :

$customer = new Customer();
$customer->name = 'Qiang';
$customer->save();

Le code ci-dessus est équivalent à l'utilisation de l'instruction SQL brute suivante pour MySQL, qui est moins intuitive, plus propice aux erreurs, et peut même poser des problèmes de compatibilité sur vous utilisez un système de gestion de base données différent.

$db->createCommand('INSERT INTO `customer` (`name`) VALUES (:name)', [
    ':name' => 'Qiang',
])->execute();

Yii assure la prise en charge de l'enregistrement actif (Active Record) pour les bases de données relationnelles suivantes :

De plus, Yii prend aussi en charge l'enregistrement actif (Active Record) avec les bases de données non SQL suivantes :

Dans ce tutoriel, nous décrivons essentiellement l'utilisation de l'enregistrement actif pour des bases de données relationnelles. Cependant, la majeure partie du contenu décrit ici est aussi applicable aux bases de données non SQL.

Déclaration des classes d'enregistrement actif (Active Record)

Pour commencer, déclarez une classe d'enregistrement actif en étendant la classe yii\db\ActiveRecord.

Définir un nom de table

Par défaut, chacune des classes d'enregistrement actif est associée à une table de la base de données. La méthode tableName() retourne le nom de la table en convertissant le nom via yii\helpers\Inflector::camel2id(). Vous pouvez redéfinir cette méthode si le nom de la table ne suit pas cette convention.

Un préfixe de table par défaut peut également être appliqué.
Par exemple, si le préfixe de table est tbl_, Customer devient tbl_customer et OrderItem devient tbl_order_item.

Si un nom de table est fourni sous la forme {{%TableName}}, alors le caractère % est remplacé par le préfixe de table. Par exemple, {{%post}} devient {{tbl_post}}. Les accolades autour du nom de table sont utilisées pour l'entourage par des marques de citation dans une requête SQL .

Dans l'exemple suivant, nous déclarons une classe d'enregistrement actif nommée Customer pour la table de base de données customer.

namespace app\models;

use yii\db\ActiveRecord;

class Customer extends ActiveRecord
{
    const STATUS_INACTIVE = 0;
    const STATUS_ACTIVE = 1;
    
    /**
     * @return string le nom de la table associée à cette classe d'enregistrement actif.
     */
    public static function tableName()
    {
        return 'customer';
    }
}

Les enregistrements actifs sont appelés "modèles"

Les instances d'une classe d'enregistrement actif (Active Record) sont considérées comme des modèles. Pour cette raison, nous plaçons les classes d'enregistrement actif dans l'espace de noms app\models(ou autres espaces de noms prévus pour contenir des classes de modèles).

Comme la classe yii\db\ActiveRecord étend la classe yii\base\Model, elle hérite de toutes les fonctionnalités d'un modèle, comme les attributs, les règles de validation, la sérialisation des données, etc.

Connexion aux bases de données

Par défaut, l'enregistrement actif utilise le composant d'application db en tant que connexion à une base de données pour accéder aux données de la base de données et les manipuler. Comme expliqué dans la section Objets d'accès aux bases de données, vous pouvez configurer le composant db dans la configuration de l'application comme montré ci-dessous :

return [
    'components' => [
        'db' => [
            'class' => 'yii\db\Connection',
            'dsn' => 'mysql:host=localhost;dbname=testdb',
            'username' => 'demo',
            'password' => 'demo',
        ],
    ],
];

Si vous voulez utiliser une connexion de base de données autre que le composant db, vous devez redéfinir la méthode getDb() :

class Customer extends ActiveRecord
{
    // ...

    public static function getDb()
    {
        // utilise le composant d'application "db2"
        return \Yii::$app->db2;
    }
}

Requête de données

Après avoir déclaré une classe d'enregistrement actif, vous pouvez l'utiliser pour faire une requête de données de la table correspondante dans la base de données. Ce processus s'accomplit en général en trois étapes :

  1. Créer un nouvel objet query (requête) en appelant la méthode yii\db\ActiveRecord::find() ;
  2. Construire l'objet query en appelant des méthodes de construction de requête;
  3. Appeler une méthode de requête pour retrouver les données en terme d'instances d'enregistrement actif.

Comme vous pouvez le voir, cela est très similaire à la procédure avec le constructeur de requêtes. La seule différence est que, au lieu d'utiliser l'opérateur new pour créer un objet query (requête), vous appelez la méthode yii\db\ActiveRecord::find() pour retourner un nouvel objet query qui est de la classe yii\db\ActiveQuery.

Ce-dessous, nous donnons quelques exemples qui montrent comment utiliser l'Active Query (requête active) pour demander des données :

// retourne un client (*customer*) unique dont l'identifiant est 123
// SELECT * FROM `customer` WHERE `id` = 123
$customer = Customer::find()
    ->where(['id' => 123])
    ->one();

// retourne tous les clients actifs et les classe par leur identifiant
// SELECT * FROM `customer` WHERE `status` = 1 ORDER BY `id`
$customers = Customer::find()
    ->where(['status' => Customer::STATUS_ACTIVE])
    ->orderBy('id')
    ->all();

// retourne le nombre de clients actifs 
// SELECT COUNT(*) FROM `customer` WHERE `status` = 1
$count = Customer::find()
    ->where(['status' => Customer::STATUS_ACTIVE])
    ->count();

// retourne tous les clients dans un tableau indexé par l'identifiant du client 
// SELECT * FROM `customer`
$customers = Customer::find()
    ->indexBy('id')
    ->all();

Dans le code ci-dessus, $customer est un objet Customer tandis que $customers est un tableau d'objets Customer. Ils sont tous remplis par les données retrouvées dans la table customer.

Info: comme la classe yii\db\ActiveQuery étend la classe yii\db\Query, vous pouvez utiliser toutes les méthodes de construction et de requête comme décrit dans la section sur le constructeur de requête.

Parce que faire une requête de données par les valeurs de clés primaires ou par jeu de valeurs de colonne est une tâche assez courante, Yii fournit une prise en charge de méthodes raccourcis pour cela :

Les deux méthodes acceptent un des formats de paramètres suivants :

  • une valeur scalaire : la valeur est traitée comme la valeur de la clé primaire à rechercher. Yii détermine automatiquement quelle colonne est la colonne de clé primaire en lisant les informations du schéma de la base de données.
  • un tableau de valeurs scalaires : le tableau est traité comme les valeurs de clé primaire désirées à rechercher.
  • un tableau associatif : les clés sont les noms de colonne et les valeurs sont les valeurs de colonne désirées à rechercher. Reportez-vous au format haché pour plus de détails.

Le code qui suit montre comment ces méthodes peuvent être utilisées :

// retourne un client unique dont l'identifiant est 123
// SELECT * FROM `customer` WHERE `id` = 123
$customer = Customer::findOne(123);

// retourne les clients dont les identifiants sont 100, 101, 123 ou 124
// SELECT * FROM `customer` WHERE `id` IN (100, 101, 123, 124)
$customers = Customer::findAll([100, 101, 123, 124]);

// retourne un client actif dont l'identifiant est 123
// SELECT * FROM `customer` WHERE `id` = 123 AND `status` = 1
$customer = Customer::findOne([
    'id' => 123,
    'status' => Customer::STATUS_ACTIVE,
]);

// retourne tous les clients inactifs 
// SELECT * FROM `customer` WHERE `status` = 0
$customers = Customer::findAll([
    'status' => Customer::STATUS_INACTIVE,
]);

Attention : si vous avez besoin de passer des saisies utilisateur à ces méthodes, assurez-vous que la valeurs saisie est un scalaire ou dans le cas d'une condition tableau, assurez-vous que la structure du tableau ne peut pas être changée depuis l'extérieur :

// yii\web\Controller garantit que $id est un scalaire
public function actionView($id)
{
    $model = Post::findOne($id);
    // ...
}

// spécifier explicitement la colonne à chercher, passer un scalaire ou un tableau ici, aboutit à retrouver un enregistrement unique
$model = Post::findOne(['id' => Yii::$app->request->get('id')]);

// n'utilisez PAS le code suivant si possible ! Il est possible d'injecter une condition tableau pour filtrer par des valeurs de colonne arbitraires !
$model = Post::findOne(Yii::$app->request->get('id'));

Note: ni yii\db\ActiveRecord::findOne(), ni yii\db\ActiveQuery::one() n'ajoutent LIMIT 1 à l'instruction SQL générée. Si votre requête peut retourner plusieurs lignes de données, vous devez appeler limit(1) explicitement pour améliorer la performance, p. ex., Customer::find()->limit(1)->one().

En plus d'utiliser les méthodes de construction de requête, vous pouvez aussi écrire du SQL brut pour effectuer une requête de données et vous servir des résultats pour remplir des objets enregistrements actifs. Vous pouvez le faire en appelant la méthode yii\db\ActiveRecord::findBySql() :

// retourne tous les clients inactifs
$sql = 'SELECT * FROM customer WHERE status=:status';
$customers = Customer::findBySql($sql, [':status' => Customer::STATUS_INACTIVE])->all();

N'appelez pas de méthodes de construction de requêtes supplémentaires après avoir appelé findBySql() car elles seront ignorées.

Accès aux données

Comme nous l'avons mentionné plus haut, les données extraites de la base de données servent à remplir des instances de la classe d'enregistrement actif et chacune des lignes du résultat de la requête correspond à une instance unique de la classe d'enregistrement actif. Vous pouvez accéder accéder aux valeurs des colonnes en accédant aux attributs des instances de la classe d'enregistrement actif, par exemple :

// "id" et "email" sont les noms des colonnes de la table "customer"
$customer = Customer::findOne(123);
$id = $customer->id;
$email = $customer->email;

Note: les attributs de l'instance de la classe d'enregistrement actif sont nommés d'après les noms des colonnes de la table associée en restant sensibles à la casse. Yii définit automatiquement un attribut dans l'objet enregistrement actif pour chacune des colonnes de la table associée. Vous ne devez PAS déclarer à nouveau l'un quelconque des ces attributs.

Comme les attributs de l'instance d'enregistrement actif sont nommés d'après le nom des colonnes, vous pouvez vous retrouver en train d'écrire du code PHP tel que $customer->first_name, qui utilise le caractère (_) souligné pour séparer les mots dans les noms d'attributs si vos colonnes de table sont nommées de cette manière. Si vous êtes attaché à la cohérence du style de codage, vous devriez renommer vos colonnes de tables en conséquence (p. ex. en utilisant la notation en dos de chameau).

Transformation des données

Il arrive souvent que les données entrées et/ou affichées soient dans un format qui diffère de celui utilisé pour stocker les données dans la base. Par exemple, dans la base de données, vous stockez la date d'anniversaire des clients sous la forme de horodates UNIX (bien que ce soit pas une conception des meilleures), tandis que dans la plupart des cas, vous avez envie de manipuler les dates d'anniversaire sous la forme de chaînes de caractères dans le format 'YYYY/MM/DD'. Pour le faire, vous pouvez définir des méthodes de transformation de données dans la classe d'enregistrement actif comme ceci :

class Customer extends ActiveRecord
{
    // ...

    public function getBirthdayText()
    {
        return date('Y/m/d', $this->birthday);
    }
    
    public function setBirthdayText($value)
    {
        $this->birthday = strtotime($value);
    }
}

Désormais, dans votre code PHP, au lieu d'accéder à $customer->birthday, vous devez accéder à $customer->birthdayText, ce qui vous permet d'entrer et d'afficher les dates d'anniversaire dans le format 'YYYY/MM/DD'.

Tip: l'exemple qui précède montre une manière générique de transformer des données dans différents formats. Si vous travaillez avec des valeurs de dates, vous pouvez utiliser DateValidator et DatePicker, qui sont plus faciles à utiliser et plus puissantes.

Retrouver des données dans des tableaux

Alors que retrouver des données en termes d'objets enregistrements actifs est souple et pratique, cela n'est pas toujours souhaitable lorsque vous devez extraire une grande quantité de données à cause de l'empreinte mémoire très importante. Dans ce cas, vous pouvez retrouver les données en utilisant des tableaux PHP en appelant asArray() avant d'exécuter une méthode de requête :

// retourne tous les clients
// chacun des clients est retourné sous forme de tableau associatif
$customers = Customer::find()
    ->asArray()
    ->all();

Note: bien que cette méthode économise de la mémoire et améliore la performance, elle est plus proche de la couche d'abstraction basse de la base de données et perd la plupart des fonctionnalités de l'objet enregistrement actif. Une distinction très importante réside dans le type de données des valeurs de colonne. Lorsque vous retournez des données dans une instance d'enregistrement actif, les valeurs des colonnes sont automatiquement typées en fonction du type réel des colonnes ; par contre, lorsque vous retournez des données dans des tableaux, les valeurs des colonnes sont des chaînes de caractères (parce qu'elles résultent de PDO sans aucun traitement), indépendamment du type réel de ces colonnes.

Retrouver des données dans des lots

Dans la section sur le constructeur de requêtes, nous avons expliqué que vous pouvez utiliser des requêtes par lots pour minimiser l'utilisation de la mémoire lorsque vous demandez de grandes quantités de données de la base de données. Vous pouvez utiliser la même technique avec l'enregistrement actif. Par exemple :

// va chercher 10 clients (customer) à la fois
foreach (Customer::find()->batch(10) as $customers) {
    // $customers est un tableau de 10 (ou moins) objets Customer 
}

// va chercher 10 clients (customers) à la fois et itère sur chacun d'eux 
foreach (Customer::find()->each(10) as $customer) {
    // $customer est un objet Customer 
}

// requête par lots avec chargement précoce 
foreach (Customer::find()->with('orders')->each() as $customer) {
    // $customer est un objet Customer avec la relation 'orders' remplie
}

Sauvegarde des données

En utilisant l'enregistrement actif, vous pouvez sauvegarder facilement les données dans la base de données en suivant les étapes suivantes :

  1. Préparer une instance de la classe d'enregistrement actif
  2. Assigner de nouvelles valeurs aux attributs de cette instance
  3. Appeler yii\db\ActiveRecord::save() pour sauvegarder les données dans la base de données.

Par exemple :

// insère une nouvelle ligne de données
$customer = new Customer();
$customer->name = 'James';
$customer->email = 'james@example.com';
$customer->save();

// met à jour une ligne de données existante
$customer = Customer::findOne(123);
$customer->email = 'james@newexample.com';
$customer->save();

La méthode save() peut soit insérer, soit mettre à jour une ligne de données, selon l'état de l'instance de l'enregistrement actif. Si l'instance est en train d'être créée via l'opérateur new, appeler save() provoque l'insertion d'une nouvelle ligne de données ; si l'instance est le résultat d'une méthode de requête, appeler save() met à jour la ligne associée à l'instance.

Vous pouvez différentier les deux états d'une instance d'enregistrement actif en testant la valeur de sa propriété yii\db\ActiveRecord::isNewRecord. Cette propriété est aussi utilisée par save() en interne, comme ceci :

public function save($runValidation = true, $attributeNames = null)
{
    if ($this->getIsNewRecord()) {
        return $this->insert($runValidation, $attributeNames);
    } else {
        return $this->update($runValidation, $attributeNames) !== false;
    }
}

Astuce: vous pouvez appeler insert() ou update() directement pour insérer ou mettre à jour une ligne.

Validation des données

Comme la classe yii\db\ActiveRecord étend la classe yii\base\Model, elle partage la même fonctionnalité de validation des données. Vous pouvez déclarer les règles de validation en redéfinissant la méthode rules() et effectuer la validation des données en appelant la méthode validate().

Lorsque vous appelez la méthode save(), par défaut, elle appelle automatiquement la méthode validate(). C'est seulement si la validation réussit, que les données sont effectivement sauvegardées ; autrement elle retourne simplement false, et vous pouvez tester la propriété yii\db\ActiveRecord::errors pour retrouver les messages d'erreurs de validation.

Astuce: si vous avez la certitude que vos données n'ont pas besoin d'être validées (p. ex. vos données proviennent de sources fiables), vous pouvez appeler save(false) pour omettre la validation.

Assignation massive

Comme les modèles habituels, les instances d'enregistrement actif profitent de la fonctionnalité d'assignation massive. L'utilisation de cette fonctionnalité vous permet d'assigner plusieurs attributs d'un enregistrement actif en une seule instruction PHP, comme c'est montré ci-dessous. N'oubliez cependant pas que, seuls les attributs sûrs sont assignables en masse.

$values = [
    'name' => 'James',
    'email' => 'james@example.com',
];

$customer = new Customer();

$customer->attributes = $values;
$customer->save();

Mise à jour des compteurs

C'est une tâche courante que d'incrémenter ou décrémenter une colonne dans une table de base de données. Nous appelons ces colonnes « colonnes compteurs*. Vous pouvez utiliser la méthode updateCounters() pour mettre à jour une ou plusieurs colonnes de comptage. Par exemple :

$post = Post::findOne(100);

// UPDATE `post` SET `view_count` = `view_count` + 1 WHERE `id` = 100
$post->updateCounters(['view_count' => 1]);

Note: si vous utilisez la méthode yii\db\ActiveRecord::save() pour mettre à jour une colonne compteur, vous pouvez vous retrouver avec un résultat erroné car il est probable que le même compteur soit sauvegardé par de multiples requêtes qui lisent et écrivent la même valeur de compteur.

Attributs sales (Dirty Attributes)

Lorsque vous appelez la méthode save() pour sauvegarder une instance d'enregistrement actif, seuls les attributs dit attributs sales sont sauvegardés. Un attribut est considéré comme sale si sa valeur a été modifiée depuis qu'il a été chargé depuis la base de données ou sauvegardé dans la base de données le plus récemment. Notez que la validation des données est assurée sans se préoccuper de savoir si l'instance d'enregistrement actif possède des attributs sales ou pas.

L'enregistrement actif tient à jour la liste des attributs sales. Il le fait en conservant une version antérieure des valeurs d'attribut et en les comparant avec les dernières. Vous pouvez appeler la méthode yii\db\ActiveRecord::getDirtyAttributes() pour obtenir les attributs qui sont couramment sales. Vous pouvez aussi appeler la méthode yii\db\ActiveRecord::markAttributeDirty() pour marquer explicitement un attribut comme sale.

Si vous êtes intéressé par les valeurs d'attribut antérieurs à leur plus récente modification, vous pouvez appeler la méthode getOldAttributes() ou la méthode getOldAttribute().

Note: la comparaison entre les anciennes et les nouvelles valeurs est faite en utilisant l'opérateur === , ainsi une valeur est considérée comme sale si le type est différent même si la valeur reste la même. Cela est souvent le cas lorsque le modèle reçoit des entrées utilisateur de formulaires HTML ou chacune des valeurs est représentée par une chaîne de caractères. Pour garantir le type correct pour p. ex. des valeurs entières, vous devez appliquer un filtre de validation: ['attributeName', 'filter', 'filter' => 'intval']. Cela fonctionne pour toutes les fonctions de transformation de type de PHP comme intval(), floatval(), boolval, etc...

Valeurs d'attribut par défaut

Quelques unes de vos colonnes de tables peuvent avoir des valeurs par défaut définies dans la base de données. Parfois, vous voulez peut-être pré-remplir votre formulaire Web pour un enregistrement actif à partir des valeurs par défaut. Pour éviter d'écrire les mêmes valeurs par défaut à nouveau, vous pouvez appeler la méthode loadDefaultValues() pour remplir les attributs de l'enregistrement actif avec les valeurs par défaut prédéfinies dans la base de données :

$customer = new Customer();
$customer->loadDefaultValues();
// $customer->xyz recevra la valeur par défaut déclarée lors de la définition de la colonne « xyz » column

Conversion de type d'attributs

Étant peuplé par les résultats des requêtes, l'enregistrement actif effectue des conversions automatiques de type pour ses valeurs d'attribut, en utilisant les informations du schéma des tables de base de données. Cela permet aux données retrouvées dans les colonnes de la table et déclarées comme entiers de peupler une instance d'enregistrement actif avec des entiers PHP, les valeurs booléennes avec des valeurs booléennes, et ainsi de suite. Néanmoins, le mécanisme de conversion de type souffre de plusieurs limitation :

  • Les valeurs flottantes (Float) ne sont pas converties et sont représentées par des chaînes de caractères, autrement elles pourraient perdre de la précision.
  • La conversion des valeurs entières dépend de la capacité du système d'exploitation utilisé. En particulier, les valeurs de colonne déclarée comme « entier non signé », ou « grand entier » (big integer) sont converties en entier PHP seulement pour les système d'exploitation 64 bits, tandis que sur les systèmes 32 bits, elles sont représentées par des chaînes de caractères.

Notez que la conversion de type des attributs n'est effectuée que lors du peuplement d'une instance d'enregistrement actif par les résultats d'une requête. Il n'y a pas de conversion automatique pour les valeurs chargées par une requête HTTP ou définies directement par accès à des propriétés. Le schéma de table est aussi utilisé lors de la préparation des instructions SQL pour la sauvegarde de l'enregistrement actif, garantissant ainsi que les valeurs sont liées à la requête avec le type correct. Cependant, les valeurs d'attribut d'une instance d'enregistrement actif ne sont pas converties durant le processus de sauvegarde.

Astuce : vous pouvez utiliser yii\behaviors\AttributeTypecastBehavior pour faciliter la conversion de type des valeurs d'attribut lors de la validation ou la sauvegarde d'un enregistrement actif.

Depuis la version 2.0.14, la classe ActiveRecord de Yii prend en charge des types de données complexe tels que JSON ou les tableaux multi-dimensionnels.

JSON dans MySQL et PostgreSQL

Après le peuplement par les données, la valeur d'une colonne JSON est automatiquement décodée selon les règles de décodage standard de JSON.

Pour sauvegarder une valeur d'attribut dans une colonne de type JSON, la classe ActiveRecord crée automatiquement un objet JsonExpression qui est encodé en une chaîne JSON au niveau du constructeur de requête.

Tableaux dans PostgreSQL

Après le peuplement par les données, les valeurs issues de colonnes de type tableau sont automatiquement décodée de la notation PgSQL en un objet ArrayExpression. Il met en œuvre l'interface ArrayAccess, ainsi pouvez-vous l'utiliser comme un tableau, ou appeler ->getValue() pour obtenir le tableau lui-même.

Pour sauvegarder une valeur d'attribut dans une colonne de type tableau,la classe ActiveRecord crée automatiquement un objet ArrayExpression qui est encodé par le constructeur de requête en une chaîne PgSQL représentant le tableau.

Vous pouvez aussi utiliser des conditions pour les colonnes de type JSON :

$query->andWhere(['=', 'json', new ArrayExpression(['foo' => 'bar'])

Pour en apprendre plus sur les le système de construction d'expressions, reportez-vous à l'article Constructeur de requêtes – Ajout de conditions et d'expressions personnalisées.

Mise à jour de plusieurs lignes

Les méthodes décrites ci-dessus fonctionnent toutes sur des instances individuelles d'enregistrement actif pour insérer ou mettre à jour des lignes individuelles de table. Pour mettre à jour plusieurs lignes à la fois, vous devez appeler la méthode statique updateAll().

// UPDATE `customer` SET `status` = 1 WHERE `email` LIKE `%@example.com%`
Customer::updateAll(['status' => Customer::STATUS_ACTIVE], ['like', 'email', '@example.com']);

De façon similaire, vous pouvez appeler updateAllCounters() pour mettre à jour les colonnes compteurs de plusieurs lignes à la fois.

// UPDATE `customer` SET `age` = `age` + 1
Customer::updateAllCounters(['age' => 1]);

Suppression de données

Pour supprimer une ligne unique de données, commencez par retrouver l'instance d'enregistrement actif correspondant à cette ligne et appelez la méthode yii\db\ActiveRecord::delete().

$customer = Customer::findOne(123);
$customer->delete();

Vous pouvez appeler yii\db\ActiveRecord::deleteAll() pour effacer plusieurs ou toutes les lignes de données. Par exemple :

Customer::deleteAll(['status' => Customer::STATUS_INACTIVE]);

Note : agissez avec prudence lorsque vous appelez deleteAll() parce que cela peut effacer totalement toutes les données de votre table si vous faites une erreur en spécifiant la condition.

Cycles de vie de l'enregistrement actif

Il est important que vous compreniez les cycles de vie d'un enregistrement actif lorsqu'il est utilisé à des fins différentes. Lors de chaque cycle de vie, une certaine séquence d'invocation de méthodes a lieu, et vous pouvez redéfinir ces méthodes pour avoir une chance de personnaliser le cycle de vie. Vous pouvez également répondre à certains événements de l'enregistrement actif déclenchés durant un cycle de vie pour injecter votre code personnalisé. Ces événements sont particulièrement utiles lorsque vous développez des comportements d'enregistrement actif qui ont besoin de personnaliser les cycles de vie d'enregistrement actifs.

Dans l'exemple suivant, nous résumons les différents cycles de vie d'enregistrement actif et les méthodes/événements à qui il est fait appel dans ces cycles.

Cycle de vie d'une nouvelle instance

Lorsque vous créez un nouvel enregistrement actif via l'opérateur new, le cycle suivant se réalise :

  1. Construction de la classe.
  2. init(): déclenche un événement EVENT_INIT.

Cycle de vie lors d'une requête de données

Lorsque vous effectuez une requête de données via l'une des méthodes de requête, chacun des enregistrements actifs nouvellement rempli entreprend le cycle suivant :

  1. Construction de la classe.
  2. init(): déclenche un événement EVENT_INIT.
  3. afterFind(): déclenche un événement EVENT_AFTER_FIND.

Cycle de vie lors d'une sauvegarde de données

En appelant save() pour insérer ou mettre à jour une instance d'enregistrement actif, le cycle de vie suivant se réalise :

  1. beforeValidate(): déclenche un événement EVENT_BEFORE_VALIDATE . Si la méthode retourne false (faux), ou si yii\base\ModelEvent::$isValid est false, les étapes suivantes sont sautées.
  2. Effectue la validation des données. Si la validation échoue, les étapes après l'étape 3 saut sautées.
  3. afterValidate(): déclenche un événement EVENT_AFTER_VALIDATE.
  4. beforeSave(): déclenche un événement EVENT_BEFORE_INSERT ou un événement EVENT_BEFORE_UPDATE. Si la méthode retourne false ou si yii\base\ModelEvent::$isValid est false, les étapes suivantes sont sautées.
  5. Effectue l'insertion ou la mise à jour réelle.
  6. afterSave(): déclenche un événement EVENT_AFTER_INSERT ou un événement EVENT_AFTER_UPDATE.

Cycle de vie lors d'une suppression de données

En appelant delete() pour supprimer une instance d'enregistrement actif, le cycle suivant se déroule :

  1. beforeDelete(): déclenche un événement EVENT_BEFORE_DELETE. Si la méthode retourne false ou si yii\base\ModelEvent::$isValid est false, les étapes suivantes sont sautées.
  2. Effectue la suppression réelle des données.
  3. afterDelete(): déclenche un événement EVENT_AFTER_DELETE.

Note : l'appel de l'une des méthodes suivantes n'initie AUCUN des cycles vus ci-dessus parce qu'elles travaillent directement sur la base de données et pas sur la base d'un enregistrement actif :

Cycle de vie lors du rafraîchissement des données

En appelant refresh() pour rafraîchir une instance d'enregistrement actif, l'événement EVENT_AFTER_REFRESH est déclenché si le rafraîchissement réussit et si la méthode retourne true.

Travail avec des transactions

Il y a deux façons d'utiliser les transactions lorsque l'on travaille avec un enregistrement actif.

La première façon consiste à enfermer explicitement les appels des différents méthodes dans un bloc transactionnel, comme ci-dessous :

$customer = Customer::findOne(123);

Customer::getDb()->transaction(function($db) use ($customer) {
    $customer->id = 200;
    $customer->save();
    // ...autres opérations de base de données...
});

// ou en alternative

$transaction = Customer::getDb()->beginTransaction();
try {
    $customer->id = 200;
    $customer->save();
    // ...other DB operations...
    $transaction->commit();
} catch(\Exception $e) {
    $transaction->rollBack();
    throw $e;
} catch(\Throwable $e) {
    $transaction->rollBack();
    throw $e;
}

Note : dans le code précédent, nous utilisons deux blocs de capture pour être compatible avec PHP 5.x et PHP 7.x. \Exception met en œuvre l'interface \Throwable à partir de PHP 7.0, c'est pourquoi vous pouvez sauter la partie avec \Exception si votre application utilise PHP 7.0 ou une version plus récente.

La deuxième façon consiste à lister les opérations de base de données qui nécessitent une prise en charge transactionnelle dans la méthode yii\db\ActiveRecord::transactions(). Par exemple :

class Customer extends ActiveRecord
{
    public function transactions()
    {
        return [
            'admin' => self::OP_INSERT,
            'api' => self::OP_INSERT | self::OP_UPDATE | self::OP_DELETE,
            // ce qui précède est équivalent à ce qui suit :
            // 'api' => self::OP_ALL,
        ];
    }
}

La méthode yii\db\ActiveRecord::transactions() doit retourner un tableau dont les clés sont les noms de scénario et les valeurs les opérations correspondantes qui doivent être enfermées dans des transactions. Vous devez utiliser les constantes suivantes pour faire référence aux différentes opérations de base de données :

Utilisez l'opérateur | pour concaténer les constantes précédentes pour indiquer de multiples opérations. Vous pouvez également utiliser la constante raccourci OP_ALL pour faire référence à l'ensemble des trois opération ci-dessus.

Les transactions qui sont créées en utilisant cette méthode sont démarrées avant d'appeler beforeSave() et sont entérinées après que la méthode afterSave() a été exécutée.

Verrous optimistes

Le verrouillage optimiste est une manière d'empêcher les conflits qui peuvent survenir lorsqu'une même ligne de données est mise à jour par plusieurs utilisateurs. Par exemple, les utilisateurs A et B sont tous deux, simultanément, en train de modifier le même article de wiki. Après que l'utilisateur A a sauvegardé ses modifications, l'utilisateur B clique sur le bouton « Sauvegarder » dans le but de sauvegarder ses modifications lui aussi. Comme l'utilisateur B est en train de travailler sur une version périmée de l'article, il serait souhaitable de disposer d'un moyen de l'empêcher de sauvegarder sa version de l'article et de lui montrer un message d'explication.

Le verrouillage optimiste résout le problème évoqué ci-dessus en utilisant une colonne pour enregistrer le numéro de version de chacune des lignes. Lorsqu'une ligne est sauvegardée avec un numéro de version périmée, une exception yii\db\StaleObjectException est levée, ce qui empêche la sauvegarde de la ligne. Le verrouillage optimiste, n'est seulement pris en charge que lorsque vous mettez à jour ou supprimez une ligne de données existante en utilisant les méthodes yii\db\ActiveRecord::update() ou yii\db\ActiveRecord::delete(),respectivement.

Pour utiliser le verrouillage optimiste :

  1. Créez une colonne dans la table de base de données associée à la classe d'enregistrement actif pour stocker le numéro de version de chacune des lignes. La colonne doit être du type big integer (dans MySQL ce doit être BIGINT DEFAULT 0).
  2. Redéfinissez la méthode yii\db\ActiveRecord::optimisticLock() pour qu'elle retourne le nom de cette colonne.
  3. Dans la classe de votre modèle, mettez en œuvre OptimisticLockBehavior pour analyser automatiquement sa valeur des requêtes reçues.
  4. Dans le formulaire Web qui reçoit les entrées de l'utilisateur, ajoutez un champ caché pour stocker le numéro de version courant de la ligne en modification. Retirez l'attribut version des règles de validation étant donné que OptimisticLockBehavior s'en charge.
  5. Dans l'action de contrôleur qui met la ligne à jour en utilisant l'enregistrement actif, utiliser une structure try-catch pour l'exception yii\db\StaleObjectException. Mettez en œuvre la logique requise (p. ex. fusionner les modifications, avertir des données douteuses) pour résoudre le conflit. Par exemple, supposons que la colonne du numéro de version est nommée version. Vous pouvez mettre en œuvre le verrouillage optimiste avec un code similaire au suivant :
// ------ view code -------

use yii\helpers\Html;

// ...other input fields
echo Html::activeHiddenInput($model, 'version');


// ------ controller code -------

use yii\db\StaleObjectException;

public function actionUpdate($id)
{
    $model = $this->findModel($id);

    try {
        if ($model->load(Yii::$app->request->post()) && $model->save()) {
            return $this->redirect(['view', 'id' => $model->id]);
        } else {
            return $this->render('update', [
                'model' => $model,
            ]);
        }
    } catch (StaleObjectException $e) {
        // logique pour résoudre le  conflict
    }
}

// ------ model code -------

use yii\behaviors\OptimisticLockBehavior;

public function behaviors()
{
    return [
        OptimisticLockBehavior::className(),
    ];
}

Note : comme OptimisticLockBehavior garantit que l'enregistrement n'est sauvegardé que si l'utilisateur soumet un numéro de version valide en analysant directement getBodyParam(), il peut être utile d'étendre votre classe de modèle et de réaliser l'étape 2 dans le modèle du parent lors de l'attachement du comportement (étape 3) à la classe enfant; ainsi vous pouvez disposer d'une instance dédiée à l'usage interne tout en liant l'autre aux contrôleurs chargés de recevoir les entrées de l'utilisateur final. En alternative, vous pouvez mettre en œuvre votre propre logique en configurant sa propriété value.

Travail avec des données relationnelles

En plus de travailler avec des tables de base de données individuelles, l'enregistrement actif permet aussi de rassembler des données en relation, les rendant ainsi immédiatement accessibles via les données primaires. Par exemple, la donnée client est en relation avec les données commandes parce qu'un client peut avoir passé une ou plusieurs commandes. Avec les déclarations appropriées de cette relation, vous serez capable d'accéder aux commandes d'un client en utilisant l'expression $customer->orders qui vous renvoie les informations sur les commandes du client en terme de tableau d'instances Order (Commande) d'enregistrement actif.

Déclaration de relations

Pour travailler avec des données relationnelles en utilisant l'enregistrement actif, vous devez d'abord déclarer les relations dans les classes d'enregistrement actif. La tâche est aussi simple que de déclarer une méthode de relation pour chacune des relations concernées, comme ceci :

class Customer extends ActiveRecord
{
    // ...

    public function getOrders()
    {
        return $this->hasMany(Order::className(), ['customer_id' => 'id']);
    }
}

class Order extends ActiveRecord
{
    // ...

    public function getCustomer()
    {
        return $this->hasOne(Customer::className(), ['id' => 'customer_id']);
    }
}

Dans le code ci-dessus, nous avons déclaré une relation orders (commandes) pour la classe Customer (client), et une relation customer (client) pour la classe Order (commande).

Chacune des méthodes de relation doit être nommée sous la forme getXyz. Nous appelons xyz (la première lettre est en bas de casse) le nom de la relation. Notez que les noms de relation sont sensibles à la casse.

En déclarant une relation, vous devez spécifier les informations suivantes :

  • la multiplicité de la relation : spécifiée en appelant soit la méthode hasMany(), soit la méthode hasOne(). Dans l'exemple ci-dessus vous pouvez facilement déduire en lisant la déclaration des relations qu'un client a beaucoup de commandes, tandis qu'une commande n'a qu'un client.
  • le nom de la classe d'enregistrement actif : spécifié comme le premier paramètre de hasMany() ou de hasOne(). Une pratique conseillée est d'appeler Xyz::className() pour obtenir la chaîne de caractères représentant le nom de la classe de manière à bénéficier de l'auto-complètement de l'EDI et de la détection d'erreur dans l'étape de compilation.
  • Le lien entre les deux types de données : spécifie le(s) colonne(s) via lesquelles les deux types de données sont en relation. Les valeurs du tableau sont les colonnes des données primaires (représentées par la classe d'enregistrement actif dont vous déclarez les relations), tandis que les clés sont les colonnes des données en relation.

Une règle simple pour vous rappeler cela est, comme vous le voyez dans l'exemple ci-dessus, d'écrire la colonne qui appartient à l'enregistrement actif en relation juste à coté de lui. Vous voyez là que l'identifiant du client (customer_id) est une propriété de Order et id est une propriété de Customer.

Accès aux données relationnelles

Après avoir déclaré des relations, vous pouvez accéder aux données relationnelles via le nom des relations. Tout se passe comme si vous accédiez à une propriété d'un objet défini par la méthode de relation. Pour cette raison, nous appelons cette propriété propriété de relation. Par exemple :

// SELECT * FROM `customer` WHERE `id` = 123
$customer = Customer::findOne(123);

// SELECT * FROM `order` WHERE `customer_id` = 123
// $orders est un tableau d'objets Order 
$orders = $customer->orders;

Info : lorsque vous déclarez une relation nommée xyz via une méthode d'obtention getXyz(), vous êtes capable d'accéder à xyz comme à un objet property. Notez que le nom est sensible à la casse.

Si une relation est déclarée avec la méthode hasMany(), l'accès à cette propriété de relation retourne un tableau des instances de l'enregistrement actif en relation ; si une relation est déclarée avec la méthode hasOne(), l'accès à la propriété de relation retourne l'instance de l'enregistrement actif en relation, ou null si aucune donnée en relation n'est trouvée.

Lorsque vous accédez à une propriété de relation pour la première fois, une instruction SQL est exécutée comme le montre l'exemple précédent. Si la même propriété fait l'objet d'un nouvel accès, le résultat précédent est retourné sans exécuter à nouveau l'instruction SQL. Pour forcer l'exécution à nouveau de l'instruction SQL, vous devez d'abord annuler la définition de la propriété de relation : unset($customer->orders).

Note : bien que ce concept semble similaire à la fonctionnalité propriété d'objet, il y a une différence importante. Pour les propriétés normales d'objet, la valeur est du même type que la méthode d'obtention de définition. Une méthode de relation cependant retourne toujours une instance d'yii\db\ActiveRecord ou un tableau de telles instances.

$customer->orders; // est un tableau d'objets `Order` 
$customer->getOrders(); // retourne une instance d'ActiveQuery

Cela est utile for créer des requêtes personnalisées, ce qui est décrit dans la section suivante.

Requête relationnelle dynamique

Parce qu'une méthode de relation retourne une instance d'yii\db\ActiveQuery, vous pouvez continuer à construire cette requête en utilisant les méthodes de construction avant de l'exécuter. Par exemple :

$customer = Customer::findOne(123);

// SELECT * FROM `order` WHERE `customer_id` = 123 AND `subtotal` > 200 ORDER BY `id`
$orders = $customer->getOrders()
    ->where(['>', 'subtotal', 200])
    ->orderBy('id')
    ->all();

Contrairement à l'accès à une propriété de relation, chaque fois que vous effectuez une requête relationnelle dynamique via une méthode de relation, une instruction SQL est exécutée, même si la même requête relationnelle dynamique a été effectuée auparavant.

Parfois, vous voulez peut-être paramétrer une déclaration de relation de manière à ce que vous puissiez effectuer des requêtes relationnelles dynamiques plus facilement. Par exemple, vous pouvez déclarer une relation bigOrders comme ceci :,

class Customer extends ActiveRecord
{
    public function getBigOrders($threshold = 100)
    {
        return $this->hasMany(Order::className(), ['customer_id' => 'id'])
            ->where('subtotal > :threshold', [':threshold' => $threshold])
            ->orderBy('id');
    }
}

Par la suite, vous serez en mesure d'effectuer les requêtes relationnelles suivantes :

// SELECT * FROM `order` WHERE `customer_id` = 123 AND `subtotal` > 200 ORDER BY `id`
$orders = $customer->getBigOrders(200)->all();

// SELECT * FROM `order` WHERE `customer_id` = 123 AND `subtotal` > 100 ORDER BY `id`
$orders = $customer->bigOrders;

Relations via une table de jointure

Dans la modélisation de base de données, lorsque la multiplicité entre deux tables en relation est many-to-many (de plusieurs à plusieurs), une table de jointure est en général introduite. Par exemple, la table order (commande) et la table item peuvent être en relation via une table de jointure nommée order_item (item_de_commande). Une commande correspond ensuite à de multiples items de commande, tandis qu'un item de produit correspond lui-aussi à de multiples items de commande (order items).

Lors de la déclaration de telles relations, vous devez appeler soit via(), soit viaTable(), pour spécifier la table de jointure. La différence entre via() et viaTable() est que la première spécifie la table de jointure en termes de noms de relation existante, tandis que la deuxième utilise directement la table de jointure. Par exemple :

class Order extends ActiveRecord
{
    public function getItems()
    {
        return $this->hasMany(Item::className(), ['id' => 'item_id'])
            ->viaTable('order_item', ['order_id' => 'id']);
    }
}

ou autrement,

class Order extends ActiveRecord
{
    public function getOrderItems()
    {
        return $this->hasMany(OrderItem::className(), ['order_id' => 'id']);
    }

    public function getItems()
    {
        return $this->hasMany(Item::className(), ['id' => 'item_id'])
            ->via('orderItems');
    }
}

L'utilisation de relations déclarées avec une table de jointure est la même que celle de relations normales. Par exemple :

// SELECT * FROM `order` WHERE `id` = 100
$order = Order::findOne(100);

// SELECT * FROM `order_item` WHERE `order_id` = 100
// SELECT * FROM `item` WHERE `item_id` IN (...)
// retourne un tableau d'objets Item 
$items = $order->items;

Chaînage de définitions de relation via de multiples tables

Il est de plus possible de définir des relations via de multiples tables en chaînant les définitions de relation en utilisant via(). En reprenant l'exemple ci-dessus, nous avons les classes Customer, Order et Item. Nous pouvons ajouter une relation à la classe Customer qui liste tous les items de tous les commandes qu'ils ont passées, et la nommer getPurchasedItems(), le chaînage de relations est présenté dans l'exemple de code suivant :

class Customer extends ActiveRecord
{
    // ...

    public function getPurchasedItems()
    {
        // items de clients pour lesquels la colonne 'id' de `Item` correspond à  'item_id' dans OrderItem
        return $this->hasMany(Item::className(), ['id' => 'item_id'])
                    ->via('orderItems');
    }

    public function getOrderItems()
    {
        // items de commandes clients pour lesquels, la colonne 'id' de `Order` correspond à 'order_id' dans OrderItem
        return $this->hasMany(OrderItem::className(), ['order_id' => 'id'])
                    ->via('orders');
    }

    public function getOrders()
    {
        // idem à ci-dessus
        return $this->hasMany(Order::className(), ['customer_id' => 'id']);
    }
}

Chargement paresseux et chargement précoce

Dans la sous-section Accès aux données relationnelles, nous avons expliqué que vous pouvez accéder à une propriété de relation d'une instance d'enregistrement actif comme si vous accédiez à une propriété normale d'objet. Une instruction SQL est exécutée seulement lorsque vous accédez à cette propriété pour la première fois. Nous appelons une telle méthode d'accès à des données relationnelles, chargement paresseux. Par exemple :

// SELECT * FROM `customer` WHERE `id` = 123
$customer = Customer::findOne(123);

// SELECT * FROM `order` WHERE `customer_id` = 123
$orders = $customer->orders;

// pas de SQL exécuté
$orders2 = $customer->orders;

Le chargement paresseux est très pratique à utiliser. Néanmoins, il peut souffrir d'un problème de performance lorsque vous avez besoin d'accéder à la même propriété de relation sur de multiples instances d'enregistrement actif. Examinons l'exemple de code suivant. Combien d'instruction SQL sont-elles exécutées ?

// SELECT * FROM `customer` LIMIT 100
$customers = Customer::find()->limit(100)->all();

foreach ($customers as $customer) {
    // SELECT * FROM `order` WHERE `customer_id` = ...
    $orders = $customer->orders;
}

Comme vous pouvez le constater dans le fragment de code ci-dessus, 101 instruction SQL sont exécutées ! Cela tient au fait que, à chaque fois que vous accédez à la propriété de relation orders d'un objet client différent dans la boucle for, une instruction SQL est exécutée.

Pour résoudre ce problème de performance, vous pouvez utiliser ce qu'on appelle le chargement précoce comme montré ci-dessous :

// SELECT * FROM `customer` LIMIT 100;
// SELECT * FROM `orders` WHERE `customer_id` IN (...)
$customers = Customer::find()
    ->with('orders')
    ->limit(100)
    ->all();

foreach ($customers as $customer) {
    // aucune instruction SQL exécutée
    $orders = $customer->orders;
}

En appelant yii\db\ActiveQuery::with(), vous donner comme instruction à l'enregistrement actif de rapporter les commandes (orders) pour les 100 premiers clients (customers) en une seule instruction SQL. En conséquence, vous réduisez le nombre d'instructions SQL de 101 à 2 !

Vous pouvez charger précocement une ou plusieurs relations. Vous pouvez même charger précocement des relations imbriquées. Une relation imbriquée est une relation qui est déclarée dans une classe d'enregistrement actif. Par exemple, Customer est en relation avec Order via la relation orders, et Order est en relation avec Item via la relation items. Lorsque vous effectuez une requête pour Customer, vous pouvez charger précocement items en utilisant la notation de relation imbriquée orders.items.

Le code suivant montre différentes utilisations de with(). Nous supposons que la classe Customer possède deux relations orders (commandes) et country (pays), tandis que la classe Order possède une relation items.

// chargement précoce à la fois de "orders" et de "country"
$customers = Customer::find()->with('orders', 'country')->all();
// équivalent au tableau de syntaxe ci-dessous
$customers = Customer::find()->with(['orders', 'country'])->all();
// aucune instruction SQL exécutée 
$orders= $customers[0]->orders;
// aucune instruction SQL exécutée
$country = $customers[0]->country;

// chargement précoce de "orders" et de la relation imbriquée "orders.items"
$customers = Customer::find()->with('orders.items')->all();
// accés aux items de la première commande du premier client
// aucune instruction SQL exécutée
$items = $customers[0]->orders[0]->items;

Vous pouvez charger précocement des relations imbriquées en profondeur, telles que a.b.c.d. Toutes les relations parentes sont chargées précocement. C'est à dire, que lorsque vous appelez with() en utilisant a.b.c.d, vous chargez précocement a, a.b, a.b.c et a.b.c.d.

Info : en général, lors du chargement précoce de N relations parmi lesquelles M relations sont définies par une table de jointure, N+M+1 instructions SQL sont exécutées au total. Notez qu'une relation imbriquée a.b.c.d possède 4 relations.

Lorsque vous chargez précocement une relation, vous pouvez personnaliser le requête relationnelle correspondante en utilisant une fonction anonyme. Par exemple :

// trouve les clients et rapporte leur pays et leurs commandes actives 
// SELECT * FROM `customer`
// SELECT * FROM `country` WHERE `id` IN (...)
// SELECT * FROM `order` WHERE `customer_id` IN (...) AND `status` = 1
$customers = Customer::find()->with([
    'country',
    'orders' => function ($query) {
        $query->andWhere(['status' => Order::STATUS_ACTIVE]);
    },
])->all();

Lors de la personnalisation de la requête relationnelle pour une relation, vous devez spécifier le nom de la relation comme une clé de tableau et utiliser une fonction anonyme comme valeur de tableau correspondante. La fonction anonyme accepte une paramètre $query qui représente l'objet yii\db\ActiveQuery utilisé pour effectuer la requête relationnelle pour la relation. Dans le code ci-dessus, nous modifions la requête relationnelle en ajoutant une condition additionnelle à propos de l'état de la commande (order).

Note : si vous appelez select() tout en chargeant précocement les relations, vous devez vous assurer que les colonnes référencées dans la déclaration de la relation sont sélectionnées. Autrement, les modèles en relation peuvent ne pas être chargés correctement. Par exemple :

$orders = Order::find()->select(['id', 'amount'])->with('customer')->all();
// $orders[0]->customer est toujours nul. Pour régler le problème, vous devez faire ce qui suit :
$orders = Order::find()->select(['id', 'amount', 'customer_id'])->with('customer')->all();

Jointure avec des relations

Note : le contenu décrit dans cette sous-section ne s'applique qu'aux bases de données relationnelles, telles que MySQL, PostgreSQL, etc.

Les requêtes relationnelles que nous avons décrites jusqu'à présent ne font référence qu'aux colonnes de table primaires lorsque nous faisons une requête des données primaires. En réalité, nous avons souvent besoin de faire référence à des colonnes dans les tables en relation. Par exemple, vous désirez peut-être rapporter les clients qui ont au moins une commande active. Pour résoudre ce problème, nous pouvons construire une requête avec jointure comme suit :

// SELECT `customer`.* FROM `customer`
// LEFT JOIN `order` ON `order`.`customer_id` = `customer`.`id`
// WHERE `order`.`status` = 1
// 
// SELECT * FROM `order` WHERE `customer_id` IN (...)
$customers = Customer::find()
    ->select('customer.*')
    ->leftJoin('order', '`order`.`customer_id` = `customer`.`id`')
    ->where(['order.status' => Order::STATUS_ACTIVE])
    ->with('orders')
    ->all();

Note : il est important de supprimer les ambiguïtés sur les noms de colonnes lorsque vous construisez les requêtes relationnelles faisant appel à des instructions SQL JOIN. Une pratique courante est de préfixer les noms de colonnes par le nom des tables correspondantes.

Néanmoins, une meilleure approche consiste à exploiter les déclarations de relations existantes en appelant yii\db\ActiveQuery::joinWith() :

$customers = Customer::find()
    ->joinWith('orders')
    ->where(['order.status' => Order::STATUS_ACTIVE])
    ->all();

Les deux approches exécutent le même jeu d'instructions SQL. La deuxième approche est plus propre et plus légère cependant.

Par défaut, joinWith() utilise LEFT JOIN pour joindre la table primaire avec les tables en relation. Vous pouvez spécifier une jointure différente (p .ex. RIGHT JOIN) via son troisième paramètre $joinType. Si le type de jointure que vous désirez est INNER JOIN, vous pouvez simplement appeler innerJoinWith(), à la place.

L'appel de joinWith() charge précocement les données en relation par défaut. Si vous ne voulez pas charger les données en relation, vous pouvez spécifier son deuxième paramètre $eagerLoading comme étant false.

Note : même en utilisant joinWith() ou innerJoinWith() avec le chargement précoce activé les données en relation ne sont pas peuplées à partir du résultat de la requête JOIN. C'est pourquoi il y a toujours une requête supplémetaire pour chacune des relations jointes comme expliqué à la section chargement précoce.

Comme avec with(), vous pouvez joindre une ou plusieurs relations ; vous pouvez personnaliser les requêtes de relation à la volée ; vous pouvez joindre des relations imbriquées ; et vous pouvez mélanger l'utilisation de with() et celle de joinWith(). Par exemple :

$customers = Customer::find()->joinWith([
    'orders' => function ($query) {
        $query->andWhere(['>', 'subtotal', 100]);
    },
])->with('country')
    ->all();

Parfois, en joignant deux tables, vous désirez peut-être spécifier quelques conditions supplémentaires dans la partie ON de la requête JOIN. Cela peut être réalisé en appelant la méthode yii\db\ActiveQuery::onCondition() comme ceci :

// SELECT `customer`.* FROM `customer`
// LEFT JOIN `order` ON `order`.`customer_id` = `customer`.`id` AND `order`.`status` = 1 
// 
// SELECT * FROM `order` WHERE `customer_id` IN (...)
$customers = Customer::find()->joinWith([
    'orders' => function ($query) {
        $query->onCondition(['order.status' => Order::STATUS_ACTIVE]);
    },
])->all();

La requête ci-dessus retourne tous les clients, et pour chacun des clients, toutes les commandes actives. Notez que cela est différent de notre exemple précédent qui ne retourne que les clients qui ont au moins une commande active.

Info : quand yii\db\ActiveQuery est spécifiée avec une condition via une jointure onCondition(), la condition est placée dans la partie ON si la requête fait appel à une requête JOIN. Si la requête ne fait pas appel à JOIN, la on-condition est automatiquement ajoutée à la partie WHERE de la requête. Par conséquent elle peut ne contenir que des conditions incluant des colonnes de la table en relation.

Alias de table de relation

Comme noté auparavant, lorsque vous utilisez une requête JOIN, vous devez lever les ambiguïtés sur le nom des colonnes. Pour cela, un alias est souvent défini pour une table. Définir un alias pour la requête relationnelle serait possible en personnalisant le requête de relation de la manière suivante :

$query->joinWith([
    'orders' => function ($q) {
        $q->from(['o' => Order::tableName()]);
    },
])

Cela paraît cependant très compliqué et implique soit de coder en dur les noms de tables des objets en relation, soit d'appeler Order::tableName(). Depuis la version 2.0.7, Yii fournit un raccourci pour cela. Vous pouvez maintenant définir et utiliser l'alias pour la table de relation comme ceci :

// joint la relation orders et trie les résultats par orders.id
$query->joinWith(['orders o'])->orderBy('o.id');

La syntaxe ci-dessus ne fonctionne que pour des relations simples. Si vous avez besoin d'un alias pour une table intermédiaire lors de la jointure via des relations imbriquées, p. ex. $query->joinWith(['orders.product']), vous devez imbriquer les appels joinWith comme le montre l'exemple suivant :

$query->joinWith(['orders o' => function($q) {
        $q->joinWith('product p');
    }])
    ->where('o.amount > 100');

Relations inverses

Les déclarations de relations sont souvent réciproques entre deux classes d'enregistrement actif. Par exemple, Customer est en relation avec Order via la relation orders, et Order est en relation inverse avec Customer via la relation customer.

class Customer extends ActiveRecord
{
    public function getOrders()
    {
        return $this->hasMany(Order::className(), ['customer_id' => 'id']);
    }
}

class Order extends ActiveRecord
{
    public function getCustomer()
    {
        return $this->hasOne(Customer::className(), ['id' => 'customer_id']);
    }
}

Considérons maintenant ce fragment de code :

// SELECT * FROM `customer` WHERE `id` = 123
$customer = Customer::findOne(123);

// SELECT * FROM `order` WHERE `customer_id` = 123
$order = $customer->orders[0];

// SELECT * FROM `customer` WHERE `id` = 123
$customer2 = $order->customer;

// displays "not the same"
echo $customer2 === $customer ? 'same' : 'not the same';

On aurait tendance à penser que $customer et $customer2 sont identiques, mais ils ne le sont pas ! En réalité, ils contiennent les mêmes données de client, mais ce sont des objets différents. En accédant à $order->customer, une instruction SQL supplémentaire est exécutée pour remplir un nouvel objet $customer2.

Pour éviter l'exécution redondante de la dernière instruction SQL dans l'exemple ci-dessus, nous devons dire à Yii que customer est une relation inverse de orders en appelant la méthode inverseOf() comme ci-après :

class Customer extends ActiveRecord
{
    public function getOrders()
    {
        return $this->hasMany(Order::className(), ['customer_id' => 'id'])->inverseOf('customer');
    }
}

Avec cette déclaration de relation modifiée, nous avons :

// SELECT * FROM `customer` WHERE `id` = 123
$customer = Customer::findOne(123);

// SELECT * FROM `order` WHERE `customer_id` = 123
$order = $customer->orders[0];

// aucune instruction SQL n'est exécutée
$customer2 = $order->customer;

// affiche "same"
echo $customer2 === $customer ? 'same' : 'not the same';

Note : les relations inverses ne peuvent être définies pour des relations faisant appel à une table de jointure. C'est à dire que, si une relation est définie avec via() ou avec viaTable(), vous ne devez pas appeler inverseOf() ensuite.

Sauvegarde des relations

En travaillant avec des données relationnelles, vous avez souvent besoin d'établir de créer des relations entre différentes données ou de supprimer des relations existantes. Cela requiert de définir les valeurs appropriées pour les colonnes qui définissent ces relations. En utilisant l'enregistrement actif, vous pouvez vous retrouver en train d'écrire le code de la façon suivante :

$customer = Customer::findOne(123);
$order = new Order();
$order->subtotal = 100;
// ...

// défninition de l'attribut qui définit la relation "customer" dans Order
$order->customer_id = $customer->id;
$order->save();

L'enregistrement actif fournit la méthode link() qui vous permet d'accomplir cette tâche plus élégamment :

$customer = Customer::findOne(123);
$order = new Order();
$order->subtotal = 100;
// ...

$order->link('customer', $customer);

La méthode link() requiert que vous spécifiiez le nom de la relation et l'instance d'enregistrement actif cible avec laquelle le relation doit être établie. La méthode modifie les valeurs des attributs qui lient deux instances d'enregistrement actif et les sauvegardes dans la base de données. Dans l'exemple ci-dessus, elle définit l'attribut customer_id de l'instance Order comme étant la valeur de l'attribut id de l'instance Customer et le sauvegarde ensuite dans la base de données.

Note : vous ne pouvez pas lier deux instances d'enregistrement actif nouvellement créées.

L'avantage d'utiliser link() est même plus évident lorsqu'une relation est définie via une table de jointure. Par exemple, vous pouvez utiliser le code suivant pour lier une instance de Order à une instance de Item :

$order->link('items', $item);

Le code ci-dessus insère automatiquement une ligne dans la table de jointure order_item pour mettre la commande en relation avec l'item.

Info : la méthode link() n'effectue AUCUNE validation de données lors de la sauvegarde de l'instance d'enregistrement actif affectée. Il est de votre responsabilité de valider toutes les données entrées avant d'appeler cette méthode.

L'opération opposée à link() est unlink() qui casse une relation existante entre deux instances d'enregistrement actif. Par exemple :

$customer = Customer::find()->with('orders')->where(['id' => 123])->one();
$customer->unlink('orders', $customer->orders[0]);

Par défaut, la méthode unlink() définit la valeur de la (des) clé(s) étrangères qui spécifie(nt) la relation existante à null. Vous pouvez cependant, choisir de supprimer la ligne de la table qui contient la valeur de clé étrangère en passant à la méthode la valeur true pour le paramètre $delete.

Lorsqu'une table de jointure est impliquée dans une relation, appeler unlink() provoque l'effacement des clés étrangères dans la table de jointure, ou l'effacement de la ligne correspondante dans la table de jointure si #delete vaut true.

Relations inter bases de données

L'enregistrement actif vous permet de déclarer des relations entre les classes d'enregistrement actif qui sont mise en œuvre par différentes bases de données. Les bases de données peuvent être de types différents (p. ex. MySQL and PostgreSQL, ou MS SQL et MongoDB), et elles peuvent s'exécuter sur des serveurs différents. Vous pouvez utiliser la même syntaxe pour effectuer des requêtes relationnelles. Par exemple :

// Customer est associé à la table "customer" dans la base de données relationnelle (e.g. MySQL)
class Customer extends \yii\db\ActiveRecord
{
    public static function tableName()
    {
        return 'customer';
    }

    public function getComments()
    {
        // a customer has many comments
        return $this->hasMany(Comment::className(), ['customer_id' => 'id']);
    }
}

// Comment est associé à la collection "comment" dans une base de données MongoDB
class Comment extends \yii\mongodb\ActiveRecord
{
    public static function collectionName()
    {
        return 'comment';
    }

    public function getCustomer()
    {
        // un commentaire (comment) a un client (customer)
        return $this->hasOne(Customer::className(), ['id' => 'customer_id']);
    }
}

$customers = Customer::find()->with('comments')->all();

Vous pouvez utiliser la plupart des fonctionnalités de requêtes relationnelles qui ont été décrites dans cette section.

Note : l'utilisation de joinWith() est limitée aux bases de données qui permettent les requête JOIN inter bases. Pour cette raison, vous ne pouvez pas utiliser cette méthode dans l'exemple ci-dessus car MongoDB ne prend pas JOIN en charge.

Personnalisation des classes de requête

Par défaut, toutes les requêtes d'enregistrement actif sont prises en charge par yii\db\ActiveQuery. Pour utiliser une classe de requête personnalisée dans une classe d'enregistrement actif, vous devez redéfinir la méthode yii\db\ActiveRecord::find() et retourner une instance de votre classe de requête personnalisée . Par exemple :

namespace app\models;

use yii\db\ActiveRecord;
use yii\db\ActiveQuery;

class Comment extends ActiveRecord
{
    public static function find()
    {
        return new CommentQuery(get_called_class());
    }
}

Désormais, à chaque fois que vous effectuez une requête (p. ex. find(), findOne()) ou définissez une relation (p. ex. hasOne()) avec Comment, vous travaillez avec une instance de CommentQuery au lieu d'une instance d'ActiveQuery.

Vous devez maintenant définir la classe CommentQuery, qui peut être personnalisée de maintes manières créatives pour améliorer votre expérience de la construction de requête. Par exemple :

// fichier CommentQuery.php
namespace app\models;

use yii\db\ActiveQuery;

class CommentQuery extends ActiveQuery
{
    // conditions ajoutées par défaut (peut être sauté)
    public function init()
    {
        $this->andOnCondition(['deleted' => false]);
        parent::init();
    }

    // ... ajoutez vos méthodes de requêtes personnalisées ici ...

    public function active($state = true)
    {
        return $this->andOnCondition(['active' => $state]);
    }
}

Note : au lieu d'appeler onCondition(), vous devez généralement appeler andOnCondition() ou orOnCondition() pour ajouter des conditions supplémentaires lors de la définition de nouvelles méthodes de requête de façon à ce que aucune condition existante en soit redéfinie.

Cela vous permet d'écrire le code de construction de requête comme suit :

$comments = Comment::find()->active()->all();
$inactiveComments = Comment::find()->active(false)->all();

Astuce : dans les gros projets, il est recommandé que vous utilisiez des classes de requête personnalisées pour contenir la majeure partie de code relatif aux requêtes de manière à ce que les classe d'enregistrement actif puissent être maintenues propres.

Vous pouvez aussi utiliser les méthodes de construction de requêtes en définissant des relations avec Comment ou en effectuant une requête relationnelle :

class Customer extends \yii\db\ActiveRecord
{
    public function getActiveComments()
    {
        return $this->hasMany(Comment::className(), ['customer_id' => 'id'])->active();
    }
}

$customers = Customer::find()->joinWith('activeComments')->all();

// ou en alternative
class Customer extends \yii\db\ActiveRecord
{
    public function getComments()
    {
        return $this->hasMany(Comment::className(), ['customer_id' => 'id']);
    }
}

$customers = Customer::find()->joinWith([
    'comments' => function($q) {
        $q->active();
    }
])->all();

Info : dans Yii 1.1, il existe un concept appelé scope. Scope n'est plus pris en charge directement par Yii 2.0, et vous devez utiliser des classes de requête personnalisées et des méthodes de requêtes pour remplir le même objectif.

Sélection de champs supplémentaires

Quand un enregistrement actif est rempli avec les résultats d'une requête, ses attributs sont remplis par les valeurs des colonnes correspondantes du jeu de données reçu.

Il vous est possible d'aller chercher des colonnes ou des valeurs additionnelles à partir d'une requête et des les stocker dans l'enregistrement actif. Par exemple, supposons que nous ayons une table nommée room, qui contient des informations sur les chambres (rooms) disponibles dans l'hôtel. Chacune des chambres stocke des informations sur ses dimensions géométriques en utilisant des champs length (longueur), width (largeur) , height (hauteur). Imaginons que vous ayez besoin de retrouver une liste des chambres disponibles en les classant par volume décroissant. Vous ne pouvez pas calculer le volume en PHP parce que vous avez besoin de trier les enregistrements par cette valeur, mais vous voulez peut-être aussi que volume soit affiché dans la liste. Pour atteindre ce but, vous devez déclarer un champ supplémentaire dans la classe d'enregistrement actif Room qui contiendra la valeur de volume :

class Room extends \yii\db\ActiveRecord
{
    public $volume;

    // ...
}

Ensuite, vous devez composer une requête qui calcule le volume de la chambre et effectue le tri :

$rooms = Room::find()
    ->select([
        '{{room}}.*', // selectionne toutes les colonnes 
        '([[length]] * [[width]] * [[height]]) AS volume', // calcule un volume
    ])
    ->orderBy('volume DESC') // applique le tri
    ->all();

foreach ($rooms as $room) {
    echo $room->volume; // contient la valeur calculée par SQL
}

La possibilité de sélectionner des champs supplémentaires peut être exceptionnellement utile pour l'agrégation de requêtes. Supposons que vous ayez besoin d'afficher une liste des clients avec le nombre total de commandes qu'ils ont passées. Tout d'abord, vous devez déclarer une classe Customer avec une relation orders et un champ supplémentaire pour le stockage du nombre de commandes :

class Customer extends \yii\db\ActiveRecord
{
    public $ordersCount;

    // ...

    public function getOrders()
    {
        return $this->hasMany(Order::className(), ['customer_id' => 'id']);
    }
}

Ensuite vous pouvez composer une requête qui joint les commandes et calcule leur nombre :

$customers = Customer::find()
    ->select([
        '{{customer}}.*', // selectionne tous les champs de customer 
        'COUNT({{order}}.id) AS ordersCount' // calcule le nombre de commandes (orders)
    ])
    ->joinWith('orders') // garantit la jointure de la table
    ->groupBy('{{customer}}.id') // groupe les résultats pour garantir que la fonction d'agrégation fonctionne 
    ->all();

Un inconvénient de l'utilisation de cette méthode est que si l'information n'est pas chargée dans la requête SQL, elle doit être calculée séparément. Par conséquent, si vous avez trouvé un enregistrement particulier via une requête régulière sans instruction de sélection supplémentaire, il ne pourra retourner la valeur réelle du champ supplémentaire. La même chose se produit pour l'enregistrement nouvellement sauvegardé.

$room = new Room();
$room->length = 100;
$room->width = 50;
$room->height = 2;

$room->volume; // cette valeur est `null` puisqu'elle n'a pas encore été déclarée

En utilisant les méthodes magiques __get() et __set() nous pouvons émuler le comportement d'une propriété :

class Room extends \yii\db\ActiveRecord
{
    private $_volume;
    
    public function setVolume($volume)
    {
        $this->_volume = (float) $volume;
    }
    
    public function getVolume()
    {
        if (empty($this->length) || empty($this->width) || empty($this->height)) {
            return null;
        }
        
        if ($this->_volume === null) {
            $this->setVolume(
                $this->length * $this->width * $this->height
            );
        }
        
        return $this->_volume;
    }

    // ...
}

Lorsque la requête select ne fournit pas le volume, le modèle est capable de le calculer automatiquement en utilisant les attributs du modèle.

Vous pouvez aussi bien calculer les champs agrégés en utilisant les relations définies :

class Customer extends \yii\db\ActiveRecord
{
    private $_ordersCount;
    
    public function setOrdersCount($count)
    {
        $this->_ordersCount = (int) $count;
    }
    
    public function getOrdersCount()
    {
        if ($this->isNewRecord) {
            return null; // cela évite d'appeler une requête pour chercher une clé primaire nulle 
        }
        
        if ($this->_ordersCount === null) {
            $this->setOrdersCount($this->getOrders()->count()); // calcule l'aggrégation à la demande à partir de la relation
        }

        return $this->_ordersCount;
    }

    // ...

    public function getOrders()
    {
        return $this->hasMany(Order::className(), ['customer_id' => 'id']);
    }
}

Avec ce code, dans le cas où 'ordersCount' est présent dans l'instruction 'select' - Customer::ordersCount est peuplé par les résultats de la requête, autrement il est calculé à la demande en utilisant la relation Customer::orders.

Cette approche peut aussi bien être utilisée pour la création de raccourcis pour certaines données relationnelles, en particulier pour l'aggrégation. Par exemple :

class Customer extends \yii\db\ActiveRecord
{
    /**
     * Definit une propriété en lecture seule pour les données agrégées.
     */
    public function getOrdersCount()
    {
        if ($this->isNewRecord) {
            return null; // ceci évite l'appel d'une requête pour cherche une clé primaire nulle
        }
        
        return empty($this->ordersAggregation) ? 0 : $this->ordersAggregation[0]['counted'];
    }

    /**
     * Déclere une relation 'orders' normale.
     */
    public function getOrders()
    {
        return $this->hasMany(Order::className(), ['customer_id' => 'id']);
    }

    /**
     * Déclare une nouvelle relation basée sur 'orders', qui fournit l'agrégation.
     */
    public function getOrdersAggregation()
    {
        return $this->getOrders()
            ->select(['customer_id', 'counted' => 'count(*)'])
            ->groupBy('customer_id')
            ->asArray(true);
    }

    // ...
}

foreach (Customer::find()->with('ordersAggregation')->all() as $customer) {
    echo $customer->ordersCount; // fournit les données agrégées à partir de la relation sans requête supplémentaire grâce au chargement précoce. 
}

$customer = Customer::findOne($pk);
$customer->ordersCount; // fournit les données agrégées à partir de la relation paresseuse chargée