Страницы

воскресенье, 22 сентября 2013 г.

Yii CAdvancedArBehavior bug.

На днях делал админку одного проекта, замутил на PHP + Yii. Модель данных MySQL содержит связи многие-ко-многим (собственно автор модели тоже я), поэтому редактирование контента реализовал с помощью расширения CAdvancedArBehavior. В процессе реализации обнаружил один баг в расширении, о чем поведу речь ниже. По окончании статьи осиливший путь освоит пару приемов работы с yii framework.

На протяжении изложения я буду использовать MS Windows + denwer.

Стартуем denwer, открываем phpMyAdmin, создаем тестовую базу данных и таблицы:
create database if not exists test_db 
character set utf8 collate utf8_general_ci;
use test_db;

create table if not exists `auth` (
 `id` mediumint unsigned not null auto_increment, 
 `name` varchar(20) not null, 
 primary key(`id`)
) engine=innodb;

create table if not exists `book` (
 `id` mediumint unsigned not null auto_increment, 
 `name` varchar(20) not null, 
 primary key(`id`)
) engine=innodb;

create table if not exists `auth_to_book` (
 `auth_id` mediumint unsigned not null, 
 `book_id` mediumint unsigned not null, 
 primary key (`auth_id`, `book_id`),  
 constraint `fk_auth` foreign key (`auth_id`) references auth(`id`),
 constraint `fk_book` foreign key (`book_id`) references book(`id`)
) engine=innodb;

Поясню: auth - авторы, book - книги, auth_to_book - связь авторов с книгами многие-ко-многим. Стандартный пример.

Создаем хост по имени yii-test, рестартуем denwer, качаем последнюю версию yii framework со страницы загрузки, распакуем каталог framework в корневой каталог хоста.

Запускаем консоль, переходим в каталог распакованного фреймворка. Не забываем добавить в переменную окружения PATH путь к интерпретатору PHP - в моем случае "W:\usr\local\php5\".
Создаем приложение:
- php -f yiic webapp ..\



В браузере идем по адресу созданного хоста:

Редактируем файл конфигурации приложения main.php, который можно найти в каталоге "protected/config/" (у меня w:\home\yii-test\www\protected\config\main.php).
<?php

// uncomment the following to define a path alias
// Yii::setPathOfAlias('local','path/to/local-folder');

// This is the main Web application configuration. Any writable
// CWebApplication properties can be configured here.
return array(
 'basePath'=>dirname(__FILE__).DIRECTORY_SEPARATOR.'..',
 'name'=>'My Web Application',

 // preloading 'log' component
 'preload'=>array('log'),

 // autoloading model and component classes
 'import'=>array(
  'application.models.*',
  'application.components.*',
 ),

 'modules'=>array(
  // uncomment the following to enable the Gii tool
  // рескоментируем для использования gii
  'gii'=>array(
   'class'=>'system.gii.GiiModule',
   'password'=>false, // без пароля
   // If removed, Gii defaults to localhost only. Edit carefully to taste.
   'ipFilters'=>array('127.0.0.1','::1'),
  ),
  
 ),

 // application components
 'components'=>array(
  'user'=>array(
   // enable cookie-based authentication
   'allowAutoLogin'=>true,
  ),
  // uncomment the following to enable URLs in path-format
  /*
  'urlManager'=>array(
   'urlFormat'=>'path',
   'rules'=>array(
    '<controller:\w+>/<id:\d+>'=>'<controller>/view',
    '<controller:\w+>/<action:\w+>/<id:\d+>'=>'<controller>/<action>',
    '<controller:\w+>/<action:\w+>'=>'<controller>/<action>',
   ),
  ),
  */
  // закомментируем строку подключения к sqlite
  /*
  'db'=>array(
   'connectionString' => 'sqlite:'.dirname(__FILE__).'/../data/testdrive.db',
  
  */
  // uncomment the following to use a MySQL database
  // раскомментируем для использования mysql
  'db'=>array(
   'connectionString' => 'mysql:host=localhost;dbname=test_db',
   'emulatePrepare' => true,
   'username' => 'root',
   'password' => '',
   'charset' => 'utf8',
  ),
  
  'errorHandler'=>array(
   // use 'site/error' action to display errors
   'errorAction'=>'site/error',
  ),
  'log'=>array(
   'class'=>'CLogRouter',
   'routes'=>array(
    array(
     'class'=>'CFileLogRoute',
     'levels'=>'error, warning',
    ),
    // uncomment the following to show log messages on web pages
    /*
    array(
     'class'=>'CWebLogRoute',
    ),
    */
   ),
  ),
 ),

 // application-level parameters that can be accessed
 // using Yii::app()->params['paramName']
 'params'=>array(
  // this is used in contact page
  'adminEmail'=>'webmaster@example.com',
 ),
);

