I’m just posting this to see if other user have a better solution to what I’m trying to achieve. I’m copying the implementation of the database from Pro Zend Framework Build a Full CMS project and trying to make it work with Yii2 since Yii2 comes more intuitive to me then Yii did. I thought I would give it a try. So the database structure is you have Page table that has just basic attributes every page would need and you have a content_node table that will have rows that make the appearance of extending the page with out having to update the page table schema. I change the name of Page and content_node to be Node, and NodeField.
example of the data schema
Node
ID INT pk
parent_id INT
type varchar(50)
name varchar(255)
created_datetime INT
modified_datetime INT
NodeField
ID INT pk
node_id INT
name varchar(64)
content TEXT
So the first approach was to extend a Yii\Base\Model and basically copied most functions from ActiveRecordInterface to make a Abstract Active Record and so if I wanted to a Node that had a Headline, and Author property just extend the Abstact Active Record and add
public $headline;
public $author;
this would extend the getAttributes to pick up to more propeties for the model which I could validate against and save. This approach worked but I would have to worry about certain names since the model has predefined properties like attributes, itterator, scenario, etc.
So the second approach was I made a file that extends the ActiveRecord and just modified some of the functions example code below
<?php
namespace backend\models;
use Yii;
use yii\behaviors\TimestampBehavior;
use yii\helpers\VarDumper;
abstract class NodeAbstract extends \yii\db\ActiveRecord
{
protected static $_modelClass = 'backend\models\Node';
protected static $_attributeClass = 'backend\models\NodeField';
protected static $_attributeRelation = 'nodeFields';
protected static $_attributeColumn = 'name';
protected static $_attributeValue = 'content';
protected $_type = 'abstract';
public function rules()
{
return [
[['parent_id', 'created_datetime', 'modified_datetime'], 'integer'],
[['type'], 'string', 'max' => 64],
[['name'], 'string', 'max' => 255]
];
}
public function attributeLabels()
{
return [
'id' => 'ID',
'parent_id' => 'Parent ID',
'type' => 'Type',
'name' => 'Name',
'created_datetime' => 'Date Created',
'modified_datetime' => 'Date Modified',
];
}
public function behaviors()
{
return [
'timestamp' => [
'class' => TimestampBehavior::className(),
'createdAtAttribute' => 'created_datetime',
'updatedAtAttribute' => 'modified_datetime',
'value' => function() { return date('U'); },
],
];
}
public function beforeSave($insert)
{
if (parent::beforeSave($insert)) {
if($insert){
$this->setAttribute('type', $this->_type);
}
return true;
} else {
return false;
}
}
public static function getDb()
{
$class = self::$_modelClass;
return $class::getDb();
}
public static function tableName()
{
$class = self::$_modelClass;
return $class::tableName();
}
public static function primaryKey()
{
$class = self::$_modelClass;
return $class::getTableSchema()->primaryKey;
}
public function attributes()
{
$class = self::$_modelClass;
$attributes = array_keys($class::getTableSchema()->columns);
$properties = array_keys($this->attributeLabels());
return array_unique(array_merge($attributes, $properties), SORT_REGULAR);
}
public function save($runValidation = true, $attributeNames = null)
{
if ($this->getIsNewRecord()) {
return $this->insert($runValidation, $attributeNames);
} else {
return $this->update($runValidation, $attributeNames) !== false;
}
}
public function insert($runValidation = true, $attributes = null)
{
if ($runValidation && !$this->validate($attributes)) {
Yii::info('Model not inserted due to validation error.', __METHOD__);
return false;
}
$transaction = static::getDb()->beginTransaction();
try {
$result = $this->insertInternal($attributes);
if ($result === false) {
$transaction->rollBack();
} else {
$transaction->commit();
}
return $result;
} catch (\Exception $e) {
$transaction->rollBack();
throw $e;
}
}
protected function insertInternal($attributeNames = null)
{
$class = self::$_modelClass;
$attrClass = self::$_attributeClass;
if (!$this->beforeSave(true)) {
return false;
}
$values = $this->getDirtyAttributes($attributeNames);
if (empty($values)) {
foreach ($this->getPrimaryKey(true) as $key => $value) {
$values[$key] = $value;
}
}
$model = new $class();
$attributes = array_flip($model->attributes());
$properties = array_flip($this->attributes());
$attributeColumns = array_reverse(array_keys(array_diff_key($properties, $attributes)), true);
$modelValues = array_intersect_key($values, $attributes);
$attributeValues = array_diff_key($values, $attributes);
$model->setAttributes($modelValues);
$result = $model->save(false, $attributeNames);
if ($result !== false) {
$modelId = $model->getPrimaryKey();
$getter = 'get'.ucfirst(self::$_attributeRelation);
$attrQuery = $model->$getter();
$attrModels = $attrQuery->all();
$attrIds = array_keys($attrQuery->link);
$attrId = $attrIds[0];
$attrColumn = self::$_attributeColumn;
$attrValue = self::$_attributeValue;
foreach($attributeColumns as $idx=>$name){
$attrModel = null;
foreach ($attrModels as $key=>$attrModelLookup) {
if($attrModelLookup->$attrColumn == $name){
$attrModel = $attrModelLookup;
unset($attrModels[$key]);
break;
}
}
if(isset($attributeValues[$name])){
$value = $attributeValues[$name];
if(isset($attrModel)){
$attrModel->id = $idx+1;
$attrModel->$attrId = $modelId;
$attrModel->$attrColumn = $name;
$attrModel->$attrValue = $value;
} else {
$attrModel = new $attrClass();
$attrModel->id = $idx+1;
$attrModel->$attrId = $modelId;
$attrModel->$attrColumn = $name;
$attrModel->$attrValue = $value;
}
}
$result = $attrModel->save(false) && $result;
}
if(count($attrModels) > 0) {
foreach ($attrModels as $attrModel) {
$attrModel->delete();
}
}
}
if ($result === false) {
return false;
} else {
$table = $class::getTableSchema();
if ($table->sequenceName !== null) {
foreach ($model->getPrimaryKey(true) as $name=>$id) {
if ($this->getAttribute($name) === null) {
$this->setAttribute($name, $id);
$values[$name] = $id;
break;
}
}
}
$changedAttributes = array_fill_keys(array_keys($values), null);
$this->setOldAttributes($values);
$this->afterSave(true, $changedAttributes);
return true;
}
}
public function update($runValidation = true, $attributeNames = null)
{
if ($runValidation && !$this->validate($attributeNames)) {
Yii::info('Model not updated due to validation error.', __METHOD__);
return false;
}
$transaction = static::getDb()->beginTransaction();
try {
$result = $this->updateInternal($attributeNames);
if ($result === false) {
$transaction->rollBack();
} else {
$transaction->commit();
}
return $result;
} catch (\Exception $e) {
$transaction->rollBack();
throw $e;
}
}
protected function updateInternal($attributeNames = null)
{
$class = self::$_modelClass;
$attrClass = self::$_attributeClass;
if (!$this->beforeSave(false)) {
return false;
}
$values = $this->getDirtyAttributes($attributeNames);
if (empty($values)) {
$this->afterSave(false, $values);
return 0;
}
$model = $class::findOne($this->getOldPrimaryKey(true));
$attributes = array_flip($model->attributes());
$properties = array_flip($this->attributes());
$attributeColumns = array_reverse(array_keys(array_diff_key($properties, $attributes)), true);
$modelValues = array_intersect_key($values, $attributes);
$attributeValues = array_diff_key($values, $attributes);
if (empty($modelValues)) {
$rows = 1;
} else {
$model->setAttributes($modelValues);
$condition = $model->getOldPrimaryKey(true);
$lock = $model->optimisticLock();
if ($lock !== null) {
$values[$lock] = $this->$lock + 1;
$condition[$lock] = $this->$lock;
}
$rows = $model->updateAll($modelValues, $condition);
if ($lock !== null && !$rows) {
throw new StaleObjectException('The object being updated is outdated.');
}
}
$result = !!$rows;
if ($result !== false) {
$modelId = $model->getPrimaryKey();
$getter = 'get'.ucfirst(self::$_attributeRelation);
$attrQuery = $model->$getter();
$attrModels = $attrQuery->all();
$attrIds = array_keys($attrQuery->link);
$attrId = $attrIds[0];
$attrColumn = self::$_attributeColumn;
$attrValue = self::$_attributeValue;
foreach($attributeColumns as $idx=>$name){
$attrModel = null;
foreach ($attrModels as $key=>$attrModelLookup) {
if($attrModelLookup->$attrColumn == $name){
$attrModel = $attrModelLookup;
unset($attrModels[$key]);
break;
}
}
if(isset($attributeValues[$name])){
$value = $attributeValues[$name];
if(isset($attrModel)){
$attrModel->id = $idx+1;
$attrModel->$attrId = $modelId;
$attrModel->$attrColumn = $name;
$attrModel->$attrValue = $value;
} else {
$attrModel = new $attrClass();
$attrModel->id = $idx+1;
$attrModel->$attrId = $modelId;
$attrModel->$attrColumn = $name;
$attrModel->$attrValue = $value;
}
$result = $attrModel->save(false) && $result;
}
}
if(count($attrModels) > 0) {
foreach ($attrModels as $attrModel) {
$attrModel->delete();
}
}
}
if ($result === false) {
return false;
} else {
$changedAttributes = [];
foreach ($values as $name => $value) {
$changedAttributes[$name] = $this->getOldAttribute($name);
$this->setOldAttribute($name, $value);
}
$this->afterSave(false, $changedAttributes);
return $rows;
}
}
public static function populateRecord($record, $row)
{
$class = self::$_modelClass;
$model = $class::instantiate($row);
$class::populateRecord($model, $row);
$model->afterFind();
if($model) {
$columns = array_flip($record->attributes());
$attributes = $model->getAttributes();
foreach ($attributes as $name => $value) {
$row[$name] = $value;
}
$getter = 'get'.ucfirst(self::$_attributeRelation);
$attrQuery = $model->$getter();
$attrModels = $attrQuery->all();
$attrColumn = self::$_attributeColumn;
$attrValue = self::$_attributeValue;
if($attrModels) {
foreach ($attrModels as $attrModel) {
$name = $attrModel->$attrColumn;
$value = $attrModel->$attrValue;
$row[$name] = $value;
}
}
} else {
throw new Exception("Unable to load content item");
}
parent::populateRecord($record, $row);
}
}
Now if I wanted more properties I just add to the AttributeLabels so add ‘Headline’=>‘Headline’, ‘Author’=>‘Author’ then add the rules needed and when I would save it would create a 1 Node and 2 NodeFields with name of Headline and Author. Doing a Find() works fine just have to remember to do the default scope of type when querying
The reason for this approach is I don’t know if you can make a rule validate on relations. I kind of think I could do a Virtual attribute functions which grab the relation value or create a relation value if one doesn’t exist but I don’t know if that would guarantee the creation of the x amount NodeFields for each property. I like that thought but the need for creating a get and set function is more work but it would conform better with Yii vs extending this abstract class.
I also have one other thing to talk about which is when you do virtual attributes for format input and output and getting user input back on this forum topic Dealing with i18N date formats qiang reply’s with creating virtual attributes to do the formating but what if the user instead of a date formatted string send random text “lalffll” the parse date isn’t going to parse that text and when you return to page with the errors the form field is not going to be what the user submitted but will be the oldAttribute if it was a existing record or blank if it’s a new one. So how do you make this code
public function getMyDateInput()
{
return date('Y-m-d', CDateTimeParser::parse($this->mydate, Yii::app()->locale->dateFormat));
}
public function setMyDateInput($value)
{
$this->mydate = Yii::app()->dateFormatter->formatDateTime(CDateTimeParser::parse($value, 'yyyy-MM-dd'),'medium',null);
}
return back what the user inputted if it’s invalid.
This does work but I would like to know if there is any other options and the virtual attribute format thing has been racking my brain for a few days I thought of storing a array with userInputs and save it on set and on the get if there is a error on that attribute return the value from userInputs but I had a problem when it came down to multiple rules that continued on error.