Thank you for your articulated answer, covering a wider overall spectrum of frameworks. Unfortunately, it does not answer the specific topic/question answered here: what would it be the best implementation to have that ->fullName
in the $changedAttributes
argument?.
Issue
However, I guess the mentioned virtual attribute ->fullName
is just to bring the issue live; it is a very commonly mentioned use-case when talking about virtual attributes. But also, your answer (and all the other posts!) have been very important to start bringing to light a lack of official guidelines regarding this topic.
To mention a bit of leg-work, unfortunately I had no luck with getters and setters and/or even overriding the attributes()
method, being these the first attempts I made. Problem here is still the same: while in theory they should work, in practice there are a bunch of caveats and implementation choices in yii\db\ActiveRecord
and parents that do not allow Virtual Attributes natively, or at least, I have still not found an official way.
Env preparation
For my future reference - and maybe helping someone else! - let me bring another example AND the closest-to-native way I’ve managed to discover:
say you have a Customer base class, being its attributes (DB fields) type
, username
and authentication_blob
where the latter is a string and and encrypt()
method to encrypt this string. Say also that string is arbitrary, varying the content depending on the child class to populate their Virtual Attributes. I will try here to represent it, try to make an effort of fantasy and assume the DB cannot change:
TYPE USERNAME AUTH_BLOB
facebook samdark {"access_token": "EAACEdE..."}
office365 en666 {"ac": "1f4g6a...", "audience":"multi"}
google alex153410 {"googleAC": "P1IW1D/...", "scopes":"read write delete"}
password cgsmith {"value":"03c7c0ace...", "digest":"md5"}
Auth Blob here is not encrypted for sake of readability!
Child Classes will extend the base Customer class, say:
class FacebookCustomer extends Customer
{
return ArrayHelper::merge(parent::attributes(),
[
'facebookAccessToken',
]
)
...
class O365Customer extends Customer
{
return ArrayHelper::merge(parent::attributes(),
[
'oauthAT',
'oauthAudience',
]
)
...
class GoogleCustomer extends Customer
{
return ArrayHelper::merge(parent::attributes(),
[
'gapiAT',
'gapiScopes',
]
)
...
class NativeCustomer extends Customer
{
return ArrayHelper::merge(parent::attributes(),
[
'password',
'password_alg',
]
)
...
Now, every child class needs to call the encrypt()
to store the arbitrary string inside authentication_blob
, and then it needs decrypt()
to read from it, populating the per-type class attributes with those values.
Ok, if you’ve reached this far, you’d probably know there are hundreds of ways you can implement this: traits, inheritance, every design patter you may think of. Anyway, so far none of them is really capable to show one of those VirtualAttributes as Dirty or to make it into yii\db\ActiveRecord::afterSave($insert, $changedAttributes)
passed as an array element of $changedAttributes
. This means you cannot do a number of things, among them saying:
do <some extra> when <VirtualAttribute Name> has changed.
Because there won’t be any ‘official’ attribute to change, even if you explicitly set it, even if you have getters and setters, even if you literally call markAttributeDirty()
on it! If you read at the Yii2 code, the Virtual Attribute won’t make it until here because it’s actually not a DB field and it’ll be removed way before reaching the afterSave()
call.
One proposed solution
So, how to achieve this? As a matter of facts, the closest way I’ve found is to override two ActiveRecord methods; this will allow methods like find(), save(), update(), etc. to work as if the Virtual Attributes were actually DB fields!
The 2 methods are:
public static function populateRecord($record, $row)
public static function updateAll($attributes, $condition = '', $params = [])
in particular, the first is responsible to fill the content of the class attributes from DB and the second to store that content inside the DB.
Ignoring the following code correctness, for sake of simplicity the GoogleCustomer class can conceptually look anything like:
class GoogleCustomer extends Customer
{
return ArrayHelper::merge(parent::attributes(),
[
'gapiAT',
'gapiScopes',
]
)
public function beforeSave($insert) {
if (!parent::beforeSave($insert)) {
return false;
}
$this->authentication_blob = self::encrypt([
'googleAC' => $this->gapiAT,
'scopes' => $this->gapiScopes
]);
return true;
}
public static function populateRecord($record, $row)
{
$arrDecryptedAuthBlob = self::decrypt($row['authentication_blob']);
$record->setAttributes([
'gapiAT' => $arrDecryptedAuthBlob['googleAC'] ?? null,
'gapiScopes' => $arrDecryptedAuthBlob['scopes'] ?? null,
], /*$safeOnly = */false);
parent::populateRecord($record, $row);
}
public static function updateAll($attributes, $condition = '', $params = [])
{
// Remove virtual attributes from the process.
// Virtual attributes cannot be directly written to DB: they do not exist as fields
$attributes = array_intersect_key($attributes, array_flip(static::getTableSchema()->getColumnNames()));
return ($attributes === []) ? 0 : parent::updateAll($attributes, $condition, $params);
}
...
Again, what the beforeSave()
does here can be done in numerous other ways; what I wanted to highlight is more to deal with the role populateRecord($record, $row)
and updateAll($attributes, $condition = '', $params = [])
play: they are the real responsible to build the model in a way that the Virtual Attributes go to $changedAttributes
, getDirtyAttributes()
and so on.
Hope to have been useful to anyone else other than myself!