Логинимся с именем admin и паролем admin, открываем страницу генератора кода нашего приложения.

Используем генератор кода по прямому назначению: создадим модель данных таблицы auth - авторы.
Переходим по ссылке Model Generator, выбираем таблицу:

Нажимаем Preview:

Генерируем код:

В результате мы получили файл Auth.php, содержащий код класса модели таблицы auth по имени Auth.
<?php

/**
 * This is the model class for table "auth".
 *
 * The followings are the available columns in table 'auth':
 * @property integer $id
 * @property string $name
 *
 * The followings are the available model relations:
 * @property Book[] $books
 */
class Auth extends CActiveRecord
{
 /**
  * @return string the associated database table name
  */
 public function tableName()
 {
  return 'auth';
 }

 /**
  * @return array validation rules for model attributes.
  */
 public function rules()
 {
  // NOTE: you should only define rules for those attributes that
  // will receive user inputs.
  return array(
   array('name', 'required'),
   array('name', 'length', 'max'=>20),
   // The following rule is used by search().
   // @todo Please remove those attributes that should not be searched.
   array('id, name', 'safe', 'on'=>'search'),
  );
 }

 /**
  * @return array relational rules.
  */
 public function relations()
 {
  // NOTE: you may need to adjust the relation name and the related
  // class name for the relations automatically generated below.
  return array(
   'books' => array(self::MANY_MANY, 'Book', 'auth_to_book(auth_id, book_id)'),
  );
 }

 /**
  * @return array customized attribute labels (name=>label)
  */
 public function attributeLabels()
 {
  return array(
   'id' => 'ID',
   'name' => 'Name',
  );
 }

 /**
  * Retrieves a list of models based on the current search/filter conditions.
  *
  * Typical usecase:
  * - Initialize the model fields with values from filter form.
  * - Execute this method to get CActiveDataProvider instance which will filter
  * models according to data in model fields.
  * - Pass data provider to CGridView, CListView or any similar widget.
  *
  * @return CActiveDataProvider the data provider that can return the models
  * based on the search/filter conditions.
  */
 public function search()
 {
  // @todo Please modify the following code to remove attributes that should not be searched.

  $criteria=new CDbCriteria;

  $criteria->compare('id',$this->id);
  $criteria->compare('name',$this->name,true);

  return new CActiveDataProvider($this, array(
   'criteria'=>$criteria,
  ));
 }

 /**
  * Returns the static model of the specified AR class.
  * Please note that you should have this exact method in all your CActiveRecord descendants!
  * @param string $className active record class name.
  * @return Auth the static model class
  */
 public static function model($className=__CLASS__)
 {
  return parent::model($className);
 }
}

Идем по ссылке Crud Generator, создаем контроллер, а также представления CRUD (Create, Read, Update, Delete) операций с созданной выше моделью данных.
Нажимаем Preview:

Кстати, нажав на ссылку с именем файла можно посмотреть создаваемый код - к примеру контроллера:

Генерируем код:

Перейдем по ссылке try it now, создадим пару авторов - у меня Smith & Wesson.

Похожим образом поступаем с таблицей book - книги. Создадим пару книг - у меня Gang & Bang.

А теперь хотелось бы добавить нашей книге авторов.

