Virtual attribute of the ActiveRecord is not handled as databse attribute

I have read about virtual attributes https://www.yiiframework.com/wiki/167/understanding-virtual-attributes-and-getset-methods and I had this great idea to add such attribute to my active record class to do conversion between the UTF16 database field and between UTF8 GUI field. At the result I was stuck with the problem https://stackoverflow.com/questions/53117748/yii2-virtual-attribute-naming-very-strange-uppercase-lowercase-problem
So, essentially this means that I can define virtual attribute, but this attribute is not handled along other attributes that are coming from database. I.e. virtual attributes do not participate getAttributes, attributes function, they do not participate in json encoding and decoding. I.e. those fields can be accessed programmatically but they are not accesse automatically by the whole infrastructure of Yii.

So, I am starting to think that such low profile of virtual attributes is by design and actually I must not expected anything more from them.

Just wanted check whether my sad conclusion is true and whether I am required to hand-code all the infrastructure activities (getAttributes, json encoding) by hand?

You are correct.

Very old topic but still very actual nowadays and after years, I wish to have this topic clarified as much as possible and, of course, if it is possible.

So, say you have an ActiveRecord class as follows:

/**
* @property string $first_name - exists in DB as a field
* @property string $last_name - exists in DB as a field
* @property string $fullName - does not exist in DB as a field
*/
class Customer extends \yii\db\ActiveRecord
{
    /**
    * {@inheritdoc}
    */
    public function attributes() {
        return \yii\helpers\ArrayHelper::merge(parent::attributes(),
            [
                'fullName',
            ]
        );
    }

    /**
    * {@inheritdoc}
    */
    public function attributeLabels() {
        return \yii\helpers\ArrayHelper::merge(parent::attributeLabels(), [
			'first_name' => 'First Name',
			'last_name'  => 'Last Name',
			'fullName'   => 'Fullname Name',
       ]);
    }

    /**
    * {@inheritdoc}
    */
    public function getFullName() {
    	return "{$this->first_name} {$this->last_name}";
    }

    /**
    * {@inheritdoc}
    */
    public function rules() {
        return \yii\helpers\ArrayHelper::merge(parent::rules(), [
            [['first_name', 'last_name', 'fullName'], 'string'],
        ]);
    }

}

Now, what is the best implementation to make it follow the whole process as if ->fullName were a real attribute? Say I want to override the afterSave($insert, $changedAttributes), what would it be the best implementation to have that ->fullName in the $changedAttributes argument?

In fair honesty, over the years I’ve already managed to achieve this in a few ways, some more and some less elegant; but I wish to know if, in 2024, there is an official way or a suggested best practice that I might have missed in the docs, in the forums or somewhere

1 Like

The implementation is wholly dependent on your specific needs. Most frameworks that I have used have some type of work around.

In Yii2, I typically just override the getter and setter and keep programming. ->fullName() could be made into a trait since it is common across projects :man_shrugging: - but that depends…

If you need more functionality you could always use a behavior but this would add some technical cost as well.

I think more importantly is the overall architecture. For your example of full name I would just do getter/setter and move on. In OP’s example, I would design the database differently and not mutate the data without first persisting it to a column in the database.

Maybe that is the point… store the data in the database to fully leverage the active record architectural pattern.

2 Likes

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!

3 Likes