Working With Multiple Records
Sometimes one request can create or affect several records in one or more tables in such cases its advisable to follow ACID properties. Yii2 supports transactions, isolation levels, and allow you to validate data independently.
For example when creating a credit, you also need to store the credit references and files associated to the credit.
public function actionCreate()
{
$model = new Credit();
$modelReferences = [new CreditRefence()];
$modelFiles = [new CreditFile()];
if (Yii::$app->request->isPost) {
$model->load(Yii::$app->request->post());
foreach (Yii::$app->requestPost($modelReferences[0]->formName())
as $i => $data
) {
$newModel = new CreditReference();
$newModel->load($data, '');
$modelReferences[$i] = $newModel;
}
foreach (Yii::$app->requestPost($modelFiles[0]->formName())
as $i => $data
) {
$newModel = new CreditFile();
$newModel->load($data, '');
$newModel->file = UploadedFile::getInstance($newModel, "[$i]file");
$modelFiles[$i] = $newModel;
}
$transaction = Yii::$app->db->beginTransaction(
Transaction::SERIALIZABLE
);
try {
$valid = $model->validate();
$valid = Model::validateMultiple($modelReferences, [
'name',
'last_name',
// other fields, make sure to NOT include credit_id since
// the record has not been created yet.
]) && $valid;
$valid = Model::::validateMultiple($modelFiles, [
'file',
// other fields, make sure to NOT include credit_id since
// the record has not been created yet.
]) && $valid;
if ($valid) {
// the model was validated, no need to validate it once more
$model->save(false);
foreach ($modelReferences as $newModel) {
$newModel->credit_id = $model->id;
$newModel->save(false);
}
foreach ($modelFiles as $newModel) {
$newModel->credit_id = $model->id;
$newModel->file->saveAs(Yii::getAlias(
'@webroot/uploads/' . $model->file->name
));
$newModel->save(false);
}
$transaction->commit();
return $this->redirect(['credit/view', ['id' => $model->id]]);
} else {
$transaction->rollBack();
}
} catch (Exception $e) {
$transaction->rollBack();
throw new BadRequestHttpException($e->getMessage(), 0, $e);
}
}
return $this->render('create', [
'model' => $model,
'modelReferences' => $modelReferences,
'modelFiles' => $modelFiles,
]);
}
The steps followed in the example.
1 Check if the request can process the petition.
In this case we are assuming the controller already checked the user credentials
using filter and at the action its enough to check if the petition is using the
post
method.
2 Create the models and load the user data to them
While there is only one credit, there might be many files and references
3 Start the transaction
Its important to start the transaction at this point since some validations like
unique
and exist
might be necessary so we start the transaction here to
avoid [Reading Phenomena]
(https://en.wikipedia.org/wiki/Isolation_(database_systems)#Read_phenomena).
You should also notice that we created the transaction using
yii\db\Transaction::SERIALIZABLE
which is the highest [isolation level]
(https://en.wikipedia.org/wiki/Isolation_(database_systems)#Isolation_levels).
4 Validate all the models
Its important to validate them all to show the user all the validation errors if
necessary. Using an if
statement like this
if ($model->validate() && Model::validateMultiple(...))
Is simpler to understand but if the first validation fails the second one won't be executed.
Also notice that we are not going to validate credit_id
on the files and
references since the credit has not been created yet.
4.1 if the validations fail, end the transaction with a rollBack()
just in
case any validation had updated anything
The action will then render the view and if you are using something like
ActiveForm
the user will see all the validation errors.
5 if the all the validations are successful we proceed to save all the models.
We will save them without validation since they were already validated and
assign the credit_id
to the files and references after the credit has been
saved.
5.1 Catch any exception from the validation or saving and execute rollBack()
For debugging purposes we throw a new exception with the previous one so it can get caught by the Yii2 exception manager.
6 If no exception is thrown then commit()
the changes.
After this you can include any success logic like redirects or new renders.
Operations Triggered by Events
Lets say you have two models Credit
with attributes id
and amount
, and a
CreditPayment
model with attributes credit_id
and amount
. You want to
update the Credit
amount when a related CreditPayment
is created.
class CreditPayment extends ActiveRecord
{
public function afterSave($insert, $changedAttributes)
{
parent::afterSave($insert, $changedAttributes);
if ($insert === false) {
return; // only work with newly created payments
}
$this->credit->amount = $this->credit->amount - $this->ammount;
if ($this->credit->amount <= 0) {
$this->credit->status = Credit::STATUS_PAYMENT_FINISHED;
}
if ($this->credit->save(false) === false) {
throw new Exception("credit couldn't be update");
}
}
public function getCredit()
{
return $this->hasOne(Credit::className(), ['id' => 'credit_id']);
}
}
If any exception is thrown in this method, then the payment will be saved but it won't affect the amount on the credit.
We can encapsulate all the operation on a transaction very simply using the
method yii\db\ActiveRecord::transactions()
public function transactions()
{
return [
self::SCENARIO_DEFAULT => self::OP_INSERT,
];
}