Качаем последнюю версию расширения CAdvancedArBehavior, после чего распакуем файл CAdvancedArBehavior.php в каталог приложения "protected/extensions/" (у меня "w:\home\yii-test\www\protected\extensions\").

Для того, чтобы не повторять код в каждом классе модели, содержащей отношения многие-ко-многим, создадим файл myActiveRecord.php, содержащий код класса, который наследует от CActiveRecord.
<?php

abstract class myActiveRecord extends CActiveRecord {
    public function behaviors() {
        return array(            
            'CAdvancedArBehavior' => array(
                'class' => 'application.extensions.CAdvancedArBehavior'),
        );
    }
}

?>

Изменим наследование классов Auth  и  Book, добавим правила (public function rules()) для связей books и auth (public function relations()):
- Auth.php:
<?php

/**
 * This is the model class for table "auth".
 *
 * The followings are the available columns in table 'auth':
 * @property integer $id
 * @property string $name
 *
 * The followings are the available model relations:
 * @property Book[] $books
 */
class Auth extends myActiveRecord
{
 /**
  * @return string the associated database table name
  */
 public function tableName()
 {
  return 'auth';
 }

 /**
  * @return array validation rules for model attributes.
  */
 public function rules()
 {
  // NOTE: you should only define rules for those attributes that
  // will receive user inputs.
  return array(
   array('name', 'required'),
   array('name', 'length', 'max'=>20),
   // The following rule is used by search().
   // @todo Please remove those attributes that should not be searched.
   array('id, name', 'safe', 'on'=>'search'),
   array('books', 'safe'),
  );
 }

 /**
  * @return array relational rules.
  */
 public function relations()
 {
  // NOTE: you may need to adjust the relation name and the related
  // class name for the relations automatically generated below.
  return array(
   'books' => array(self::MANY_MANY, 'Book', 'auth_to_book(auth_id, book_id)'),
  );
 }

 /**
  * @return array customized attribute labels (name=>label)
  */
 public function attributeLabels()
 {
  return array(
   'id' => 'ID',
   'name' => 'Name',
  );
 }

 /**
  * Retrieves a list of models based on the current search/filter conditions.
  *
  * Typical usecase:
  * - Initialize the model fields with values from filter form.
  * - Execute this method to get CActiveDataProvider instance which will filter
  * models according to data in model fields.
  * - Pass data provider to CGridView, CListView or any similar widget.
  *
  * @return CActiveDataProvider the data provider that can return the models
  * based on the search/filter conditions.
  */
 public function search()
 {
  // @todo Please modify the following code to remove attributes that should not be searched.

  $criteria=new CDbCriteria;

  $criteria->compare('id',$this->id);
  $criteria->compare('name',$this->name,true);

  return new CActiveDataProvider($this, array(
   'criteria'=>$criteria,
  ));
 }

 /**
  * Returns the static model of the specified AR class.
  * Please note that you should have this exact method in all your CActiveRecord descendants!
  * @param string $className active record class name.
  * @return Auth the static model class
  */
 public static function model($className=__CLASS__)
 {
  return parent::model($className);
 }
}

- Book.php:
<?php

/**
 * This is the model class for table "book".
 *
 * The followings are the available columns in table 'book':
 * @property integer $id
 * @property string $name
 *
 * The followings are the available model relations:
 * @property Auth[] $auths
 */
class Book extends myActiveRecord
{
 /**
  * @return string the associated database table name
  */
 public function tableName()
 {
  return 'book';
 }

 /**
  * @return array validation rules for model attributes.
  */
 public function rules()
 {
  // NOTE: you should only define rules for those attributes that
  // will receive user inputs.
  return array(
   array('name', 'required'),
   array('name', 'length', 'max'=>20),
   // The following rule is used by search().
   // @todo Please remove those attributes that should not be searched.
   array('id, name', 'safe', 'on'=>'search'),
   array('auths', 'safe'),
  );
 }

 /**
  * @return array relational rules.
  */
 public function relations()
 {
  // NOTE: you may need to adjust the relation name and the related
  // class name for the relations automatically generated below.
  return array(
   'auths' => array(self::MANY_MANY, 'Auth', 'auth_to_book(book_id, auth_id)'),
  );
 }

 /**
  * @return array customized attribute labels (name=>label)
  */
 public function attributeLabels()
 {
  return array(
   'id' => 'ID',
   'name' => 'Name',
  );
 }

 /**
  * Retrieves a list of models based on the current search/filter conditions.
  *
  * Typical usecase:
  * - Initialize the model fields with values from filter form.
  * - Execute this method to get CActiveDataProvider instance which will filter
  * models according to data in model fields.
  * - Pass data provider to CGridView, CListView or any similar widget.
  *
  * @return CActiveDataProvider the data provider that can return the models
  * based on the search/filter conditions.
  */
 public function search()
 {
  // @todo Please modify the following code to remove attributes that should not be searched.

  $criteria=new CDbCriteria;

  $criteria->compare('id',$this->id);
  $criteria->compare('name',$this->name,true);

  return new CActiveDataProvider($this, array(
   'criteria'=>$criteria,
  ));
 }

 /**
  * Returns the static model of the specified AR class.
  * Please note that you should have this exact method in all your CActiveRecord descendants!
  * @param string $className active record class name.
  * @return Book the static model class
  */
 public static function model($className=__CLASS__)
 {
  return parent::model($className);
 }
}

Открываем файлы _form.php представлений, редактируем - добавим элементы для отображения связанных значений:
- ./protected/views/auth/_form.php:
<?php
/* @var $this AuthController */
/* @var $model Auth */
/* @var $form CActiveForm */
?>

<div class="form">

<?php $form=$this->beginWidget('CActiveForm', array(
 'id'=>'auth-form',
 // Please note: When you enable ajax validation, make sure the corresponding
 // controller action is handling ajax validation correctly.
 // There is a call to performAjaxValidation() commented in generated controller code.
 // See class documentation of CActiveForm for details on this.
 'enableAjaxValidation'=>false,
)); ?>

 <p class="note">Fields with <span class="required">*</span> are required.</p>

 <?php echo $form->errorSummary($model); ?>

 <div class="row">
  <?php echo $form->labelEx($model,'name'); ?>
  <?php echo $form->textField($model,'name',array('size'=>20,'maxlength'=>20)); ?>
  <?php echo $form->error($model,'name'); ?>
 </div>
 
 <div class="row">
  <?php echo $form->labelEx($model, 'books'); ?>
            <?php echo $form->dropDownList($model,'books', 
                CHtml::listData(Book::model()->findAll(array('order'=>'id')), 'id', 'name'),
                array('empty'=>'','multiple'=>'multiple','style'=>'width:300px;','size'=>'3'));
            ?>
        <?php echo $form->error($model, 'books'); ?>
    </div>        

 <div class="row buttons">
  <?php echo CHtml::submitButton($model->isNewRecord ? 'Create' : 'Save'); ?>
 </div>

<?php $this->endWidget(); ?>

</div><!-- form -->

./protected/views/book/_form.php:
<?php
/* @var $this BookController */
/* @var $model Book */
/* @var $form CActiveForm */
?>

<div class="form">

<?php $form=$this->beginWidget('CActiveForm', array(
 'id'=>'book-form',
 // Please note: When you enable ajax validation, make sure the corresponding
 // controller action is handling ajax validation correctly.
 // There is a call to performAjaxValidation() commented in generated controller code.
 // See class documentation of CActiveForm for details on this.
 'enableAjaxValidation'=>false,
)); ?>

 <p class="note">Fields with <span class="required">*</span> are required.</p>

 <?php echo $form->errorSummary($model); ?>

 <div class="row">
  <?php echo $form->labelEx($model,'name'); ?>
  <?php echo $form->textField($model,'name',array('size'=>20,'maxlength'=>20)); ?>
  <?php echo $form->error($model,'name'); ?>
 </div>
 
 <div class="row">
  <?php echo $form->labelEx($model, 'auths'); ?>
            <?php echo $form->dropDownList($model,'auths', 
                CHtml::listData(Auth::model()->findAll(array('order'=>'id')), 'id', 'name'),
                array('empty'=>'','multiple'=>'multiple','style'=>'width:300px;','size'=>'3'));
            ?>
        <?php echo $form->error($model, 'auths'); ?>
    </div>        

 <div class="row buttons">
  <?php echo CHtml::submitButton($model->isNewRecord ? 'Create' : 'Save'); ?>
 </div>

<?php $this->endWidget(); ?>

</div><!-- form -->

Редактируем данные автора Smith, добавим в его коллекцию обе книги:

Открываем книгу Gang:

Все в елочку. Добавляем открытой книге Gang еще одного автора:

Без проблем. А теперь откроем, к примеру, автора Wesson и попробуем присвоить ему авторство обоих произведений выбрав их с помощью клавиатурного сочетания Ctrl+A:



Много букаф :). Решение очевидно - нужно добавить проверку на наличие пустого поля в списке выделенных полей. Редактируем код скачанного ранее расширения - файл CAdvancedArBehavior.php (./protected/extensions/CAdvancedArBehavior.php):
<?php

