Virtual attributes break save() after refresh()
Follow-up to #116 (virtual attribute support, x-db-type: false).
Problem
A property declared with x-db-type: false is generated as a virtual attribute:
protected $virtualAttributes = ['canBeDeleted'];
public $canBeDeleted;
public function attributes()
{
return array_merge(parent::attributes(), $this->virtualAttributes);
}
Because the virtual attribute is added to attributes(), Yii treats it as a column-backed attribute. After refresh() it ends up in the dirty set, and the next save() writes it to a column that does not exist:
SQLSTATE[42703]: Undefined column: column "canBeDeleted" of relation "documents" does not exist
Why (root cause)
yii\db\BaseActiveRecord::refreshInternal() iterates attributes() and forces every name (incl. virtual ones) into _attributes, while _oldAttributes comes from populateRecord() (real DB columns only) and therefore lacks them:
foreach ($this->attributes() as $name) {
$this->_attributes[$name] = $record->_attributes[$name] ?? null; // virtual attr now set
}
$this->_oldAttributes = $record->_oldAttributes; // virtual attr NOT here
→ virtual attribute is in _attributes but not in _oldAttributes ⇒ counted as dirty.
insertInternal() / updateInternal() build the SQL from getDirtyAttributes(), so the virtual attribute gets written.
Note: a plain find() does not trigger this (populateRecord only sets real columns), so it stays latent until a refresh()-then-save() path is hit (e.g. updating a document during invoice billing). Every generated model with virtual attributes is affected.
What to do
Generated base models with virtual attributes must exclude them from DB writes. Add to the generated base:
public function getDirtyAttributes($names = null)
{
return array_diff_key(parent::getDirtyAttributes($names), array_flip($this->virtualAttributes));
}
This keeps virtual attributes readable/serializable (they stay in attributes()) but guarantees they are never part of INSERT/UPDATE — independent of how they became dirty (find, refresh, setAttributes).
Repro
- Generate a model with an
x-db-type: false property.
$m = Model::findOne($id); $m->refresh(); $m->someRealColumn = 'x'; $m->save();
- →
Undefined column "<virtualAttribute>".
Virtual attributes break
save()afterrefresh()Follow-up to #116 (virtual attribute support,
x-db-type: false).Problem
A property declared with
x-db-type: falseis generated as a virtual attribute:Because the virtual attribute is added to
attributes(), Yii treats it as a column-backed attribute. Afterrefresh()it ends up in the dirty set, and the nextsave()writes it to a column that does not exist:Why (root cause)
yii\db\BaseActiveRecord::refreshInternal()iteratesattributes()and forces every name (incl. virtual ones) into_attributes, while_oldAttributescomes frompopulateRecord()(real DB columns only) and therefore lacks them:→ virtual attribute is in
_attributesbut not in_oldAttributes⇒ counted as dirty.insertInternal()/updateInternal()build the SQL fromgetDirtyAttributes(), so the virtual attribute gets written.Note: a plain
find()does not trigger this (populateRecordonly sets real columns), so it stays latent until arefresh()-then-save()path is hit (e.g. updating a document during invoice billing). Every generated model with virtual attributes is affected.What to do
Generated base models with virtual attributes must exclude them from DB writes. Add to the generated base:
This keeps virtual attributes readable/serializable (they stay in
attributes()) but guarantees they are never part of INSERT/UPDATE — independent of how they became dirty (find,refresh,setAttributes).Repro
x-db-type: falseproperty.$m = Model::findOne($id); $m->refresh(); $m->someRealColumn = 'x'; $m->save();Undefined column "<virtualAttribute>".