class CAdvancedArbehavior extends CActiveRecordBehavior
{
 // Set this to false to disable tracing of changes
 public $trace = true;

 // If you want to ignore some relations, set them here.
 public $ignoreRelations = array();

 // After the save process of the model this behavior is attached to 
 // is finished, we begin saving our MANY_MANY related data 
 public function afterSave($event) 
 {
  if(!is_array($this->ignoreRelations))
   throw new CException('ignoreRelations of CAdvancedArBehavior needs to be an array');

  $this->writeManyManyTables();
  parent::afterSave($event);
  return true;
 }

 protected function writeManyManyTables() 
 {
  if($this->trace)
   Yii::trace('writing MANY_MANY data for '.get_class($this->owner),
     'system.db.ar.CActiveRecord');

  foreach($this->getRelations() as $relation) 
  {
   $this->cleanRelation($relation);
   $this->writeRelation($relation);
  }
 }

 /* A relation will have the following format:
  $relation['m2mTable'] = the tablename of the foreign object
  $relation['m2mThisField'] = the column in the many2many table that represents the primary Key of the object that this behavior is attached to
  $relation['m2mForeignField'] = the column in the many2many table that represents the foreign object. 

  Written in Yii relation syntax, it would be like this
  'relationname' => array('foreignobject', 'column', 'm2mTable(m2mThisField, m2mForeignField) */
 protected function getRelations()
 {
  $relations = array();

  foreach ($this->owner->relations() as $key => $relation) 
  {
   if ($relation[0] == CActiveRecord::MANY_MANY && 
     !in_array($key, $this->ignoreRelations) &&
     $this->owner->hasRelated($key) && 
     $this->owner->$key != -1)
   {
    $info = array();
    $info['key'] = $key;
    $info['foreignTable'] = $relation[1];

     if (preg_match('/^(.+)\((.+)\s*,\s*(.+)\)$/s', $relation[2], $pocks)) 
     {
      $info['m2mTable'] = $pocks[1];
      $info['m2mThisField'] = $pocks[2];
      $info['m2mForeignField'] = $pocks[3];
     }
     else 
     {
      $info['m2mTable'] = $relation[2];
      $info['m2mThisField'] = $this->owner->tableSchema->PrimaryKey;
      $info['m2mForeignField'] = CActiveRecord::model($relation[1])->tableSchema->primaryKey;
     }
    $relations[$key] = $info;
   }
  }
  return $relations;
 }

 /** writeRelation's job is to check if the user has given an array or an 
  * single Object, and executes the needed query */
 protected function writeRelation($relation) 
 {
  $key = $relation['key'];
                
  // Only an object or primary key id is given
  if(!is_array($this->owner->$key) && $this->owner->$key != array())   
   $this->owner->$key = array($this->owner->$key);
                
  // An array of objects is given
  foreach((array)$this->owner->$key as $foreignobject)
  {
            if(empty($foreignobject)) continue; // если выбрали пустое поле - не пишем
   if(!is_numeric($foreignobject) && is_object($foreignobject))
    $foreignobject = $foreignobject->{$foreignobject->$relation['m2mForeignField']};
   $this->execute($this->makeManyManyInsertCommand($relation, $foreignobject));
  }
 }

 /* before saving our relation data, we need to clean up exsting relations so
  * they are synchronized */
 protected function cleanRelation($relation)
 {
  $this->execute($this->makeManyManyDeleteCommand($relation)); 
 }

 // A wrapper function for execution of SQL queries
 public function execute($query) {
  return Yii::app()->db->createCommand($query)->execute();
 }

 public function makeManyManyInsertCommand($relation, $value) {
  return sprintf("insert into %s (%s, %s) values ('%s', '%s')",
    $relation['m2mTable'],
    $relation['m2mThisField'],
    $relation['m2mForeignField'],
    $this->owner->{$this->owner->tableSchema->primaryKey},
    $value);
 }

 public function makeManyManyDeleteCommand($relation) {
  return sprintf("delete ignore from %s where %s = '%s'",
    $relation['m2mTable'],
    $relation['m2mThisField'],
    $this->owner->{$this->owner->tableSchema->primaryKey}
    );
 }
}

Тестируем: пытаемся сохранить...ошибок нет.
Проверяем:

Easy peasy lemon squeezy :).