├── tests ├── phpunit.xml ├── bootstrap.php ├── fixtures │ ├── models │ │ ├── Tag.php │ │ ├── Category.php │ │ ├── ProductDescription.php │ │ ├── Certificate.php │ │ ├── CertificateForm.php │ │ ├── Attachment.php │ │ ├── ProductForm.php │ │ ├── Product.php │ │ └── AttachmentForm.php │ └── data │ │ └── sqlite.sql └── unit │ ├── WFileIteratorTest.php │ ├── WActiveRecordTest.php │ ├── WTempFileTest.php │ ├── WFormRelationTest.php │ ├── WFormBehaviorTest.php │ ├── WFormTest.php │ ├── WFormRelationBelongsToTest.php │ ├── WFormRelationHasOneTest.php │ ├── WFormRelationManyManyTest.php │ └── WFormRelationHasManyTest.php ├── WActiveRecord.php ├── examples └── product │ ├── models │ ├── Tag.php │ ├── Category.php │ ├── ProductDescription.php │ ├── Certificate.php │ ├── forms │ │ ├── CertificateForm.php │ │ ├── ProductForm.php │ │ └── AttachmentForm.php │ ├── Attachment.php │ └── Product.php │ ├── views │ └── product │ │ ├── index.php │ │ └── edit.php │ ├── controllers │ └── ProductController.php │ ├── product.sql │ └── README.md ├── WTempFile.php ├── WFormRelationBelongsTo.php ├── WFileIterator.php ├── WFormRelationHasOne.php ├── js └── jquery.multiplyforms.js ├── WFormRelationHasMany.php ├── WFormRelationManyMany.php ├── WFormRelation.php ├── WFormBehavior.php ├── README.md └── WForm.php /tests/phpunit.xml: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /WActiveRecord.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class WActiveRecord extends CActiveRecord { 9 | 10 | // @todo any ideas how to prevent using custom event for that ? 11 | public function onUnsafeAttribute($name, $value) 12 | { 13 | $event = new CEvent($this, array('name' => $name, 'value' => $value)); 14 | $this->raiseEvent('onUnsafeAttribute', $event); 15 | return parent::onUnsafeAttribute($name, $value); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | dirname(__FILE__) . '/fixtures/' 19 | )); 20 | -------------------------------------------------------------------------------- /tests/fixtures/models/Tag.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class Tag extends WActiveRecord 9 | { 10 | 11 | public static function model($className=__CLASS__) 12 | { 13 | return parent::model($className); 14 | } 15 | 16 | public function tableName() 17 | { 18 | return 'tags'; 19 | } 20 | 21 | public function rules() 22 | { 23 | return array( 24 | array('name', 'required'), 25 | ); 26 | } 27 | 28 | public function attributeLabels() 29 | { 30 | return array( 31 | 'id' => 'ID', 32 | 'name' => 'Name', 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/product/models/Tag.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class Tag extends WActiveRecord 9 | { 10 | 11 | public static function model($className=__CLASS__) 12 | { 13 | return parent::model($className); 14 | } 15 | 16 | public function tableName() 17 | { 18 | return 'tags'; 19 | } 20 | 21 | public function rules() 22 | { 23 | return array( 24 | array('name', 'required'), 25 | array('name', 'unique'), 26 | ); 27 | } 28 | 29 | public function attributeLabels() 30 | { 31 | return array( 32 | 'id' => 'ID', 33 | 'name' => 'Name', 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/product/models/Category.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class Category extends WActiveRecord 9 | { 10 | 11 | public static function model($className=__CLASS__) 12 | { 13 | return parent::model($className); 14 | } 15 | 16 | public function tableName() 17 | { 18 | return 'categories'; 19 | } 20 | 21 | public function rules() 22 | { 23 | return array( 24 | array('name', 'required'), 25 | array('name', 'unique'), 26 | ); 27 | } 28 | 29 | public function attributeLabels() 30 | { 31 | return array( 32 | 'id' => 'ID', 33 | 'name' => 'Name', 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/fixtures/models/Category.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class Category extends WActiveRecord 9 | { 10 | 11 | public static function model($className=__CLASS__) 12 | { 13 | return parent::model($className); 14 | } 15 | 16 | public function tableName() 17 | { 18 | return 'categories'; 19 | } 20 | 21 | public function rules() 22 | { 23 | return array( 24 | array('name', 'required'), 25 | array('name', 'unique'), 26 | ); 27 | } 28 | 29 | public function attributeLabels() 30 | { 31 | return array( 32 | 'id' => 'ID', 33 | 'name' => 'Name', 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/product/models/ProductDescription.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class ProductDescription extends WActiveRecord 9 | { 10 | 11 | public static function model($className=__CLASS__) 12 | { 13 | return parent::model($className); 14 | } 15 | 16 | public function tableName() 17 | { 18 | return 'product_descriptions'; 19 | } 20 | 21 | public function rules() 22 | { 23 | return array( 24 | array('color, size, product_id', 'safe'), 25 | ); 26 | } 27 | 28 | public function attributeLabels() 29 | { 30 | return array( 31 | 'id' => 'ID', 32 | 'color' => 'Color', 33 | 'size' => 'Size', 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/fixtures/models/ProductDescription.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class ProductDescription extends WActiveRecord 9 | { 10 | 11 | public static function model($className=__CLASS__) 12 | { 13 | return parent::model($className); 14 | } 15 | 16 | public function tableName() 17 | { 18 | return 'product_descriptions'; 19 | } 20 | 21 | public function rules() 22 | { 23 | return array( 24 | array('size', 'required'), 25 | array('color, product_id', 'safe'), 26 | ); 27 | } 28 | 29 | public function attributeLabels() 30 | { 31 | return array( 32 | 'id' => 'ID', 33 | 'color' => 'Color', 34 | 'size' => 'Size', 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/product/models/Certificate.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class Certificate extends WActiveRecord 9 | { 10 | 11 | public static function model($className=__CLASS__) 12 | { 13 | return parent::model($className); 14 | } 15 | 16 | public function tableName() 17 | { 18 | return 'certificates'; 19 | } 20 | 21 | public function rules() 22 | { 23 | return array( 24 | array('name', 'required'), 25 | ); 26 | } 27 | 28 | public function relations() 29 | { 30 | return array( 31 | 'image' => array(self::HAS_ONE, 'Attachment', 'object_id', 'condition' => 'image.object_type=:object_type', 'params' => array('object_type' => Attachment::OBJECT_TYPE_CERTIFICATE)), 32 | ); 33 | } 34 | 35 | public function attributeLabels() 36 | { 37 | return array( 38 | 'id' => 'ID', 39 | 'name' => 'Name', 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/fixtures/models/Certificate.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class Certificate extends WActiveRecord 9 | { 10 | 11 | public static function model($className=__CLASS__) 12 | { 13 | return parent::model($className); 14 | } 15 | 16 | public function tableName() 17 | { 18 | return 'certificates'; 19 | } 20 | 21 | public function rules() 22 | { 23 | return array( 24 | array('name', 'required'), 25 | ); 26 | } 27 | 28 | public function relations() 29 | { 30 | return array( 31 | 'image' => array(self::HAS_ONE, 'Attachment', 'object_id', 'condition' => 'image.object_type=:object_type', 'params' => array('object_type' => Attachment::OBJECT_TYPE_CERTIFICATE)), 32 | ); 33 | } 34 | 35 | public function attributeLabels() 36 | { 37 | return array( 38 | 'id' => 'ID', 39 | 'name' => 'Name', 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/fixtures/models/CertificateForm.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class CertificateForm extends Certificate 9 | { 10 | 11 | public static function model($className=__CLASS__) 12 | { 13 | return parent::model($className); 14 | } 15 | 16 | public function relations() 17 | { 18 | return array_merge(parent::relations(), array( 19 | 'image' => array(self::HAS_ONE, 'AttachmentForm', 'object_id', 'condition' => 'image.object_type=:object_type', 'params' => array('object_type' => Attachment::OBJECT_TYPE_CERTIFICATE)), 20 | )); 21 | } 22 | 23 | public function behaviors() 24 | { 25 | return array_merge( 26 | parent::behaviors(), 27 | array( 28 | 'wform' => array( 29 | 'class' => 'ext.wform.WFormBehavior', 30 | 'relations' => array( 31 | 'image' => array(), 32 | ), 33 | ), 34 | ) 35 | ); 36 | } 37 | 38 | 39 | } 40 | -------------------------------------------------------------------------------- /examples/product/models/forms/CertificateForm.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class CertificateForm extends Certificate 9 | { 10 | 11 | public static function model($className=__CLASS__) 12 | { 13 | return parent::model($className); 14 | } 15 | 16 | public function relations() 17 | { 18 | return array_merge(parent::relations(), array( 19 | 'image' => array(self::HAS_ONE, 'AttachmentForm', 'object_id', 'condition' => 'image.object_type=:object_type', 'params' => array('object_type' => Attachment::OBJECT_TYPE_CERTIFICATE)), 20 | )); 21 | } 22 | 23 | public function behaviors() 24 | { 25 | return array_merge( 26 | parent::behaviors(), 27 | array( 28 | 'wform' => array( 29 | 'class' => 'ext.wform.WFormBehavior', 30 | 'relations' => array( 31 | 'image' => array(), 32 | ), 33 | ), 34 | ) 35 | ); 36 | } 37 | 38 | 39 | } 40 | -------------------------------------------------------------------------------- /WTempFile.php: -------------------------------------------------------------------------------- 1 | _tempDirectory = $tempDirectory; 12 | } 13 | 14 | public function setFile($file) 15 | { 16 | $this->_file = $file; 17 | } 18 | 19 | public function isValid() 20 | { 21 | return is_file($this->getPath()) && file_exists($this->getPath()); 22 | } 23 | 24 | public function getPath() 25 | { 26 | return $this->_tempDirectory . '/' . $this->getFile(); 27 | } 28 | 29 | public function saveAs($destFile) 30 | { 31 | if (!$this->isValid()) 32 | return false; 33 | 34 | return copy($this->getPath(), $destFile); 35 | } 36 | 37 | public function upload($sourceFile) 38 | { 39 | return move_uploaded_file($sourceFile, $this->getPath()); 40 | } 41 | 42 | public function getFile() 43 | { 44 | if (empty($this->_file)) 45 | $this->_file = tempnam($this->_tempDirectory, "php"); 46 | 47 | return basename($this->_file); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/fixtures/models/Attachment.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class Attachment extends WActiveRecord 9 | { 10 | 11 | const OBJECT_TYPE_PRODUCT_IMAGE = 'product_image'; 12 | const OBJECT_TYPE_CERTIFICATE = 'certificate'; 13 | 14 | public static function model($className=__CLASS__) 15 | { 16 | return parent::model($className); 17 | } 18 | 19 | public function tableName() 20 | { 21 | return 'attachments'; 22 | } 23 | 24 | public function rules() 25 | { 26 | return array( 27 | array('object_id', 'numerical', 'integerOnly'=>true), 28 | array('object_type', 'length', 'max'=>13), 29 | array('file', 'length', 'max'=>250), 30 | array('file', 'required'), 31 | ); 32 | } 33 | 34 | public function attributeLabels() 35 | { 36 | return array( 37 | 'id' => 'ID', 38 | 'object_id' => 'Object', 39 | 'object_type' => 'Object Type', 40 | 'file' => 'File', 41 | 'file_origin' => 'File Origin', 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /examples/product/views/product/index.php: -------------------------------------------------------------------------------- 1 |

Products

2 |

3 | Create Product 4 |

5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
idnamecategporydescriptionimages countcertificateeditdelete
id ?>name ?>category ? $product->category->name : '' ?>description ? $product->description->color . '/' . $product->description->size : '' ?>images ? count($product->images) : '' ?>certificate ? $product->certificate->name : '' ?>EditDelete
30 | 31 | -------------------------------------------------------------------------------- /tests/fixtures/models/ProductForm.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class ProductForm extends Product 9 | { 10 | 11 | public static function model($className=__CLASS__) 12 | { 13 | return parent::model($className); 14 | } 15 | 16 | public function relations() 17 | { 18 | return array_merge(parent::relations(), array( 19 | 'images' => array(self::HAS_MANY, 'AttachmentForm', 'object_id', 'condition' => 'images.object_type=:object_type', 'params' => array('object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE)), 20 | 'certificate' => array(self::HAS_ONE, 'CertificateForm', 'product_id'), 21 | )); 22 | } 23 | 24 | 25 | public function behaviors() 26 | { 27 | return array_merge( 28 | parent::behaviors(), 29 | array( 30 | 'wform' => array( 31 | 'class' => 'ext.wform.WFormBehavior', 32 | 'relations' => array( 33 | 'category' => array('unsetInvalid' => true, 'required' => false), 34 | 'tags' => array('required' => false), 35 | 'images', 36 | 'certificate', 37 | 'description', 38 | ), 39 | ), 40 | ) 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /WFormRelationBelongsTo.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class WFormRelationBelongsTo extends WFormRelationHasOne { 9 | 10 | public $type = CActiveRecord::BELONGS_TO; 11 | 12 | public $required = true; 13 | 14 | public function save() { 15 | $relationModel = $this->getRelatedModel($this->required); 16 | 17 | if (is_null($relationModel) && !$this->required) 18 | return true; 19 | 20 | if (!$relationModel->save()) 21 | return false; 22 | 23 | $foreignKey = $this->info[WFormRelation::RELATION_FOREIGN_KEY]; 24 | $this->model->{$foreignKey} = $relationModel->primaryKey; 25 | 26 | return true; 27 | } 28 | 29 | public function getRelatedModel($createNewIfEmpty = true) { 30 | $relationClass = $this->info[WFormRelation::RELATION_CLASS]; 31 | 32 | if (!$this->model->{$this->name} || (!$this->isAttributesPerformed() && $this->isPreloaded())) { 33 | if (!$createNewIfEmpty) 34 | return null; 35 | 36 | $this->model->{$this->name} = new $relationClass(); 37 | } 38 | 39 | return $this->model->{$this->name}; 40 | } 41 | 42 | public function delete() { 43 | return true; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /examples/product/models/forms/ProductForm.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class ProductForm extends Product 9 | { 10 | 11 | public static function model($className=__CLASS__) 12 | { 13 | return parent::model($className); 14 | } 15 | 16 | public function relations() 17 | { 18 | return array_merge(parent::relations(), array( 19 | 'images' => array(self::HAS_MANY, 'AttachmentForm', 'object_id', 20 | 'condition' => 'images.object_type = :object_type', 21 | 'params' => array('object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE), 22 | 'together' => false, 23 | ), 24 | 'certificate' => array(self::HAS_ONE, 'CertificateForm', 'product_id'), 25 | )); 26 | } 27 | 28 | 29 | public function behaviors() 30 | { 31 | return array_merge( 32 | parent::behaviors(), 33 | array( 34 | 'wform' => array( 35 | 'class' => 'ext.wform.WFormBehavior', 36 | 'relations' => array( 37 | 'category' => array('unsetInvalid' => true, 'required' => false), 38 | 'tags' => array('required' => false), 39 | 'images', 40 | 'certificate', 41 | 'description', 42 | ), 43 | ), 44 | ) 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/fixtures/models/Product.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class Product extends WActiveRecord 9 | { 10 | 11 | public static function model($className=__CLASS__) 12 | { 13 | return parent::model($className); 14 | } 15 | 16 | public function tableName() 17 | { 18 | return 'products'; 19 | } 20 | 21 | public function rules() 22 | { 23 | return array( 24 | array('price, category_id', 'numerical'), 25 | array('name', 'required'), 26 | ); 27 | } 28 | 29 | public function relations() 30 | { 31 | return array( 32 | 'category' => array(self::BELONGS_TO, 'Category', 'category_id'), 33 | 'tags' => array(self::MANY_MANY, 'Tag', 'products_2_tags(product_id, tag_id)'), 34 | 'images' => array(self::HAS_MANY, 'Attachment', 'object_id', 'condition' => 'images.object_type=:object_type', 'params' => array('object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE)), 35 | 'certificate' => array(self::HAS_ONE, 'Certificate', 'product_id'), 36 | 'description' => array(self::HAS_ONE, 'ProductDescription', 'product_id'), 37 | ); 38 | } 39 | 40 | public function attributeLabels() 41 | { 42 | return array( 43 | 'id' => 'ID', 44 | 'name' => 'Name', 45 | 'price' => 'Price', 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /examples/product/controllers/ProductController.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class ProductController extends Controller 9 | { 10 | public function actionIndex() 11 | { 12 | $products = ProductForm::model()->findAll(); 13 | $this->render('index', array( 14 | 'products' => $products 15 | )); 16 | } 17 | 18 | public function actionAdd() 19 | { 20 | $this->forward('edit'); 21 | } 22 | 23 | public function actionEdit($id = null) 24 | { 25 | $productForm = $id ? ProductForm::model()->with('images','tags')->findByPk($id) : new ProductForm(); 26 | 27 | if (Yii::app()->request->getPost('ProductForm')) { 28 | $productForm->attributes = Yii::app()->request->getPost('ProductForm'); 29 | if ($productForm->save()) { 30 | $this->redirect($this->createUrl('product/index')); 31 | } 32 | } 33 | $this->render('edit', array( 34 | 'product' => $productForm, 35 | 'categories' => Category::model()->findAll(), 36 | 'tags' => Tag::model()->findAll() 37 | )); 38 | } 39 | 40 | public function actionDelete($id) 41 | { 42 | $productForm = ProductForm::model()->findByPk($id); 43 | if (!empty($productForm)) { 44 | $productForm->delete(); 45 | } 46 | $this->redirect($this->createUrl('product/index')); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /examples/product/models/Attachment.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class Attachment extends WActiveRecord 9 | { 10 | 11 | const OBJECT_TYPE_PRODUCT_IMAGE = 'product_image'; 12 | const OBJECT_TYPE_CERTIFICATE = 'certificate'; 13 | 14 | public static function model($className=__CLASS__) 15 | { 16 | return parent::model($className); 17 | } 18 | 19 | public function tableName() 20 | { 21 | return 'attachments'; 22 | } 23 | 24 | public function rules() 25 | { 26 | return array( 27 | array('object_id', 'numerical', 'integerOnly'=>true), 28 | array('object_type', 'length', 'max'=>13), 29 | array('file, file_origin', 'length', 'max'=>250), 30 | ); 31 | } 32 | 33 | public function attributeLabels() 34 | { 35 | return array( 36 | 'id' => 'ID', 37 | 'object_id' => 'Object', 38 | 'object_type' => 'Object Type', 39 | 'file' => 'File', 40 | 'file_origin' => 'File Origin', 41 | ); 42 | } 43 | 44 | public function getFilePath() 45 | { 46 | return Yii::app()->runtimePath.'/'.$this->object_type . '/' . $this->file; 47 | } 48 | 49 | public function getFileUrl() 50 | { 51 | if ($this->file) { 52 | return Yii::app()->createUrl('/protected/runtime/' . $this->object_type . '/' . $this->file); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /examples/product/models/Product.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class Product extends WActiveRecord 9 | { 10 | 11 | public static function model($className=__CLASS__) 12 | { 13 | return parent::model($className); 14 | } 15 | 16 | public function tableName() 17 | { 18 | return 'products'; 19 | } 20 | 21 | public function rules() 22 | { 23 | return array( 24 | array('price, category_id', 'numerical'), 25 | array('name', 'required'), 26 | array('name', 'length', 'max'=>200), 27 | ); 28 | } 29 | 30 | public function relations() 31 | { 32 | return array( 33 | 'category' => array(self::BELONGS_TO, 'Category', 'category_id'), 34 | 'tags' => array(self::MANY_MANY, 'Tag', 'products_2_tags(product_id, tag_id)'), 35 | 'images' => array(self::HAS_MANY, 'Attachment', 'object_id', 36 | 'condition' => 'images.object_type=:object_type', 37 | 'params' => array('object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE), 38 | 'together' => false, 39 | ), 40 | 'certificate' => array(self::HAS_ONE, 'Certificate', 'product_id'), 41 | 'description' => array(self::HAS_ONE, 'ProductDescription', 'product_id'), 42 | ); 43 | } 44 | 45 | public function attributeLabels() 46 | { 47 | return array( 48 | 'id' => 'ID', 49 | 'name' => 'Name', 50 | 'price' => 'Price', 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/unit/WFileIteratorTest.php: -------------------------------------------------------------------------------- 1 | object = new WFileIterator; 21 | } 22 | 23 | /** 24 | * Tears down the fixture, for example, closes a network connection. 25 | * This method is called after a test is executed. 26 | */ 27 | protected function tearDown() 28 | { 29 | } 30 | 31 | /** 32 | * @covers {className}::{origMethodName} 33 | * @todo Implement testGetFiles(). 34 | */ 35 | public function testGetFiles() 36 | { 37 | // Remove the following lines when you implement this test. 38 | $this->markTestIncomplete( 39 | 'This test has not been implemented yet.' 40 | ); 41 | } 42 | 43 | /** 44 | * @covers {className}::{origMethodName} 45 | * @todo Implement test_toPaths(). 46 | */ 47 | public function test_toPaths() 48 | { 49 | // Remove the following lines when you implement this test. 50 | $this->markTestIncomplete( 51 | 'This test has not been implemented yet.' 52 | ); 53 | } 54 | } 55 | ?> 56 | -------------------------------------------------------------------------------- /tests/unit/WActiveRecordTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('PDO and SQLite extensions are required.'); 16 | 17 | $this->_connection = new CDbConnection('sqlite::memory:'); 18 | $this->_connection->active = true; 19 | $this->_connection->pdoInstance->exec(file_get_contents(dirname(__FILE__).'/../fixtures/data/sqlite.sql')); 20 | CActiveRecord::$db = $this->_connection; 21 | } 22 | 23 | 24 | protected function tearDown() 25 | { 26 | $this->_connection->active=false; 27 | } 28 | 29 | /** 30 | * @covers WActiveRecord::onUnsafeAttribute 31 | */ 32 | public function testOnUnsafeAttribute() 33 | { 34 | $unsafeAttributes = array(); 35 | $product = new Product(); 36 | $product->onUnsafeAttribute = function($event) use (&$unsafeAttributes) { 37 | $unsafeAttributes[] = $event->params; 38 | }; 39 | $product->attributes = array( 40 | 'name' => 'product name', 41 | 'images' => array( 42 | 'file' => 'file.txt' 43 | ), 44 | 'tags' => array(), 45 | ); 46 | 47 | $this->assertNotEmpty($unsafeAttributes); 48 | $this->assertCount(2, $unsafeAttributes); 49 | $this->assertEquals($unsafeAttributes[0]['name'], 'images'); 50 | $this->assertCount(1, $unsafeAttributes[0]['value']); 51 | $this->assertEquals($unsafeAttributes[0]['value']['file'], 'file.txt'); 52 | $this->assertEquals($unsafeAttributes[1]['name'], 'tags'); 53 | $this->assertEmpty($unsafeAttributes[1]['value']); 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /WFileIterator.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class WFileIterator extends CMap { 9 | 10 | public function __construct($model) { 11 | $modelClass = get_class($model); 12 | $files = $this->getFiles($modelClass); 13 | $this->copyFrom($files); 14 | } 15 | 16 | public function getFiles($key) { 17 | if (!isset($_FILES[$key])) { 18 | return array(); 19 | } 20 | 21 | $files = $this->_normalize($_FILES[$key]); 22 | 23 | return $this->_toPaths($files); 24 | } 25 | 26 | public function _toPaths($files) { 27 | $complete = false; 28 | while(!$complete) { 29 | $complete = true; 30 | foreach($files as $key => $file) { 31 | if (!($file instanceof CUploadedFile)) { 32 | if (!$this->_isFile($file)) { 33 | $complete = false; 34 | if (is_array($file)) { 35 | foreach($file as $subKey => $subFile) { 36 | $files[$key . '.' . $subKey] = $subFile; 37 | } 38 | } 39 | unset($files[$key]); 40 | } elseif ($file['error'] != UPLOAD_ERR_OK) { 41 | unset($files[$key]); 42 | } else { 43 | $files[$key] = new CUploadedFile($file['name'], $file['tmp_name'], $file['type'], $file['size'], $file['error']); 44 | } 45 | } 46 | } 47 | } 48 | return $files; 49 | } 50 | 51 | protected function _normalize($files = array()) { 52 | $normalizedFiles = array(); 53 | foreach($files as $key => $value) { 54 | if (is_array($value)) { 55 | foreach($value as $k=>$v) { 56 | $normalizedFiles[$k][$key] = $v; 57 | } 58 | } else { 59 | $normalizedFiles[$key] = $value; 60 | } 61 | } 62 | 63 | foreach($normalizedFiles as $k => $v) { 64 | if (is_array($v)) 65 | $normalizedFiles[$k] = $this->_normalize($v); 66 | } 67 | 68 | return $normalizedFiles; 69 | } 70 | 71 | protected function _isFile($data) { 72 | return isset($data['name']) && isset($data['tmp_name']) && isset($data['size']) && isset($data['error']); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/fixtures/models/AttachmentForm.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class AttachmentForm extends Attachment 9 | { 10 | 11 | public $tempFile = null; 12 | 13 | public static function model($className=__CLASS__) 14 | { 15 | return parent::model($className); 16 | } 17 | 18 | public function rules() 19 | { 20 | return array_merge(parent::rules(), array( 21 | array('tempFile', 'safe'), 22 | )); 23 | } 24 | 25 | public static function create($type) 26 | { 27 | $attachmentForm = new AttachmentForm(); 28 | $attachmentForm->object_type = $type; 29 | return $attachmentForm; 30 | } 31 | 32 | public function beforeValidate() 33 | { 34 | if ($this->file instanceof CUploadedFile) { 35 | // save to tmp folder 36 | $tempFile = new WTempFile(Yii::app()->runtimePath); 37 | 38 | if ($this->file->saveAs($tempFile->getPath())) { 39 | $this->tempFile = $tempFile->getFile(); 40 | 41 | // setup proper file_origin 42 | $this->file_origin = $this->file->getName(); 43 | } 44 | } 45 | return true; 46 | } 47 | 48 | public function saveUploadedFile() 49 | { 50 | if (empty($this->file_origin)) { 51 | if (!$this->isNewRecord) 52 | $this->delete(); 53 | return false; 54 | } 55 | 56 | if (empty($this->tempFile)) { 57 | return false; 58 | } 59 | 60 | $tempFile = new WTempFile(Yii::app()->runtimePath); 61 | $tempFile->setFile($this->tempFile); 62 | 63 | if (!$tempFile->isValid()) { 64 | return false; 65 | } 66 | 67 | $attachmentDirectory = Yii::app()->runtimePath . '/' . $this->object_type . '/'; 68 | 69 | if (!is_dir($attachmentDirectory)) { 70 | mkdir($attachmentDirectory); 71 | } 72 | 73 | $fileName = $this->id . '.' . pathinfo($this->file_origin, PATHINFO_EXTENSION); 74 | 75 | 76 | if ($tempFile->saveAs($attachmentDirectory . $fileName)) { 77 | $this->file = $fileName; 78 | $this->isNewRecord = false; 79 | $this->tempFile = null; 80 | $this->save(false); 81 | } 82 | 83 | $this->tempFile = null; 84 | 85 | return false; 86 | } 87 | 88 | public function afterSave() 89 | { 90 | $this->saveUploadedFile(); 91 | return parent::afterSave(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /examples/product/models/forms/AttachmentForm.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class AttachmentForm extends Attachment 9 | { 10 | 11 | public $tempFile = null; 12 | 13 | public static function model($className=__CLASS__) 14 | { 15 | return parent::model($className); 16 | } 17 | 18 | public function rules() 19 | { 20 | return array_merge(parent::rules(), array( 21 | array('tempFile', 'safe'), 22 | )); 23 | } 24 | 25 | public static function create($type) 26 | { 27 | $attachmentForm = new AttachmentForm(); 28 | $attachmentForm->object_type = $type; 29 | return $attachmentForm; 30 | } 31 | 32 | public function beforeValidate() 33 | { 34 | if ($this->file instanceof CUploadedFile) { 35 | // save to tmp folder 36 | $tempFile = new WTempFile(Yii::app()->runtimePath); 37 | 38 | if ($this->file->saveAs($tempFile->getPath())) { 39 | $this->tempFile = $tempFile->getFile(); 40 | 41 | // setup proper file_origin 42 | $this->file_origin = $this->file->getName(); 43 | } 44 | } 45 | return true; 46 | } 47 | 48 | public function saveUploadedFile() 49 | { 50 | if (empty($this->file_origin)) { 51 | if (!$this->isNewRecord) 52 | $this->delete(); 53 | return false; 54 | } 55 | 56 | if (empty($this->tempFile)) { 57 | return false; 58 | } 59 | 60 | $tempFile = new WTempFile(Yii::app()->runtimePath); 61 | $tempFile->setFile($this->tempFile); 62 | 63 | if (!$tempFile->isValid()) { 64 | return false; 65 | } 66 | 67 | $attachmentDirectory = Yii::app()->runtimePath . '/' . $this->object_type . '/'; 68 | 69 | if (!is_dir($attachmentDirectory)) { 70 | mkdir($attachmentDirectory); 71 | } 72 | 73 | $fileName = $this->id . '.' . pathinfo($this->file_origin, PATHINFO_EXTENSION); 74 | 75 | 76 | if ($tempFile->saveAs($attachmentDirectory . $fileName)) { 77 | $this->file = $fileName; 78 | $this->isNewRecord = false; 79 | $this->tempFile = null; 80 | $this->save(false); 81 | } 82 | 83 | $this->tempFile = null; 84 | 85 | return false; 86 | } 87 | 88 | public function afterSave() 89 | { 90 | $this->saveUploadedFile(); 91 | return parent::afterSave(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /WFormRelationHasOne.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class WFormRelationHasOne extends WFormRelation { 9 | 10 | public $type = CActiveRecord::HAS_ONE; 11 | 12 | public $required = true; 13 | 14 | public function setAttributes($attributes) { 15 | parent::setAttributes($attributes); 16 | 17 | if (!is_null($attributes)) { 18 | $relationModel = $this->getRelatedModel(); 19 | $relationModel->attributes = $attributes; 20 | } else { 21 | $this->model->{$this->name} = null; 22 | } 23 | } 24 | 25 | public function validate() { 26 | $relationModel = $this->getRelatedModel($this->required); 27 | 28 | if (is_null($relationModel) && !$this->required) 29 | return true; 30 | 31 | 32 | return $relationModel->validate(); 33 | } 34 | 35 | public function save() { 36 | $relationModel = $this->getRelatedModel($this->required); 37 | 38 | if ($this->mode == self::MODE_REPLACE && ($actualModel = $this->getActualRelatedModel()) !== null) { 39 | $this->addToLazyDelete($actualModel); 40 | } 41 | 42 | if (is_null($relationModel) && !$this->required) 43 | return true; 44 | 45 | $foreignKey = $this->info[WFormRelation::RELATION_FOREIGN_KEY]; 46 | $relationModel->$foreignKey = $this->model->primaryKey; 47 | 48 | $this->removeFromLazyDelete($relationModel); 49 | 50 | return $relationModel->save(); 51 | } 52 | 53 | public function getRelatedModel($createNewIfEmpty = true) { 54 | $relationClass = $this->info[WFormRelation::RELATION_CLASS]; 55 | 56 | if (!$this->model->{$this->name} || (!$this->isAttributesPerformed() && $this->isPreloaded())) { 57 | $this->model->{$this->name} = $createNewIfEmpty ? new $relationClass() : null; 58 | } 59 | 60 | return $this->model->{$this->name}; 61 | } 62 | 63 | public function getActualRelatedModel() { 64 | if ($this->model->isNewRecord) 65 | return null; 66 | 67 | $modelClone = clone $this->model; 68 | return $modelClone->getRelated($this->name, true); 69 | } 70 | 71 | public function delete() { 72 | if (!$this->cascadeDelete) 73 | return true; 74 | 75 | $model = $this->getActualRelatedModel(); 76 | if ($model) 77 | return $model->delete(); 78 | 79 | return true; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/unit/WTempFileTest.php: -------------------------------------------------------------------------------- 1 | markTestIncomplete( 36 | 'This test has not been implemented yet.' 37 | ); 38 | } 39 | 40 | /** 41 | * @covers {className}::{origMethodName} 42 | * @todo Implement testIsValid(). 43 | */ 44 | public function testIsValid() 45 | { 46 | // Remove the following lines when you implement this test. 47 | $this->markTestIncomplete( 48 | 'This test has not been implemented yet.' 49 | ); 50 | } 51 | 52 | /** 53 | * @covers {className}::{origMethodName} 54 | * @todo Implement testGetPath(). 55 | */ 56 | public function testGetPath() 57 | { 58 | // Remove the following lines when you implement this test. 59 | $this->markTestIncomplete( 60 | 'This test has not been implemented yet.' 61 | ); 62 | } 63 | 64 | /** 65 | * @covers {className}::{origMethodName} 66 | * @todo Implement testSaveAs(). 67 | */ 68 | public function testSaveAs() 69 | { 70 | // Remove the following lines when you implement this test. 71 | $this->markTestIncomplete( 72 | 'This test has not been implemented yet.' 73 | ); 74 | } 75 | 76 | /** 77 | * @covers {className}::{origMethodName} 78 | * @todo Implement testUpload(). 79 | */ 80 | public function testUpload() 81 | { 82 | // Remove the following lines when you implement this test. 83 | $this->markTestIncomplete( 84 | 'This test has not been implemented yet.' 85 | ); 86 | } 87 | 88 | /** 89 | * @covers {className}::{origMethodName} 90 | * @todo Implement testGetFile(). 91 | */ 92 | public function testGetFile() 93 | { 94 | // Remove the following lines when you implement this test. 95 | $this->markTestIncomplete( 96 | 'This test has not been implemented yet.' 97 | ); 98 | } 99 | } -------------------------------------------------------------------------------- /tests/fixtures/data/sqlite.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS `attachments`; 2 | CREATE TABLE `attachments` ( 3 | `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | `object_id` INTEGER, 5 | `object_type` VARCHAR(250), 6 | `file` varchar(250), 7 | `file_origin` varchar(250) 8 | ); 9 | 10 | -- ---------------------------- 11 | -- Table structure for categories 12 | -- ---------------------------- 13 | DROP TABLE IF EXISTS `categories`; 14 | CREATE TABLE `categories` ( 15 | `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 16 | `name` varchar(200) 17 | ); 18 | 19 | -- ---------------------------- 20 | -- Table structure for certificates 21 | -- ---------------------------- 22 | DROP TABLE IF EXISTS `certificates`; 23 | CREATE TABLE `certificates` ( 24 | `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 25 | `product_id` int(11), 26 | `name` varchar(11) 27 | ); 28 | 29 | -- ---------------------------- 30 | -- Table structure for product_descriptions 31 | -- ---------------------------- 32 | DROP TABLE IF EXISTS `product_descriptions`; 33 | CREATE TABLE `product_descriptions` ( 34 | `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 35 | `product_id` int(11) NOT NULL, 36 | `color` varchar(200), 37 | `size` varchar(200) 38 | ); 39 | 40 | -- ---------------------------- 41 | -- Table structure for products 42 | -- ---------------------------- 43 | DROP TABLE IF EXISTS `products`; 44 | CREATE TABLE `products` ( 45 | `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 46 | `category_id` int(11), 47 | `name` varchar(200), 48 | `price` double 49 | ); 50 | 51 | -- ---------------------------- 52 | -- Table structure for products_2_tags 53 | -- ---------------------------- 54 | DROP TABLE IF EXISTS `products_2_tags`; 55 | CREATE TABLE `products_2_tags` ( 56 | `product_id` INTEGER NOT NULL, 57 | `tag_id` INTEGER NOT NULL, 58 | PRIMARY KEY (`product_id`,`tag_id`) 59 | ); 60 | 61 | -- ---------------------------- 62 | -- Table structure for tags 63 | -- ---------------------------- 64 | DROP TABLE IF EXISTS `tags`; 65 | CREATE TABLE `tags` ( 66 | `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 67 | `name` varchar(200) 68 | ); 69 | 70 | -- ---------------------------- 71 | -- Records 72 | -- ---------------------------- 73 | INSERT INTO `categories` VALUES ('1', 'Auto'); 74 | INSERT INTO `categories` VALUES ('2', 'Mobile'); 75 | INSERT INTO `categories` VALUES ('3', 'Used'); 76 | 77 | INSERT INTO `tags` VALUES ('1', 'bad'); 78 | INSERT INTO `tags` VALUES ('2', 'good'); 79 | INSERT INTO `tags` VALUES ('3', 'awesome'); 80 | 81 | INSERT INTO `certificates` VALUES ('1', '1', '9045'); 82 | INSERT INTO `product_descriptions` VALUES ('1', '1', 'Red', '100x100'); 83 | INSERT INTO `products` VALUES ('1', '1', 'Test Product', '99'); 84 | INSERT INTO `attachments` VALUES ('1', '1', 'product_image', 'file.txt', '/path/to/file.txt'); 85 | 86 | 87 | INSERT INTO `products_2_tags` VALUES ('1', '2'); 88 | INSERT INTO `products_2_tags` VALUES ('1', '3'); -------------------------------------------------------------------------------- /examples/product/product.sql: -------------------------------------------------------------------------------- 1 | -- ---------------------------- 2 | -- Table structure for attachments 3 | -- ---------------------------- 4 | CREATE TABLE `attachments` ( 5 | `id` int(11) unsigned NOT NULL auto_increment, 6 | `object_id` int(11) default NULL, 7 | `object_type` enum('product_image','certificate') default NULL, 8 | `file` varchar(250) default NULL, 9 | `file_origin` varchar(250) default NULL, 10 | PRIMARY KEY (`id`) 11 | ) ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; 12 | 13 | -- ---------------------------- 14 | -- Table structure for categories 15 | -- ---------------------------- 16 | CREATE TABLE `categories` ( 17 | `id` int(11) unsigned NOT NULL auto_increment, 18 | `name` varchar(200) default NULL, 19 | PRIMARY KEY (`id`) 20 | ) ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; 21 | 22 | -- ---------------------------- 23 | -- Table structure for certificates 24 | -- ---------------------------- 25 | CREATE TABLE `certificates` ( 26 | `id` int(11) unsigned NOT NULL auto_increment, 27 | `product_id` int(11) default NULL, 28 | `name` varchar(11) default NULL, 29 | PRIMARY KEY (`id`) 30 | ) ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; 31 | 32 | -- ---------------------------- 33 | -- Table structure for product_descriptions 34 | -- ---------------------------- 35 | CREATE TABLE `product_descriptions` ( 36 | `id` int(11) unsigned NOT NULL auto_increment, 37 | `product_id` int(11) NOT NULL, 38 | `color` varchar(200) default NULL, 39 | `size` varchar(200) default NULL, 40 | PRIMARY KEY (`id`) 41 | ) ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; 42 | 43 | -- ---------------------------- 44 | -- Table structure for products 45 | -- ---------------------------- 46 | CREATE TABLE `products` ( 47 | `id` int(11) unsigned NOT NULL auto_increment, 48 | `category_id` int(11) default NULL, 49 | `name` varchar(200) default NULL, 50 | `price` double default NULL, 51 | PRIMARY KEY (`id`) 52 | ) ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; 53 | 54 | -- ---------------------------- 55 | -- Table structure for products_2_tags 56 | -- ---------------------------- 57 | CREATE TABLE `products_2_tags` ( 58 | `product_id` int(11) unsigned NOT NULL, 59 | `tag_id` int(11) unsigned NOT NULL, 60 | PRIMARY KEY (`product_id`,`tag_id`) 61 | ) ENGINE=MyISAM DEFAULT CHARSET=utf8; 62 | 63 | -- ---------------------------- 64 | -- Table structure for tags 65 | -- ---------------------------- 66 | CREATE TABLE `tags` ( 67 | `id` int(11) unsigned NOT NULL auto_increment, 68 | `name` varchar(200) default NULL, 69 | PRIMARY KEY (`id`) 70 | ) ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; 71 | 72 | -- ---------------------------- 73 | -- Records 74 | -- ---------------------------- 75 | INSERT INTO `categories` VALUES ('1', 'Auto'); 76 | INSERT INTO `categories` VALUES ('2', 'Mobile'); 77 | INSERT INTO `categories` VALUES ('3', 'Used'); 78 | INSERT INTO `certificates` VALUES ('1', '1', '9045'); 79 | INSERT INTO `product_descriptions` VALUES ('1', '1', 'Red', '100x100'); 80 | INSERT INTO `products` VALUES ('1', '1', 'Test Product', '99'); 81 | INSERT INTO `attachments` VALUES ('1', '1', 'product_image', 'file.text', 'path/to/file.txt'); 82 | INSERT INTO `products_2_tags` VALUES ('1', '2'); 83 | INSERT INTO `products_2_tags` VALUES ('1', '3'); 84 | INSERT INTO `tags` VALUES ('1', 'bad'); 85 | INSERT INTO `tags` VALUES ('2', 'good'); 86 | INSERT INTO `tags` VALUES ('3', 'awesome'); -------------------------------------------------------------------------------- /js/jquery.multiplyforms.js: -------------------------------------------------------------------------------- 1 | (function($){ 2 | $.multiplyForms = function(element, options){ 3 | 4 | var self = this; 5 | self.element = $(element); 6 | 7 | //self.formCounter = 100; 8 | self.options = $.extend({}, $.multiplyForms.defaultOptions, options); 9 | 10 | self.element.data("multiplyForms", self); 11 | self.template = self.element.find("." + self.options.templateClass); 12 | 13 | self.init = function() { 14 | 15 | self.template.find('input, textarea, select, button').attr('disabled', 'disabled'); 16 | 17 | // find add links inside self.element 18 | var addLinks = self.element.find(self.options.addLink); 19 | // find add links in document 20 | addLinks = addLinks.length ? addLinks : $(self.options.addLink); 21 | 22 | addLinks.click(function(e) { 23 | e.preventDefault(); 24 | self._cloneTemplate(); 25 | }); 26 | 27 | self.element.find(self.options.deleteLink).live('click', function(e) { 28 | e.preventDefault(); 29 | 30 | var embedForm = $(e.target).parents("." + self.options.embedClass); 31 | 32 | // beforeDelete callback 33 | var e = jQuery.Event("multiplyForms.delete", {multiplyFormInstance: self}); 34 | e.target = embedForm; 35 | self.element.trigger(e, [embedForm, self]); 36 | // if ($.isFunction(self.options.beforeDelete)) { 37 | // self.options.beforeDelete.call(this, embedForm, self); 38 | // } 39 | if (!e.isDefaultPrevented()) 40 | embedForm.remove(); 41 | }); 42 | }; 43 | 44 | self._cloneTemplate = function() { 45 | var self = this; 46 | var newForm = self.template 47 | .clone(false) 48 | .find('input, textarea, select, button') 49 | .removeAttr('disabled') 50 | .end(); 51 | 52 | if (self.options.mode == "append") { 53 | if (self.options.appendTo) { 54 | self.element.find(self.options.appendTo).append(newForm); 55 | } else { 56 | newForm.appendTo(self.element); 57 | } 58 | } else { 59 | if (self.options.prependTo) { 60 | self.element.find(self.options.prependTo).append(newForm); 61 | } else { 62 | newForm.prependTo(self.element); 63 | } 64 | } 65 | 66 | newForm 67 | .addClass(self.options.embedClass) 68 | .removeClass(self.options.templateClass) 69 | .show(); 70 | 71 | self._updateIndex(newForm); 72 | 73 | 74 | // afterAdd callback 75 | var e = jQuery.Event("multiplyForms.add"); 76 | e.target = newForm; 77 | self.element.trigger(e, [newForm, self]); 78 | // if ($.isFunction(self.options.afterAdd)) { 79 | // self.options.afterAdd.call(this, newForm, self); 80 | // } 81 | }; 82 | 83 | self._updateIndex = function(form) { 84 | form.find('*[name*="{index}"]').each(function() { 85 | $(this).attr('name', $(this).attr('name').replace('{index}', $.multiplyForms.formCounter)); 86 | this.id = this.id.replace('{index}', $.multiplyForms.formCounter); 87 | }); 88 | $.multiplyForms.formCounter++; 89 | }; 90 | 91 | self.init(); 92 | }; 93 | 94 | $.multiplyForms.defaultOptions = { 95 | addLink: ".add", 96 | deleteLink: ".delete", 97 | templateClass: "template", 98 | embedClass: "embed", 99 | afterAdd: undefined, 100 | beforeDelete: undefined, 101 | mode: "append", 102 | appendTo: undefined, 103 | prependTo: undefined 104 | }; 105 | 106 | $.multiplyForms.formCounter = 1000; 107 | 108 | $.fn.multiplyForms = function(options) { 109 | return this.each(function(){ 110 | (new $.multiplyForms(this, options)); 111 | }); 112 | }; 113 | 114 | })(jQuery); 115 | -------------------------------------------------------------------------------- /WFormRelationHasMany.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class WFormRelationHasMany extends WFormRelation { 9 | 10 | public $type = CActiveRecord::HAS_MANY; 11 | 12 | public function setAttributes($bunchOfAttributes) { 13 | parent::setAttributes($bunchOfAttributes); 14 | 15 | $relationClass = $this->info[WFormRelation::RELATION_CLASS]; 16 | $relationPk = $relationClass::model()->getMetaData()->tableSchema->primaryKey; 17 | 18 | $modelsDictionary = array(); 19 | foreach ($this->getRelatedModels() as $relationModel) { 20 | if ($relationModel->primaryKey) { 21 | $modelsDictionary[$relationModel->primaryKey] = $relationModel; 22 | } 23 | } 24 | 25 | $relationModels = array(); 26 | 27 | foreach ($bunchOfAttributes as $key => &$attributes) { 28 | if (!is_array($attributes) && is_numeric($attributes)) { 29 | $attributes = array($relationPk => $attributes); 30 | } 31 | 32 | if (isset($attributes[$relationPk])) { 33 | if (isset($modelsDictionary[$attributes[$relationPk]])) { 34 | $relationModel = $modelsDictionary[$attributes[$relationPk]]; 35 | } else { 36 | $relationModel = $relationClass::model()->findByPk($attributes[$relationPk]) ?: new $relationClass(); 37 | } 38 | } else { 39 | $relationModel = new $relationClass(); 40 | } 41 | 42 | $relationModel->attributes = $attributes; 43 | $relationModels[$key] = $relationModel; 44 | } 45 | 46 | $this->model->{$this->name} = $relationModels; 47 | } 48 | 49 | public function validate() { 50 | $isValid = true; 51 | 52 | $relatedModels = $this->getRelatedModels(); 53 | if (count($relatedModels) == 0 && $this->required) 54 | return false; 55 | 56 | foreach ($relatedModels as $key => $relationModel) { 57 | if (!$relationModel->validate()) { 58 | if ($this->unsetInvalid) { 59 | unset($relatedModels[$key]); 60 | $this->model->{$this->name} = $relatedModels; 61 | } else { 62 | $isValid = false; 63 | } 64 | } 65 | } 66 | return $isValid; 67 | } 68 | 69 | public function save() { 70 | $foreignKey = $this->info[WFormRelation::RELATION_FOREIGN_KEY]; 71 | 72 | if ($this->mode == self::MODE_REPLACE) { 73 | foreach($this->getActualRelatedModels() as $model) 74 | $this->addToLazyDelete($model); 75 | } 76 | 77 | $relatedModels = $this->getRelatedModels(); 78 | if (count($relatedModels) == 0 && $this->required) 79 | return false; 80 | 81 | $isSuccess = true; 82 | foreach ($relatedModels as $index => $relationModel) { 83 | $this->removeFromLazyDelete($relationModel); 84 | 85 | $relationModel->$foreignKey = $this->model->primaryKey; 86 | $isSuccess = $relationModel->save() && $isSuccess; 87 | } 88 | 89 | return $isSuccess; 90 | } 91 | 92 | public function getRelatedModels() { 93 | if (!$this->model->{$this->name} || (!$this->isAttributesPerformed() && $this->isPreloaded())) { 94 | $this->model->{$this->name} = array(); 95 | } 96 | 97 | return (array) $this->model->{$this->name}; 98 | } 99 | 100 | public function getActualRelatedModels() { 101 | if ($this->model->isNewRecord) 102 | return array(); 103 | 104 | $modelClone = clone $this->model; 105 | return (array) $modelClone->getRelated($this->name, true); 106 | } 107 | 108 | public function delete() { 109 | if (!$this->cascadeDelete) 110 | return true; 111 | 112 | $isSuccess = true; 113 | foreach($this->getActualRelatedModels() as $model) 114 | $isSuccess = $model->delete() && $isSuccess; 115 | 116 | return $isSuccess; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /WFormRelationManyMany.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class WFormRelationManyMany extends WFormRelationHasMany { 9 | 10 | public $type = CActiveRecord::HAS_MANY; 11 | 12 | public function save() { 13 | if ($this->mode == self::MODE_REPLACE) { 14 | foreach($this->getActualRelatedModels() as $model) 15 | $this->addToLazyDelete($model); 16 | } 17 | 18 | $relatedModels = $this->getRelatedModels(); 19 | if (count($relatedModels) == 0 && $this->required) 20 | return false; 21 | 22 | $isSuccess = true; 23 | foreach ($relatedModels as $index => $relationModel) { 24 | $this->removeFromLazyDelete($relationModel); 25 | 26 | if ($relationModel->save()) { 27 | $isSuccess = $this->_linkTo($relationModel) && $isSuccess; 28 | } else { 29 | $isSuccess = false; 30 | } 31 | } 32 | 33 | return $isSuccess; 34 | } 35 | 36 | /** 37 | * Insert link between parent and relation models into database 38 | * 39 | * @todo maybe we should execute bulk insert of links ? It faster a lot 40 | * @param $relatedModel 41 | * @return bool 42 | */ 43 | protected function _linkTo($relatedModel) { 44 | $foreignKey = $this->_parseForeignKey($this->info[WFormRelation::RELATION_FOREIGN_KEY]); 45 | 46 | try { 47 | $sql = "INSERT INTO {$foreignKey['table']} ({$foreignKey['model_fk']}, {$foreignKey['relation_fk']}) VALUES (:model_fk,:relation_fk)"; 48 | 49 | $command = $this->model->getDbConnection()->createCommand($sql); 50 | $command->bindValues(array( 51 | ":model_fk" => $this->model->primaryKey, 52 | ":relation_fk" => $relatedModel->primaryKey, 53 | )); 54 | $command->execute(); 55 | } catch (Exception $e) { 56 | return false; 57 | } 58 | return true; 59 | } 60 | 61 | public function lazyDelete() { 62 | $relatedIds = array(); 63 | foreach($this->_lazyDeleteRecords as $model) { 64 | $relatedIds[] = $model->primaryKey; 65 | } 66 | 67 | 68 | if (count($relatedIds)) 69 | $this->_unlink($relatedIds); 70 | } 71 | 72 | public function delete() { 73 | if (!$this->cascadeDelete) 74 | return true; 75 | 76 | return $this->_unlink(); 77 | } 78 | 79 | /** 80 | * Remove all links between parent and relation models into database 81 | * 82 | * @return bool 83 | */ 84 | protected function _unlink($ids = null) { 85 | $foreignKey = $this->_parseForeignKey($this->info[WFormRelation::RELATION_FOREIGN_KEY]); 86 | 87 | try { 88 | 89 | $sql = "DELETE FROM {$foreignKey['table']} WHERE {$foreignKey['model_fk']} = :model_fk"; 90 | if (!is_null($ids)) { 91 | $sql .= " AND {$foreignKey['relation_fk']} IN ('" . join("','", $ids) . "')"; 92 | } 93 | 94 | $command = $this->model->getDbConnection()->createCommand($sql); 95 | $command->bindValues(array( 96 | ":model_fk" => $this->model->primaryKey, 97 | )); 98 | $command->execute(); 99 | 100 | } catch (Exception $e) { 101 | return false; 102 | } 103 | 104 | return true; 105 | } 106 | 107 | /** 108 | * Parse foreign key into table name, model FK and relation FK 109 | * 110 | * @param $key 111 | * @return array 112 | */ 113 | protected function _parseForeignKey($key) { 114 | if (preg_match('/(?P.*?)\((?P.*?),(?P.*?)\)/is', $key, $matches)) 115 | { 116 | return array( 117 | 'table' => $this->model->getDbConnection()->quoteTableName(trim($matches['table'])), 118 | 'model_fk' => $this->model->getDbConnection()->quoteColumnName(trim($matches['model_fk'])), 119 | 'relation_fk' => $this->model->getDbConnection()->quoteColumnName(trim($matches['relation_fk'])), 120 | ); 121 | } 122 | 123 | return null; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /WFormRelation.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class WFormRelation { 9 | 10 | const RELATION_TYPE = 0; 11 | const RELATION_CLASS = 1; 12 | const RELATION_FOREIGN_KEY = 2; 13 | 14 | const MODE_APPEND = "append"; 15 | const MODE_REPLACE = "replace"; 16 | 17 | public $required = false; 18 | public $unsetInvalid = false; 19 | public $mode = self::MODE_REPLACE; 20 | public $cascadeDelete = true; 21 | 22 | // @todo why $name is public? 23 | public $name = null; 24 | 25 | // @todo rename to $_info, $_model, $_type 26 | protected $info = null; 27 | protected $model = null; 28 | protected $type = null; 29 | 30 | protected $_lazyDeleteRecords = array(); 31 | protected $_attributesPerformed = false; 32 | protected $_preloaded = false; // relation was specified into CActiveRecord::with(); 33 | 34 | public static function getInstance($model, $relationName, $options = array()) { 35 | // for 'relations' => array('someRelation','someOtherRelation') 36 | if (is_numeric($relationName) && is_string($options)) { 37 | $relationName = $options; 38 | $options = array(); 39 | } 40 | 41 | $relationInfo = self::getRelationInfo($model, $relationName); 42 | if ($relationInfo === null) 43 | return null; 44 | 45 | switch($relationInfo[self::RELATION_TYPE]) { 46 | case CActiveRecord::HAS_ONE: $relation = new WFormRelationHasOne(); break; 47 | case CActiveRecord::HAS_MANY: $relation = new WFormRelationHasMany(); break; 48 | case CActiveRecord::BELONGS_TO: $relation = new WFormRelationBelongsTo(); break; 49 | case CActiveRecord::MANY_MANY: $relation = new WFormRelationManyMany(); break; 50 | default: 51 | return null; 52 | 53 | } 54 | 55 | $relation->setModel($model); 56 | $relation->setInfo($relationInfo); 57 | $relation->name = $relationName; 58 | 59 | $relation->setOptions($options); 60 | 61 | return $relation; 62 | } 63 | 64 | public function __construct($options = array()) { 65 | $this->setOptions($options); 66 | } 67 | 68 | public function setOptions($options) { 69 | foreach($options as $key => $value) { 70 | if (property_exists($this, $key) && !in_array($key, array('name', 'type', 'info', 'model'))) { 71 | $this->{$key} = $value; 72 | } 73 | } 74 | } 75 | 76 | public static function getRelationInfo($model, $relationName) { 77 | $relations = $model->relations(); 78 | if (!array_key_exists($relationName, $relations)) 79 | return null; 80 | return $relations[$relationName]; 81 | } 82 | 83 | public function setInfo($info) { 84 | $this->info = $info; 85 | } 86 | 87 | public function setType($type) { 88 | $this->type = $type; 89 | } 90 | 91 | public function setModel($model) { 92 | $this->model = $model; 93 | } 94 | 95 | public function setPreloaded($isPreloaded) { 96 | $this->_preloaded = $isPreloaded; 97 | } 98 | 99 | public function isPreloaded() { 100 | return $this->_preloaded; 101 | } 102 | 103 | public function addToLazyDelete($model) { 104 | if (!$model->isNewRecord) 105 | $this->_lazyDeleteRecords[$model->primaryKey] = $model; 106 | } 107 | 108 | public function removeFromLazyDelete($model) { 109 | if (array_key_exists($model->primaryKey, $this->_lazyDeleteRecords)) 110 | unset($this->_lazyDeleteRecords[$model->primaryKey]); 111 | } 112 | 113 | public function lazyDelete() { 114 | foreach($this->_lazyDeleteRecords as $model) { 115 | $model->delete(); 116 | } 117 | } 118 | 119 | public function delete() { 120 | return true; 121 | } 122 | 123 | public function setAttributes($bunchOfAttributes) { 124 | $this->_attributesPerformed = true; 125 | } 126 | 127 | public function isAttributesPerformed() { 128 | return $this->_attributesPerformed; 129 | } 130 | } -------------------------------------------------------------------------------- /tests/unit/WFormRelationTest.php: -------------------------------------------------------------------------------- 1 | getModelMock(); 16 | 17 | $model->expects($this->any()) 18 | ->method('setOptions'); 19 | 20 | $options = array( 21 | 'required' => true, 22 | 'type' => 'someUnexpectedType' 23 | ); 24 | 25 | // for 'relations' => array('someRelation' => array(..options..)) 26 | $this->assertInstanceOf('WFormRelationHasOne', WFormRelation::getInstance($model, 'hasOne', $options)); 27 | $this->assertInstanceOf('WFormRelationHasMany', WFormRelation::getInstance($model, 'hasMany', $options)); 28 | $this->assertInstanceOf('WFormRelationManyMany', WFormRelation::getInstance($model, 'manyMany', $options)); 29 | $this->assertInstanceOf('WFormRelationBelongsTo', WFormRelation::getInstance($model, 'belongsTo', $options)); 30 | $this->assertNull(WFormRelation::getInstance($model, 'stat', $options)); 31 | $this->assertNull(WFormRelation::getInstance($model, 'misteriousRelation', $options)); 32 | 33 | // for 'relations' => array('someRelation','someOtherRelation') 34 | $this->assertInstanceOf('WFormRelationHasOne', WFormRelation::getInstance($model, 0, 'hasOne')); 35 | $this->assertInstanceOf('WFormRelationHasMany', WFormRelation::getInstance($model, 1, 'hasMany')); 36 | $this->assertInstanceOf('WFormRelationManyMany', WFormRelation::getInstance($model, 2, 'manyMany')); 37 | $this->assertInstanceOf('WFormRelationBelongsTo', WFormRelation::getInstance($model, 3, 'belongsTo')); 38 | $this->assertNull(WFormRelation::getInstance($model, 4, 'stat')); 39 | $this->assertNull(WFormRelation::getInstance($model, 5, 'misteriousRelation')); 40 | 41 | $relation = WFormRelation::getInstance($model, 'hasOne', $options); 42 | 43 | $this->assertAttributeNotEmpty('name', $relation); 44 | $this->assertAttributeNotEmpty('info', $relation); 45 | $this->assertAttributeNotEmpty('type', $relation); 46 | $this->assertAttributeNotEmpty('model', $relation); 47 | } 48 | 49 | /** 50 | * @covers WFormRelation::setOption 51 | */ 52 | public function testSetOptions() 53 | { 54 | $relation = new WFormRelation(); 55 | 56 | $relation->setOptions(array( 57 | 'required' => true, 58 | 'unsetInvalid' => true, 59 | 'name' => 'someName', // shouldn't affect 60 | 'info' => 'someInfo', // shouldn't affect 61 | 'type' => 'someType', // shouldn't affect 62 | 'model' => 'someModel', // shouldn't affect 63 | )); 64 | 65 | $this->assertTrue($relation->required); 66 | $this->assertTrue($relation->unsetInvalid); 67 | 68 | $this->assertAttributeEmpty('name', $relation); 69 | $this->assertAttributeEmpty('info', $relation); 70 | $this->assertAttributeEmpty('type', $relation); 71 | $this->assertAttributeEmpty('model', $relation); 72 | 73 | } 74 | 75 | /** 76 | * @covers WFormRelation::getRelationInfo 77 | */ 78 | public function testGetRelationInfo() 79 | { 80 | $model = $this->getModelMock(); 81 | 82 | $this->assertNotNull(WFormRelation::getRelationInfo($model, 'hasOne')); 83 | $this->assertNotNull(WFormRelation::getRelationInfo($model, 'hasMany')); 84 | $this->assertNotNull(WFormRelation::getRelationInfo($model, 'manyMany')); 85 | $this->assertNotNull(WFormRelation::getRelationInfo($model, 'belongsTo')); 86 | $this->assertNotNull(WFormRelation::getRelationInfo($model, 'stat')); 87 | $this->assertNull(WFormRelation::getRelationInfo($model, 'misteriousRelation')); 88 | } 89 | 90 | private function getModelMock() { 91 | $model = $this->getMockBuilder('CActiveRecord') 92 | ->disableOriginalConstructor() 93 | ->getMock(); 94 | 95 | $model->expects($this->any()) 96 | ->method('relations') 97 | ->will($this->returnValue(array( 98 | 'hasOne' => array(CActiveRecord::HAS_ONE, 'SomeModel', 'key'), 99 | 'hasMany' => array(CActiveRecord::HAS_MANY, 'SomeModel', 'key'), 100 | 'manyMany' => array(CActiveRecord::MANY_MANY, 'SomeModel', 'key'), 101 | 'belongsTo' => array(CActiveRecord::BELONGS_TO, 'SomeModel', 'key'), 102 | 'stat' => array(CActiveRecord::STAT, 'SomeModel', 'key'), 103 | ))); 104 | 105 | return $model; 106 | } 107 | } -------------------------------------------------------------------------------- /examples/product/README.md: -------------------------------------------------------------------------------- 1 | [Example source code](https://github.com/weavora/wform/tree/master/examples/product) 2 | 3 | # Setup 4 | 5 | 1. Copy the sample source to your application 6 | 2. Execute db dump from products.sql 7 | 3. Update imports in main.php to the following: 8 | 9 | ```php 10 | 'import'=>array( 11 | 'application.models.*', 12 | 'application.models.forms.*', 13 | 'application.components.*', 14 | 'ext.wform.*', 15 | ), 16 | ``` 17 | 4. Now you have access to the sample controller: 18 | 19 | - Product List: http://yourhost.local/index.php?r=/product 20 | - Create a Product: http://yourhost.local/index.php?r=/product/add 21 | 22 | # Form Layout 23 | 24 | Here is what you should see: 25 | 26 | ![Form Layout](http://i.imgur.com/F3wRZ.png) 27 | 28 | # DB Diagram 29 | 30 | ![DB Structure](http://i.imgur.com/A8c7W.png) 31 | 32 | # ProductController 33 | 34 | ```php 35 | forward('edit'); 41 | } 42 | 43 | public function actionEdit($id = null) 44 | { 45 | $productForm = $id ? ProductForm::model()->findByPk($id) : new ProductForm(); 46 | 47 | if (Yii::app()->request->getPost('ProductForm')) { 48 | $productForm ->attributes = Yii::app()->request->getPost('ProductForm'); 49 | if ($productForm ->save()) { 50 | $this->redirect('/product/index'); 51 | } 52 | } 53 | $this->render('edit', array( 54 | 'product' => $productForm , 55 | 'categories' => Category::model()->findAll(), 56 | 'tags' => Tag::model()->findAll() 57 | )); 58 | } 59 | ... 60 | } 61 | ``` 62 | 63 | # ProductForm 64 | 65 | ```php 66 | array(self::HAS_MANY, 'AttachmentForm', 'object_id', 'condition' => 'images.object_type=:object_type', 'params' => array('object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE)), 78 | 'certificate' => array(self::HAS_ONE, 'CertificateForm', 'product_id'), 79 | )); 80 | } 81 | 82 | 83 | public function behaviors() 84 | { 85 | return array_merge( 86 | parent::behaviors(), 87 | array( 88 | 'wform' => array( 89 | 'class' => 'ext.wform.WFormBehavior', 90 | 'relations' => array( 91 | 'category' => array('unsetInvalid' => true, 'required' => false), 92 | 'tags' => array('required' => false), 93 | 'images', 94 | 'certificate', 95 | 'description', 96 | ), 97 | ), 98 | ) 99 | ); 100 | } 101 | } 102 | ``` 103 | 104 | # AttachmentForm 105 | 106 | ```php 107 | 108 | object_type = $type; 131 | return $attachmentForm; 132 | } 133 | 134 | public function beforeValidate() 135 | { 136 | if ($this->file instanceof CUploadedFile) { 137 | // save to tmp folder 138 | $tempFile = new WTempFile(Yii::app()->runtimePath); 139 | 140 | if ($this->file->saveAs($tempFile->getPath())) { 141 | $this->tempFile = $tempFile->getFile(); 142 | 143 | // setup proper file_origin 144 | $this->file_origin = $this->file->getName(); 145 | } 146 | } 147 | return true; 148 | } 149 | 150 | public function saveUploadedFile() 151 | { 152 | if (empty($this->file_origin)) { 153 | if (!$this->isNewRecord) 154 | $this->delete(); 155 | return false; 156 | } 157 | 158 | if (empty($this->tempFile)) { 159 | return false; 160 | } 161 | 162 | $tempFile = new WTempFile(Yii::app()->runtimePath); 163 | $tempFile->setFile($this->tempFile); 164 | 165 | if (!$tempFile->isValid()) { 166 | return false; 167 | } 168 | 169 | $attachmentDirectory = Yii::app()->runtimePath . '/' . $this->object_type . '/'; 170 | 171 | if (!is_dir($attachmentDirectory)) { 172 | mkdir($attachmentDirectory); 173 | } 174 | 175 | $fileName = $this->id . '.' . pathinfo($this->file_origin, PATHINFO_EXTENSION); 176 | 177 | 178 | if ($tempFile->saveAs($attachmentDirectory . $fileName)) { 179 | $this->file = $fileName; 180 | $this->isNewRecord = false; 181 | $this->tempFile = null; 182 | $this->save(false); 183 | } 184 | 185 | $this->tempFile = null; 186 | 187 | return false; 188 | } 189 | 190 | public function afterSave() 191 | { 192 | $this->saveUploadedFile(); 193 | return parent::afterSave(); 194 | } 195 | } 196 | ``` 197 | -------------------------------------------------------------------------------- /tests/unit/WFormBehaviorTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('PDO and SQLite extensions are required.'); 18 | 19 | $this->_connection = new CDbConnection('sqlite::memory:'); 20 | $this->_connection->active = true; 21 | $this->_connection->pdoInstance->exec(file_get_contents(dirname(__FILE__) . '/../fixtures/data/sqlite.sql')); 22 | CActiveRecord::$db = $this->_connection; 23 | } 24 | 25 | 26 | protected function tearDown() 27 | { 28 | $this->_connection->active = false; 29 | } 30 | 31 | /** 32 | * @covers {className}::{origMethodName} 33 | * @todo Implement testEvents(). 34 | */ 35 | public function testEvents() 36 | { 37 | // Remove the following lines when you implement this test. 38 | $this->markTestIncomplete( 39 | 'This test has not been implemented yet.' 40 | ); 41 | } 42 | 43 | /** 44 | * @covers {className}::{origMethodName} 45 | * @todo Implement testAfterConstruct(). 46 | */ 47 | public function testAfterConstruct() 48 | { 49 | // Remove the following lines when you implement this test. 50 | $this->markTestIncomplete( 51 | 'This test has not been implemented yet.' 52 | ); 53 | } 54 | 55 | /** 56 | * @covers {className}::{origMethodName} 57 | * @todo Implement testAfterFind(). 58 | */ 59 | public function testAfterFind() 60 | { 61 | // Remove the following lines when you implement this test. 62 | $this->markTestIncomplete( 63 | 'This test has not been implemented yet.' 64 | ); 65 | } 66 | 67 | /** 68 | * @covers {className}::{origMethodName} 69 | * @todo Implement testUnsafeAttribute(). 70 | */ 71 | public function testUnsafeAttribute() 72 | { 73 | // Remove the following lines when you implement this test. 74 | $this->markTestIncomplete( 75 | 'This test has not been implemented yet.' 76 | ); 77 | } 78 | 79 | /** 80 | * @covers {className}::{origMethodName} 81 | * @todo Implement testBeforeValidate(). 82 | */ 83 | public function testBeforeValidate() 84 | { 85 | // Remove the following lines when you implement this test. 86 | $this->markTestIncomplete( 87 | 'This test has not been implemented yet.' 88 | ); 89 | } 90 | 91 | /** 92 | * @covers {className}::{origMethodName} 93 | * @todo Implement testAfterValidate(). 94 | */ 95 | public function testAfterValidate() 96 | { 97 | // Remove the following lines when you implement this test. 98 | $this->markTestIncomplete( 99 | 'This test has not been implemented yet.' 100 | ); 101 | } 102 | 103 | /** 104 | * @covers {className}::{origMethodName} 105 | * @todo Implement testBeforeSave(). 106 | */ 107 | public function testBeforeSave() 108 | { 109 | // Remove the following lines when you implement this test. 110 | $this->markTestIncomplete( 111 | 'This test has not been implemented yet.' 112 | ); 113 | } 114 | 115 | /** 116 | * @covers {className}::{origMethodName} 117 | * @todo Implement testAfterSave(). 118 | */ 119 | public function testAfterSave() 120 | { 121 | // Remove the following lines when you implement this test. 122 | $this->markTestIncomplete( 123 | 'This test has not been implemented yet.' 124 | ); 125 | } 126 | 127 | /** 128 | * @covers {className}::{origMethodName} 129 | * @todo Implement testFindRelationByPath(). 130 | */ 131 | public function testFindRelationByPath() 132 | { 133 | // Remove the following lines when you implement this test. 134 | $this->markTestIncomplete( 135 | 'This test has not been implemented yet.' 136 | ); 137 | } 138 | 139 | /** 140 | * @covers {className}::{origMethodName} 141 | * @todo Implement testFindPathAttribute(). 142 | */ 143 | public function testFindPathAttribute() 144 | { 145 | // Remove the following lines when you implement this test. 146 | $this->markTestIncomplete( 147 | 'This test has not been implemented yet.' 148 | ); 149 | } 150 | 151 | /** 152 | * @covers WFormBehavior::_buildRelatedModel 153 | */ 154 | public function testBuildRelatedModel() 155 | { 156 | 157 | $behavior = new WFormBehavior(); 158 | $this->assertAttributeSame(null, 'relations', $behavior); 159 | $this->assertAttributeCount(0, 'relatedModels', $behavior); 160 | 161 | $method = new ReflectionMethod($behavior, '_buildRelatedModel'); 162 | $method->setAccessible(true); 163 | 164 | $model = $this->getMockBuilder('CActiveRecord') 165 | ->disableOriginalConstructor() 166 | ->getMock(); 167 | 168 | $model->expects($this->any()) 169 | ->method('relations') 170 | ->will($this->returnValue(array( 171 | 'hasOne' => array(CActiveRecord::HAS_ONE, 'SomeModel', 'key'), 172 | 'hasMany' => array(CActiveRecord::HAS_MANY, 'SomeModel', 'key'), 173 | 'manyMany' => array(CActiveRecord::MANY_MANY, 'SomeModel', 'key'), 174 | 'belongsTo' => array(CActiveRecord::BELONGS_TO, 'SomeModel', 'key'), 175 | 'stat' => array(CActiveRecord::STAT, 'SomeModel', 'key'), 176 | ))); 177 | 178 | $method->invokeArgs($behavior, array($model)); 179 | 180 | $this->assertAttributeCount(4, 'relations', $behavior); 181 | $this->assertAttributeCount(4, 'relatedModels', $behavior); 182 | 183 | } 184 | } -------------------------------------------------------------------------------- /WFormBehavior.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class WFormBehavior extends CActiveRecordBehavior { 9 | 10 | /** 11 | * @var array what relations we should save 12 | */ 13 | public $relations = null; 14 | 15 | /** 16 | * @var array scenarios to behavior will be applied 17 | */ 18 | public $scenarios = array('*'); 19 | 20 | protected $relatedModels = array(); 21 | 22 | protected $deleteQuery = array(); 23 | 24 | // relation was specified into CActiveRecord::with(); 25 | // finder and populated record are different AR instances that's why they should be static :( 26 | // could potentially cause an issue with multi threading 27 | protected static $preloadedRelations = array(); 28 | 29 | /** 30 | * Extend standard AR behavior events 31 | * 32 | * @return array 33 | */ 34 | public function events() { 35 | return array_merge(parent::events(), array( 36 | // @todo any ideas how to prevent using custom event for that ? Maybe create attributes dynamic for relations? 37 | 'onUnsafeAttribute' => 'unsafeAttribute', 38 | )); 39 | } 40 | 41 | /** 42 | * Initialize related models 43 | * 44 | * @param $event 45 | * @return void 46 | */ 47 | public function afterConstruct($event) { 48 | $this->_buildRelatedModel($event->sender); 49 | } 50 | 51 | /** 52 | * Cache preloaded relation by CActiveRecord::with() method 53 | * 54 | * @param $event 55 | * @return void 56 | */ 57 | public function beforeFind($event) { 58 | $model = $event->sender; 59 | self::$preloadedRelations = array(); 60 | foreach((array) $model->getDbCriteria()->with as $key => $value) { 61 | self::$preloadedRelations[] = is_numeric($key) ? $value : $key; 62 | } 63 | } 64 | 65 | /** 66 | * Rebuild related models 67 | * 68 | * @param $event 69 | * @return void 70 | */ 71 | public function afterFind($event) { 72 | $this->_buildRelatedModel($event->sender); 73 | } 74 | 75 | /** 76 | * Set related models attributes 77 | * 78 | * @param $event 79 | * @return void 80 | */ 81 | public function unsafeAttribute($event) { 82 | $relation = $event->params['name']; 83 | if (isset($this->relatedModels[$relation])) { 84 | $this->relatedModels[$relation]->setAttributes($event->params['value']); 85 | } 86 | 87 | } 88 | 89 | /** 90 | * Handle file inputs 91 | * 92 | * @param $event 93 | */ 94 | public function beforeValidate($event) { 95 | $model = $event->sender; 96 | 97 | // create CUploadedFile for all file inputs 98 | $files = new WFileIterator($model); 99 | foreach($files as $path => $file) { 100 | $relation = $this->findRelationByPath($model, $path); 101 | $attribute = $this->findPathAttribute($path); 102 | if ($relation) { 103 | $relation->setAttribute($attribute, $file); 104 | } 105 | } 106 | } 107 | 108 | /** 109 | * Validate related models 110 | * 111 | * @param $event 112 | * @return void 113 | */ 114 | public function afterValidate($event) { 115 | $model = $event->sender; 116 | foreach ($this->relatedModels as $relatedModel) { 117 | if (!$relatedModel->validate()) { 118 | $model->addError($relatedModel->name, $relatedModel->name . ' is not valid'); 119 | } 120 | } 121 | } 122 | 123 | /** 124 | * Save related models that affect parent models 125 | * 126 | * @param $event 127 | * @return void 128 | */ 129 | public function beforeSave($event) { 130 | foreach ($this->relatedModels as $relatedModel) { 131 | if (in_array($relatedModel->type, array(CActiveRecord::BELONGS_TO))) { 132 | if (!$relatedModel->save()) 133 | $event->isValid = false; 134 | 135 | $this->deleteQuery[] = $relatedModel; 136 | } 137 | } 138 | } 139 | 140 | /** 141 | * Save related models that depend on the parent model 142 | * 143 | * @param $event 144 | * @return void 145 | */ 146 | public function afterSave($event) { 147 | foreach ($this->relatedModels as $relatedModel) { 148 | if (in_array($relatedModel->type, array(CActiveRecord::HAS_ONE, CActiveRecord::HAS_MANY, CActiveRecord::MANY_MANY))) { 149 | $relatedModel->save(); 150 | 151 | $this->deleteQuery[] = $relatedModel; 152 | } 153 | } 154 | 155 | foreach($this->deleteQuery as $relation) { 156 | $relation->lazyDelete(); 157 | } 158 | } 159 | 160 | /** 161 | * Delete related models that depend on the parent model 162 | * 163 | * @param $event 164 | * @return void 165 | */ 166 | public function afterDelete($event) { 167 | foreach ($this->relatedModels as $relatedModel) { 168 | if (in_array($relatedModel->type, array(CActiveRecord::HAS_ONE, CActiveRecord::HAS_MANY, CActiveRecord::MANY_MANY))) { 169 | $relatedModel->delete(); 170 | } 171 | } 172 | } 173 | 174 | /** 175 | * Find related model by path (e.g. categories.0.name) 176 | * 177 | * @param $parentModel parent model 178 | * @param $path path 179 | * @return CActiveRecord 180 | */ 181 | public function findRelationByPath($parentModel, $path) { 182 | $model = $parentModel; 183 | $pathPortions = explode(".", trim($path, ".")); 184 | if (count($pathPortions)) { 185 | $attribute = array_pop($pathPortions); 186 | } 187 | 188 | foreach($pathPortions as $portion) { 189 | if (empty($model[$portion])) 190 | return null; 191 | $model = $model[$portion]; 192 | } 193 | return $model; 194 | } 195 | 196 | /** 197 | * Find an attribute name in the path (e.g. categories.0.name) 198 | * 199 | * @param $path path 200 | * @return string attribute name 201 | */ 202 | public function findPathAttribute($path) { 203 | $pathPortions = explode(".", trim($path, ".")); 204 | return count($pathPortions) ? end($pathPortions) : null; 205 | } 206 | 207 | /** 208 | * Rebuild related models 209 | * 210 | * @param $parentModel 211 | * @return void 212 | */ 213 | protected function _buildRelatedModel($parentModel) { 214 | $this->relatedModels = array(); 215 | if (is_null($this->relations)) { 216 | $this->relations = array_keys($parentModel->relations()); 217 | } 218 | foreach ($this->relations as $index => $options) { 219 | $relation = $index; 220 | 221 | if (is_numeric($index)) { 222 | $relation = $options; 223 | $options = array(); 224 | } 225 | 226 | if (($relationModel = WFormRelation::getInstance($parentModel, $relation, $options)) !== null) { 227 | $this->relatedModels[$relation] = $relationModel; 228 | $this->relatedModels[$relation]->setPreloaded(in_array($relation, self::$preloadedRelations)); 229 | } else { 230 | unset($this->relations[$index]); 231 | } 232 | 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /tests/unit/WFormTest.php: -------------------------------------------------------------------------------- 1 | object = new WForm; 21 | } 22 | 23 | /** 24 | * Tears down the fixture, for example, closes a network connection. 25 | * This method is called after a test is executed. 26 | */ 27 | protected function tearDown() 28 | { 29 | } 30 | 31 | /** 32 | * @covers {className}::{origMethodName} 33 | * @todo Implement testLabel(). 34 | */ 35 | public function testLabel() 36 | { 37 | // Remove the following lines when you implement this test. 38 | $this->markTestIncomplete( 39 | 'This test has not been implemented yet.' 40 | ); 41 | } 42 | 43 | /** 44 | * @covers {className}::{origMethodName} 45 | * @todo Implement testLabelEx(). 46 | */ 47 | public function testLabelEx() 48 | { 49 | // Remove the following lines when you implement this test. 50 | $this->markTestIncomplete( 51 | 'This test has not been implemented yet.' 52 | ); 53 | } 54 | 55 | /** 56 | * @covers {className}::{origMethodName} 57 | * @todo Implement testTextField(). 58 | */ 59 | public function testTextField() 60 | { 61 | // Remove the following lines when you implement this test. 62 | $this->markTestIncomplete( 63 | 'This test has not been implemented yet.' 64 | ); 65 | } 66 | 67 | /** 68 | * @covers {className}::{origMethodName} 69 | * @todo Implement testHiddenField(). 70 | */ 71 | public function testHiddenField() 72 | { 73 | // Remove the following lines when you implement this test. 74 | $this->markTestIncomplete( 75 | 'This test has not been implemented yet.' 76 | ); 77 | } 78 | 79 | /** 80 | * @covers {className}::{origMethodName} 81 | * @todo Implement testPasswordField(). 82 | */ 83 | public function testPasswordField() 84 | { 85 | // Remove the following lines when you implement this test. 86 | $this->markTestIncomplete( 87 | 'This test has not been implemented yet.' 88 | ); 89 | } 90 | 91 | /** 92 | * @covers {className}::{origMethodName} 93 | * @todo Implement testTextArea(). 94 | */ 95 | public function testTextArea() 96 | { 97 | // Remove the following lines when you implement this test. 98 | $this->markTestIncomplete( 99 | 'This test has not been implemented yet.' 100 | ); 101 | } 102 | 103 | /** 104 | * @covers {className}::{origMethodName} 105 | * @todo Implement testFileField(). 106 | */ 107 | public function testFileField() 108 | { 109 | // Remove the following lines when you implement this test. 110 | $this->markTestIncomplete( 111 | 'This test has not been implemented yet.' 112 | ); 113 | } 114 | 115 | /** 116 | * @covers {className}::{origMethodName} 117 | * @todo Implement testRadioButton(). 118 | */ 119 | public function testRadioButton() 120 | { 121 | // Remove the following lines when you implement this test. 122 | $this->markTestIncomplete( 123 | 'This test has not been implemented yet.' 124 | ); 125 | } 126 | 127 | /** 128 | * @covers {className}::{origMethodName} 129 | * @todo Implement testCheckBox(). 130 | */ 131 | public function testCheckBox() 132 | { 133 | // Remove the following lines when you implement this test. 134 | $this->markTestIncomplete( 135 | 'This test has not been implemented yet.' 136 | ); 137 | } 138 | 139 | /** 140 | * @covers {className}::{origMethodName} 141 | * @todo Implement testDropDownList(). 142 | */ 143 | public function testDropDownList() 144 | { 145 | // Remove the following lines when you implement this test. 146 | $this->markTestIncomplete( 147 | 'This test has not been implemented yet.' 148 | ); 149 | } 150 | 151 | /** 152 | * @covers {className}::{origMethodName} 153 | * @todo Implement testListBox(). 154 | */ 155 | public function testListBox() 156 | { 157 | // Remove the following lines when you implement this test. 158 | $this->markTestIncomplete( 159 | 'This test has not been implemented yet.' 160 | ); 161 | } 162 | 163 | /** 164 | * @covers {className}::{origMethodName} 165 | * @todo Implement testCheckBoxList(). 166 | */ 167 | public function testCheckBoxList() 168 | { 169 | // Remove the following lines when you implement this test. 170 | $this->markTestIncomplete( 171 | 'This test has not been implemented yet.' 172 | ); 173 | } 174 | 175 | /** 176 | * @covers {className}::{origMethodName} 177 | * @todo Implement testRadioButtonList(). 178 | */ 179 | public function testRadioButtonList() 180 | { 181 | // Remove the following lines when you implement this test. 182 | $this->markTestIncomplete( 183 | 'This test has not been implemented yet.' 184 | ); 185 | } 186 | 187 | /** 188 | * @covers {className}::{origMethodName} 189 | * @todo Implement testError(). 190 | */ 191 | public function testError() 192 | { 193 | // Remove the following lines when you implement this test. 194 | $this->markTestIncomplete( 195 | 'This test has not been implemented yet.' 196 | ); 197 | } 198 | 199 | /** 200 | * @covers {className}::{origMethodName} 201 | * @todo Implement testResolveModel(). 202 | */ 203 | public function testResolveModel() 204 | { 205 | // Remove the following lines when you implement this test. 206 | $this->markTestIncomplete( 207 | 'This test has not been implemented yet.' 208 | ); 209 | } 210 | 211 | /** 212 | * @covers {className}::{origMethodName} 213 | * @todo Implement testResolveName(). 214 | */ 215 | public function testResolveName() 216 | { 217 | // Remove the following lines when you implement this test. 218 | $this->markTestIncomplete( 219 | 'This test has not been implemented yet.' 220 | ); 221 | } 222 | 223 | /** 224 | * @covers {className}::{origMethodName} 225 | * @todo Implement testResolveAttribute(). 226 | */ 227 | public function testResolveAttribute() 228 | { 229 | // Remove the following lines when you implement this test. 230 | $this->markTestIncomplete( 231 | 'This test has not been implemented yet.' 232 | ); 233 | } 234 | 235 | /** 236 | * @covers {className}::{origMethodName} 237 | * @todo Implement testResolveArgs(). 238 | */ 239 | public function testResolveArgs() 240 | { 241 | // Remove the following lines when you implement this test. 242 | $this->markTestIncomplete( 243 | 'This test has not been implemented yet.' 244 | ); 245 | } 246 | 247 | /** 248 | * @covers {className}::{origMethodName} 249 | * @todo Implement testResolveLabel(). 250 | */ 251 | public function testResolveLabel() 252 | { 253 | // Remove the following lines when you implement this test. 254 | $this->markTestIncomplete( 255 | 'This test has not been implemented yet.' 256 | ); 257 | } 258 | } 259 | ?> 260 | -------------------------------------------------------------------------------- /examples/product/views/product/edit.php: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 18 | isNewRecord):?> 19 |

Add Product

20 | 21 |

Edit Product

22 | 23 | 24 |

Back to products list

25 | 26 |
27 | beginWidget('WForm', array('htmlOptions' => array('enctype'=>'multipart/form-data'))); ?> 28 | hasErrors()):?> 29 |
    30 | getErrors() as $errors):?> 31 | 32 | 33 |
  • 34 | 35 | 36 |
  • 37 | 38 | 39 |
40 | 41 |
42 |
43 | Product 44 |
45 | labelEx($product, 'name'); ?> 46 | textField($product, 'name'); ?> 47 | error($product, 'name'); ?> 48 |
49 | 50 |
51 | labelEx($product, 'price'); ?> 52 | textField($product, 'price'); ?> 53 | error($product, 'price'); ?> 54 |
55 |
56 |
57 | Description 58 |
59 | labelEx($product, 'description.color'); ?> 60 | textField($product, 'description.color'); ?> 61 | error($product, 'description.color'); ?> 62 |
63 |
64 | labelEx($product, 'description.size'); ?> 65 | textField($product, 'description.size'); ?> 66 | error($product, 'description.size'); ?> 67 |
68 |
69 |
70 | Certificate 71 |
72 | labelEx($product, 'certificate.name'); ?> 73 | textField($product, 'certificate.name'); ?> 74 | error($product, 'certificate.name'); ?> 75 | certificate->image->file_origin)): ?> 76 | hiddenField($product, "certificate.image.object_type") ?> 77 | hiddenField($product, "certificate.image.id") ?> 78 | hiddenField($product, "certificate.image.file") ?> 79 | hiddenField($product, "certificate.image.file_origin") ?> 80 | hiddenField($product, "certificate.image.tempFile") ?> 81 | certificate->image->file_origin, $product->certificate->image->fileUrl) ?> 82 | Delete 83 | 84 | hiddenField($product, 'certificate.image.object_type', array('value' => Attachment::OBJECT_TYPE_CERTIFICATE)) ?> 85 | fileField($product, 'certificate.image.file') ?> 86 | 87 |
88 |
89 |
90 |
91 |
92 | Category 93 |
94 | labelEx($product, 'category_id'); ?> 95 | dropDownList($product, 'category_id', 96 | CHtml::listData($categories, 'id', 'name') + array('0' => 'new category') 97 | , array('empty' => 'none')) ?> 98 | error($product, 'category_id'); ?> 99 | 100 | 105 | 106 |
107 |
108 |
109 | Tags 110 |
111 | 112 |
    113 | $tag): ?> 114 |
  • 115 | 119 |
  • 120 | 121 |
122 | 123 | 124 |
    125 | tags): ?> 126 | tags as $index => $tag): ?> 127 | isNewRecord): ?> 128 |
  • 129 | textField($product, "tags.$index.name") ?> 130 | error($product, "tags.$index.name") ?> 131 | delete 132 |
  • 133 | 134 | 135 | 136 | 140 |
141 | add 142 |
143 |
144 |
145 | Images 146 |
    147 | images): ?> 148 | images as $index => $image): ?> 149 | file_origin)):?> 150 |
  • 151 | hiddenField($product, "images.{$index}.object_type") ?> 152 | hiddenField($product, "images.{$index}.id") ?> 153 | hiddenField($product, "images.{$index}.file") ?> 154 | hiddenField($product, "images.{$index}.file_origin") ?> 155 | hiddenField($product, "images.{$index}.tempFile") ?> 156 | file_origin, $image->fileUrl) ?> 157 | Delete 158 |
  • 159 | 160 | 161 | 162 | 167 |
168 | add 169 |
170 |
171 |
172 |
173 | 174 |
175 | 176 | endWidget(); ?> 177 |
178 | 179 | 216 | -------------------------------------------------------------------------------- /tests/unit/WFormRelationBelongsToTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('PDO and SQLite extensions are required.'); 16 | 17 | $this->_connection = new CDbConnection('sqlite::memory:'); 18 | $this->_connection->active = true; 19 | $this->_connection->pdoInstance->exec(file_get_contents(dirname(__FILE__).'/../fixtures/data/sqlite.sql')); 20 | CActiveRecord::$db = $this->_connection; 21 | } 22 | 23 | 24 | protected function tearDown() 25 | { 26 | $this->_connection->active=false; 27 | } 28 | 29 | /** 30 | * @covers WFormRelationHasMany::setAttributes 31 | */ 32 | public function testSetAttributes() 33 | { 34 | $product = $this->_getProductWithRelation(); 35 | 36 | $product->attributes = array( 37 | 'name' => 'product_name', 38 | 'category' => array( 39 | 'name' => '12', 40 | ), 41 | ); 42 | 43 | $this->assertNotEmpty($product->category); 44 | $this->assertEquals('12', $product->category->name); 45 | 46 | $product->attributes = array( 47 | 'name' => 'product_name', 48 | ); 49 | $this->assertNotEmpty($product->category); 50 | 51 | $product->attributes = array( 52 | 'name' => 'product_name', 53 | 'category' => array(), 54 | ); 55 | $this->assertTrue($product->category->isNewRecord); 56 | 57 | $product = $this->_getProductWithRelation(1); 58 | $this->assertNotEmpty($product->category); 59 | 60 | $product->attributes = array( 61 | 'name' => 'product_name', 62 | 'category' => array( 63 | 'id' => 1, 64 | ), 65 | ); 66 | 67 | $this->assertNotEmpty($product->category); 68 | // check if exists category just updated 69 | $this->assertEquals('Auto', $product->category->name); 70 | } 71 | 72 | /** 73 | * @covers WFormRelationHasOne::save 74 | */ 75 | public function testSaveIfNotSet() 76 | { 77 | $product = Product::model() ; 78 | $product->attachBehavior('wform', array( 79 | 'class' => 'WFormBehavior', 80 | 'relations' => array( 81 | 'category' => array('required' => false), 82 | ), 83 | )); 84 | $product = $product->findByPk(1); 85 | $product->attachBehavior('wform', array( 86 | 'class' => 'WFormBehavior', 87 | 'relations' => array( 88 | 'category' => array('required' => false), 89 | ), 90 | )); 91 | $product->afterFind(new CEvent($product)); 92 | 93 | 94 | $this->assertEquals(true, $product->save()); 95 | $this->assertNotEmpty($product->category); 96 | 97 | $product = Product::model(); 98 | $product->attachBehavior('wform', array( 99 | 'class' => 'WFormBehavior', 100 | 'relations' => array( 101 | 'category' => array('required' => false), 102 | ), 103 | )); 104 | $product = $product->with('category')->findByPk(1); 105 | $product->attachBehavior('wform', array( 106 | 'class' => 'WFormBehavior', 107 | 'relations' => array( 108 | 'category' => array('required' => false), 109 | ), 110 | )); 111 | $product->afterFind(new CEvent($product)); 112 | 113 | $this->assertEquals(true, $product->save()); 114 | 115 | $this->assertNotEmpty($product->category); 116 | } 117 | 118 | /** 119 | * @covers WFormRelationHasMany::validate 120 | * @dataProvider validateProvider 121 | */ 122 | public function testValidate($expectedResult, $relationOptions, $relationAttribute, $onFailComment = "") 123 | { 124 | $product = $this->_getProductWithRelation(null, $relationOptions); 125 | 126 | $product->attributes = array( 127 | 'name' => 'product_name', 128 | 'category' => $relationAttribute, 129 | ); 130 | 131 | $this->assertEquals($expectedResult, $product->validate(), $onFailComment); 132 | } 133 | 134 | public function validateProvider() 135 | { 136 | return array( 137 | // required=true 138 | array( 139 | 'result' => true, 140 | 'relationOptions' => array('required' => true), 141 | 'relationAttribute' => array( 142 | 'name' => '12x12' 143 | ), 144 | 'comment' => 'required, 1 valid related object' 145 | ), 146 | array( 147 | 'result' => false, 148 | 'relationOptions' => array('required' => true), 149 | 'relationAttribute' => array( 150 | 'name' => '' 151 | ), 152 | 'comment' => 'required, 1 invalid related object' 153 | ), 154 | array( 155 | 'result' => false, 156 | 'relationOptions' => array('required' => true), 157 | 'relationAttribute' => array(), 158 | 'comment' => 'required, 0 related objects' 159 | ), 160 | // required=false 161 | array( 162 | 'result' => true, 163 | 'relationOptions' => array('required' => false), 164 | 'relationAttribute' => array( 165 | 'name' => '12x12' 166 | ), 167 | 'comment' => 'not required, 1 valid related object' 168 | ), 169 | array( 170 | 'result' => false, 171 | 'relationOptions' => array('required' => false), 172 | 'relationAttribute' => array( 173 | 'name' => '' 174 | ), 175 | 'comment' => 'not required, 1 invalid related objects' 176 | ), 177 | array( 178 | 'result' => true, 179 | 'relationOptions' => array('required' => false), 180 | 'relationAttribute' => null, 181 | 'comment' => 'not required, 0 related objects' 182 | ), 183 | ); 184 | } 185 | 186 | /** 187 | * @covers WFormRelationHasMany::save 188 | * @dataProvider saveProvider 189 | */ 190 | public function testSave($expectedResult, $relationOptions, $relationAttribute, $onFailComment = "") 191 | { 192 | $product = $this->_getProductWithRelation(null, $relationOptions); 193 | 194 | $product->attributes = array( 195 | 'name' => 'product_name', 196 | 'category' => $relationAttribute, 197 | ); 198 | 199 | $this->assertEquals($expectedResult['saved'], $product->save(), $onFailComment); 200 | if ($expectedResult['relationsCount'] > 0) { 201 | $this->assertNotEmpty($product->category, $onFailComment); 202 | } else { 203 | $this->assertEmpty($product->category, $onFailComment); 204 | } 205 | 206 | } 207 | 208 | public function saveProvider() 209 | { 210 | return array( 211 | // required=true 212 | array( 213 | 'result' => array('saved' => true, 'relationsCount' => 1), 214 | 'relationOptions' => array('required' => true), 215 | 'relationAttribute' => array( 216 | 'name' => 'tag_name' 217 | ), 218 | 'comment' => 'required, 1 valid related object' 219 | ), 220 | array( 221 | 'result' => array('saved' => false, 'relationsCount' => 1), 222 | 'relationOptions' => array('required' => true), 223 | 'relationAttribute' => array( 224 | 'name' => '' 225 | ), 226 | 'comment' => 'required, 1 invalid related object' 227 | ), 228 | array( 229 | 'result' => array('saved' => false, 'relationsCount' => 1), 230 | 'relationOptions' => array('required' => true), 231 | 'relationAttribute' => null, 232 | 'comment' => 'required, 0 related objects' 233 | ), 234 | // required=false 235 | array( 236 | 'result' => array('saved' => true, 'relationsCount' => 1), 237 | 'relationOptions' => array('required' => false), 238 | 'relationAttribute' => array( 239 | 'name' => 'tag_name' 240 | ), 241 | 'comment' => 'not required, 1 valid related object' 242 | ), 243 | array( 244 | 'result' => array('saved' => false, 'relationsCount' => 1), 245 | 'relationOptions' => array('required' => false), 246 | 'relationAttribute' => array( 247 | 'name' => '' 248 | ), 249 | 'comment' => 'not required, 1 invalid related objects' 250 | ), 251 | array( 252 | 'result' => array('saved' => true, 'relationsCount' => 0), 253 | 'relationOptions' => array('required' => false), 254 | 'relationAttribute' => null, 255 | 'comment' => 'not required, 0 related objects' 256 | ), 257 | ); 258 | } 259 | 260 | /** 261 | * @covers WFormRelationHasMany::getRelatedModel 262 | */ 263 | public function testGetRelatedModel() 264 | { 265 | $product = $this->_getProductWithRelation(); 266 | $relation = WFormRelation::getInstance($product, 'category'); 267 | 268 | $this->assertEmpty($relation->getRelatedModel(false)); 269 | // $this->assertNotEmpty($relation->getRelatedModel(true)); 270 | 271 | $product->attributes = array( 272 | 'name' => 'product_name', 273 | 'category' => array( 274 | 'id' => 1, 275 | 'name' => '10' 276 | ), 277 | ); 278 | 279 | $this->assertNotEmpty($relation->getRelatedModel()); 280 | 281 | $product = $this->_getProductWithRelation(1); 282 | $relation = WFormRelation::getInstance($product, 'category'); 283 | 284 | $this->assertNotEmpty($relation->getRelatedModel()); 285 | } 286 | 287 | /** 288 | * @param null $id 289 | * @param array $relationOptions 290 | * @return Product 291 | */ 292 | protected function _getProductWithRelation($id = null, $relationOptions = array()) 293 | { 294 | $product = $id ? Product::model()->findByPk($id) : new Product(); 295 | $product->attachBehavior('wform', array( 296 | 'class' => 'WFormBehavior', 297 | 'relations' => array( 298 | 'category' => $relationOptions, 299 | ), 300 | )); 301 | $product->afterConstruct(new CEvent($product)); 302 | 303 | return $product; 304 | } 305 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Yii Composite Form Extension 2 | ========================== 3 | 4 | The extension that can greatly simplify processing of complex forms with multiple relations. 5 | 6 | [Weavora's](http://weavora.com) Git Repo - [https://github.com/weavora/wform](https://github.com/weavora/wform) 7 | 8 | **Features**: 9 | 10 | * Easy composite form processing 11 | * Fast configuration 12 | * Support of all standard relations: has_one, belongs_to, has_many and many_many 13 | 14 | Configuration 15 | ----- 16 | 17 | 1) Download and unpack the source into the protected/extensions/ folder. 18 | 19 | 2) Below you can see the config settings for import: 20 | 21 | ```php 22 | array( 27 | ... 28 | 'ext.wform.*', 29 | ), 30 | ... 31 | ); 32 | ``` 33 | 34 | 3) The extension requires changing CActiveRecord::onUnsafeAttribute. Here are a few options for that: 35 | 36 | a) Extend all your models/forms from WActiveRecord instead of CActiveRecord 37 | 38 | b) If you have already modified the class for active record, extend it from WActiveRecord or add the onUnsafeAttribute method: 39 | 40 | ```php 41 | $name, 'value' => $value)); 65 | $this->raiseEvent('onUnsafeAttribute', $event); 66 | return parent::onUnsafeAttribute($name, $value); 67 | } 68 | } 69 | ``` 70 | 71 | Usage 72 | ----- 73 | 74 | 1) Modify the model: define relations and attach behavior. 75 | You can also create a separate class for the form extended from your model. 76 | 77 | ```php 78 | array(self::HAS_ONE, 'HasOneModel', 'my_model_fk_into_related_model'), 85 | 'belongsToRelation' => array(self::BELONGS_TO, 'BelongsToModel', 'related_model_fk_into_my_model'), 86 | 'hasManyRelation' => array(self::HAS_MANY, 'HasManyModel', 'my_model_fk_into_related_model'), 87 | 'manyManyRelation' => array(self::MANY_MANY, 'ManyManyModel', 'linker(my_model_id,related_model_id)'), 88 | ); 89 | } 90 | ... 91 | public function behaviors() { 92 | return array( 93 | // attach wform behavior 94 | 'wform' => array( 95 | 'class' => 'ext.wform.WFormBehavior', 96 | // define relations which would be processed 97 | 'relations' => array('hasOneRelation', 'belongsToRelation', 'hasManyRelation', 'manyManyRelation'), 98 | ), 99 | // or you could allow to skip some relation saving if it was submitted empty 100 | 'wform' => array( 101 | 'class' => 'ext.wform.WFormBehavior', 102 | 'relations' => array( 103 | 'hasOneRelation' => array( 104 | 'required' => true, // declare that a relation item should be valid (default for HAS_ONE: false) 105 | 'cascadeDelete' => true, // declare if a relation item would be deleted during parent model deletion (default for HAS_ONE: true) 106 | ), 107 | 'belongsToRelation' => array( 108 | 'required' => true, // declare all relation items to be valid (default for BELONGS_TO: false) 109 | ), 110 | 'hasManyRelation' => array( 111 | 'required' => true, // declare all relation items to be valid (default for HAS_MANY: false) 112 | 'unsetInvalid' => true, // will unset invalid relation items during save or validate actions (default for HAS_MANY: false) 113 | 'cascadeDelete' => true, // declare if relation items would be deleted during parent model deletion (default for HAS_MANY: true) 114 | ), 115 | 'manyManyRelation' => array( 116 | 'required' => true, // declare all relation items to be valid (default for MANY_MANY: false) 117 | 'unsetInvalid' => true, // will unset invalid relation items during save or validate actions (default for MANY_MANY: false) 118 | 'cascadeDelete' => true, // declare if db rows with a relation item link to model would be deleted during parent model deletion (default for MANY_MANY: true) 119 | ), 120 | ), 121 | ), 122 | ); 123 | } 124 | ... 125 | } 126 | ``` 127 | 128 | 2) Create an action to process the form. 129 | 130 | ```php 131 | with('hasManyRelation','manyManyRelation')->findByPk($id) : new MyModel(); 138 | if(Yii::app()->request->isPostRequest) { 139 | $myModel->attributes = Yii::app()->request->getPost('MyModel'); 140 | if ($myModel->save()) { 141 | $this->redirect('some/page'); 142 | } 143 | } 144 | $this->render('edit', array( 145 | 'model' => $myModel 146 | )); 147 | } 148 | 149 | // delete the model with relation to a single line of code :) 150 | public function actionDelete($id) 151 | { 152 | $myModel = MyModel::model()->findByPk($id); 153 | if(!empty($myModel)) { 154 | $myModel->delete(); 155 | } 156 | $this->redirect('some/page'); 157 | } 158 | } 159 | ``` 160 | 161 | 3) Include js/jquery.multiplyforms.js jquery plugin into your layout 162 | 163 | 164 | 4) Define the form using WForm instead of CActiveForm 165 | 166 | ```php 167 | // protected/views/my/edit.php 168 | 169 |

isNewRecord ? "Create" : "Update " . $model->name);?>

170 | beginWidget('WForm'); ?> 171 | 172 | 173 |
174 | labelEx($model, 'name'); ?> 175 | textField($model, 'name'); ?> 176 | error($model, 'name'); ?> 177 |
178 | 179 | 180 | 181 | 182 |
183 | labelEx($model, 'hasOneRelation.name'); ?> 184 | textField($model, 'hasOneRelation.name'); ?> 185 | error($model, 'hasOneRelation.name'); ?> 186 |
187 | 188 | 189 |
190 | labelEx($model, 'belongsToRelation.name'); ?> 191 | textField($model, 'belongsToRelation.name'); ?> 192 | error($model, 'belongsToRelation.name'); ?> 193 |
194 | 195 | 196 |
197 | 198 | hasManyRelation): ?> 199 | hasManyRelation as $index => $item): ?> 200 |
201 | isNewRecord): ?> 202 | hiddenField($model, "hasManyRelation.$index.id"); ?> 203 | 204 | labelEx($model, "hasManyRelation.$index.text"); ?> 205 | textField($model, "hasManyRelation.$index.text"); ?> 206 | error($model, "hasManyRelation.$index.text"); ?> 207 | Delete 208 |
209 | 210 | 211 | 212 | 213 |
214 | labelEx($model, "hasManyRelation..text"); ?> 215 | textField($model, "hasManyRelation..text"); ?> 216 | error($model, "hasManyRelation..text"); ?> 217 | Delete 218 |
219 | 220 | Add more 221 |
222 | 223 | 224 |
225 | 226 | manyManyRelation): ?> 227 | manyManyRelation as $index => $item): ?> 228 |
229 | isNewRecord): ?> 230 | hiddenField($model, "manyManyRelation.$index.id"); ?> 231 | 232 | labelEx($model, "manyManyRelation.$index.note"); ?> 233 | textField($model, "manyManyRelation.$index.note"); ?> 234 | error($model, "manyManyRelation.$index.note"); ?> 235 | Delete 236 |
237 | 238 | 239 | 240 | 241 |
242 | labelEx($model, "manyManyRelation..note"); ?> 243 | textField($model, "manyManyRelation..note"); ?> 244 | error($model, "manyManyRelation..note"); ?> 245 | Delete 246 |
247 | 248 | Add more 249 |
250 | 251 |
252 | isNewRecord ? 'Create' : 'Save'); ?> 253 |
254 | 255 | endWidget(); ?> 256 | 257 | 284 | 285 | ``` 286 | 287 | Real Examples 288 | ----- 289 | 290 | [product form example](https://github.com/weavora/wform/wiki/Example:-Product-form) 291 | -------------------------------------------------------------------------------- /WForm.php: -------------------------------------------------------------------------------- 1 | 4 | * @link http://weavora.com 5 | * @copyright Copyright (c) 2011 Weavora LLC 6 | */ 7 | 8 | class WForm extends CActiveForm 9 | { 10 | /** 11 | * Renders an HTML label for a model attribute. 12 | * @param CModel $parentModel the parent data model 13 | * @param string $attributedPath the attribute or path to related model attribute 14 | * @param array $htmlOptions additional HTML attributes. 15 | * @return string the generated label tag 16 | */ 17 | public function label($parentModel, $attributedPath, $htmlOptions = array()) 18 | { 19 | list($model, $attribute, $htmlOptions) = self::resolveArgs($parentModel, $attributedPath, $htmlOptions); 20 | $htmlOptions['for'] = CHtml::getIdByName($htmlOptions['name']); 21 | if (!isset($htmlOptions['label']) && ($label = self::resolveLabel($parentModel, $attributedPath)) !== null) 22 | $htmlOptions['label'] = $label; 23 | return parent::label($model, $attribute, $htmlOptions); 24 | } 25 | 26 | /** 27 | * Renders an HTML label for a model attribute. 28 | * @param CModel $parentModel the parent data model 29 | * @param string $attributedPath the attribute or path to related model attribute 30 | * @param array $htmlOptions additional HTML attributes. 31 | * @return string the generated label tag 32 | */ 33 | public function labelEx($parentModel, $attributedPath, $htmlOptions = array()) 34 | { 35 | list($model, $attribute, $htmlOptions) = self::resolveArgs($parentModel, $attributedPath, $htmlOptions); 36 | $htmlOptions['for'] = CHtml::getIdByName($htmlOptions['name']); 37 | if (!isset($htmlOptions['label']) && ($label = self::resolveLabel($parentModel, $attributedPath)) !== null) 38 | $htmlOptions['label'] = $label; 39 | return parent::labelEx($model, $attribute, $htmlOptions); 40 | } 41 | 42 | /** 43 | * Renders a text field for a model attribute. 44 | * @param CModel $parentModel the parent data model 45 | * @param string $attributedPath the attribute or path to related model attribute 46 | * @param array $htmlOptions additional HTML attributes. 47 | * @return string the generated input field 48 | */ 49 | public function textField($parentModel, $attributedPath, $htmlOptions = array()) 50 | { 51 | list($model, $attribute, $htmlOptions) = self::resolveArgs($parentModel, $attributedPath, $htmlOptions); 52 | return parent::textField($model, $attribute, $htmlOptions); 53 | } 54 | 55 | /** 56 | * Renders a hidden field for a model attribute. 57 | * @param CModel $parentModel the parent data model 58 | * @param string $attributedPath the attribute or path to related model attribute 59 | * @param array $htmlOptions additional HTML attributes. 60 | * @return string the generated input field 61 | */ 62 | public function hiddenField($parentModel, $attributedPath, $htmlOptions = array()) 63 | { 64 | list($model, $attribute, $htmlOptions) = self::resolveArgs($parentModel, $attributedPath, $htmlOptions); 65 | return parent::hiddenField($model, $attribute, $htmlOptions); 66 | } 67 | 68 | /** 69 | * Renders a password field for a model attribute. 70 | * @param CModel $parentModel the parent data model 71 | * @param string $attributedPath the attribute or path to related model attribute 72 | * @param array $htmlOptions additional HTML attributes. 73 | * @return string the generated input field 74 | */ 75 | public function passwordField($parentModel, $attributedPath, $htmlOptions = array()) 76 | { 77 | list($model, $attribute, $htmlOptions) = self::resolveArgs($parentModel, $attributedPath, $htmlOptions); 78 | return parent::passwordField($model, $attribute, $htmlOptions); 79 | } 80 | 81 | /** 82 | * Renders a text area for a model attribute. 83 | * @param CModel $parentModel the parent data model 84 | * @param string $attributedPath the attribute or path to related model attribute 85 | * @param array $htmlOptions additional HTML attributes. 86 | * @return string the generated text area 87 | */ 88 | public function textArea($parentModel, $attributedPath, $htmlOptions = array()) 89 | { 90 | list($model, $attribute, $htmlOptions) = self::resolveArgs($parentModel, $attributedPath, $htmlOptions); 91 | return parent::textArea($model, $attribute, $htmlOptions); 92 | } 93 | 94 | /** 95 | * Renders a file field for a model attribute. 96 | * @param CModel $parentModel the parent data model 97 | * @param string $attributedPath the attribute or path to related model attribute 98 | * @param array $htmlOptions additional HTML attributes 99 | * @return string the generated input field 100 | */ 101 | public function fileField($parentModel, $attributedPath, $htmlOptions = array()) 102 | { 103 | list($model, $attribute, $htmlOptions) = self::resolveArgs($parentModel, $attributedPath, $htmlOptions); 104 | return parent::fileField($model, $attribute, $htmlOptions); 105 | } 106 | 107 | /** 108 | * Renders a radio button for a model attribute. 109 | * @param CModel $parentModel the parent data model 110 | * @param string $attributedPath the attribute or path to related model attribute 111 | * @param array $htmlOptions additional HTML attributes. 112 | * @return string the generated radio button 113 | */ 114 | public function radioButton($parentModel, $attributedPath, $htmlOptions = array()) 115 | { 116 | list($model, $attribute, $htmlOptions) = self::resolveArgs($parentModel, $attributedPath, $htmlOptions); 117 | return parent::radioButton($model, $attribute, $htmlOptions); 118 | } 119 | 120 | /** 121 | * Renders a checkbox for a model attribute. 122 | * @param CModel $parentModel the parent data model 123 | * @param string $attributedPath the attribute or path to related model attribute 124 | * @param array $htmlOptions additional HTML attributes. 125 | * @return string the generated check box 126 | */ 127 | public function checkBox($parentModel, $attributedPath, $htmlOptions = array()) 128 | { 129 | list($model, $attribute, $htmlOptions) = self::resolveArgs($parentModel, $attributedPath, $htmlOptions); 130 | return parent::checkBox($model, $attribute, $htmlOptions); 131 | } 132 | 133 | /** 134 | * Renders a dropdown list for a model attribute. 135 | * @param CModel $parentModel the parent data model 136 | * @param string $attributedPath the attribute or path to related model attribute 137 | * @param array $data data for generating the list options (value=>display) 138 | * @param array $htmlOptions additional HTML attributes. 139 | * @return string the generated drop down list 140 | */ 141 | public function dropDownList($parentModel, $attributedPath, $data, $htmlOptions = array()) 142 | { 143 | list($model, $attribute, $htmlOptions) = self::resolveArgs($parentModel, $attributedPath, $htmlOptions); 144 | return parent::dropDownList($model, $attribute, $data, $htmlOptions); 145 | } 146 | 147 | /** 148 | * Renders a list box for a model attribute. 149 | * @param CModel $parentModel the parent data model 150 | * @param string $attributedPath the attribute or path to related model attribute 151 | * @param array $data data for generating the list options (value=>display) 152 | * @param array $htmlOptions additional HTML attributes. 153 | * @return string the generated list box 154 | */ 155 | public function listBox($parentModel, $attributedPath, $data, $htmlOptions = array()) 156 | { 157 | list($model, $attribute, $htmlOptions) = self::resolveArgs($parentModel, $attributedPath, $htmlOptions); 158 | return parent::listBox($model, $attribute, $data, $htmlOptions); 159 | } 160 | 161 | /** 162 | * Renders a checkbox list for a model attribute. 163 | * @param CModel $parentModel the parent data model 164 | * @param string $attributedPath the attribute or path to related model attribute 165 | * @param array $data value-label pairs used to generate the check box list. 166 | * @param array $htmlOptions additional HTML options. 167 | * @return string the generated check box list 168 | */ 169 | public function checkBoxList($parentModel, $attributedPath, $data, $htmlOptions = array()) 170 | { 171 | list($model, $attribute, $htmlOptions) = self::resolveArgs($parentModel, $attributedPath, $htmlOptions); 172 | return parent::checkBoxList($model, $attribute, $data, $htmlOptions); 173 | } 174 | 175 | /** 176 | * Renders a radio button list for a model attribute. 177 | * @param CModel $parentModel the parent data model 178 | * @param string $attributedPath the attribute or path to related model attribute 179 | * @param array $data value-label pairs used to generate the radio button list. 180 | * @param array $htmlOptions additional HTML options. 181 | * @return string the generated radio button list 182 | */ 183 | public function radioButtonList($parentModel, $attributedPath, $data, $htmlOptions = array()) 184 | { 185 | list($model, $attribute, $htmlOptions) = self::resolveArgs($parentModel, $attributedPath, $htmlOptions); 186 | return parent::radioButtonList($model, $attribute, $data, $htmlOptions); 187 | } 188 | 189 | /** 190 | * Displays the first validation error for a model attribute. 191 | * This is similar to {@link CHtml::error} except that it registers the model attribute 192 | * so that if its value is changed by users, an AJAX validation may be triggered. 193 | * @param CModel $parentModel the parent data model 194 | * @param string $attributedPath the attribute name 195 | * @param array $htmlOptions additional HTML attributes to be rendered in the container div tag. 196 | * @param boolean $enableAjaxValidation whether to enable AJAX validation for the specified attribute. 197 | * @param boolean $enableClientValidation whether to enable client-side validation for the specified attribute. 198 | * @return string the validation result (error display or success message). 199 | * @see CHtml::error 200 | */ 201 | public function error($parentModel, $attributedPath, $htmlOptions=array(), $enableAjaxValidation=true, $enableClientValidation=true) 202 | { 203 | list($model, $attribute, $htmlOptions) = self::resolveArgs($parentModel, $attributedPath, $htmlOptions); 204 | return parent::error($model, $attribute, $htmlOptions, $enableAjaxValidation, $enableClientValidation); 205 | } 206 | 207 | public static function resolveModel($parentModel, $attributedPath) 208 | { 209 | $model = $parentModel; 210 | $pathPortions = explode('.', $attributedPath); 211 | 212 | // last portion is always model attribute 213 | $attribute = array_pop($pathPortions); 214 | foreach ($pathPortions as $index => $portion) { 215 | // handle 'parent.statuses..id' 216 | if ($portion == '') { 217 | // portion becomes to new index 218 | $portion = is_array($model) ? count($model) : 0; 219 | } 220 | 221 | // handle 'parent.1.' 222 | if (is_numeric($portion)) { 223 | if (!empty($model) && !is_array($model)) { 224 | throw new Exception("Incorrect '..' or '.<index>.' usage"); 225 | } 226 | 227 | $nextModel = isset($model[$portion]) ? $model[$portion] 228 | : self::createRelationModel($parentModel, $pathPortions[$index - 1]); 229 | } 230 | // handle 'parent.status' when status relation is empty (new model required) 231 | elseif (empty($model->{$portion})) { 232 | 233 | $nextModel = self::createRelationModel($model, $portion, true); 234 | } 235 | // handle 'parent.status' 236 | else { 237 | $nextModel = $model->{$portion}; 238 | } 239 | 240 | // shift models 241 | $parentModel = $model; 242 | $model = $nextModel; 243 | } 244 | 245 | return $model; 246 | } 247 | 248 | public static function resolveName($parentModel, $attributedPath) 249 | { 250 | $name = get_class($parentModel); 251 | $pathPortions = explode('.', $attributedPath); 252 | foreach ($pathPortions as $key => $pathPortion) { 253 | if ($pathPortion === '') 254 | $pathPortion = '{index}'; 255 | $name .= '[' . $pathPortion . ']'; 256 | } 257 | return $name; 258 | } 259 | 260 | public static function resolveAttribute($attributedPath) 261 | { 262 | $pathPortions = explode('.', $attributedPath); 263 | return trim(end($pathPortions)); 264 | } 265 | 266 | public static function resolveArgs($parentModel, $attributedPath, $htmlOptions = array()) 267 | { 268 | $model = self::resolveModel($parentModel, $attributedPath); 269 | $attribute = self::resolveAttribute($attributedPath); 270 | if (empty($htmlOptions['name'])) 271 | $htmlOptions['name'] = self::resolveName($parentModel, $attributedPath); 272 | 273 | return array($model, $attribute, $htmlOptions); 274 | } 275 | 276 | public static function resolveLabel($parentModel, $attributedPath) 277 | { 278 | $attribute = str_replace('..','.', $attributedPath); 279 | return $parentModel->getAttributeLabel($attribute); 280 | } 281 | 282 | protected static function createRelationModel($model, $relation, $allowMany = false) 283 | { 284 | $relations = $model->relations(); 285 | if (!array_key_exists($relation, $relations)) 286 | throw new Exception("Undefined relation " . $relation); 287 | 288 | $relationType = $relations[$relation][0]; 289 | $relationModelClass = $relations[$relation][1]; 290 | 291 | if ($allowMany && in_array($relationType, array(CActiveRecord::HAS_MANY, CActiveRecord::MANY_MANY))) { 292 | $model = array(); 293 | } else { 294 | $model = new $relationModelClass(); 295 | } 296 | 297 | return $model; 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /tests/unit/WFormRelationHasOneTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('PDO and SQLite extensions are required.'); 16 | 17 | $this->_connection = new CDbConnection('sqlite::memory:'); 18 | $this->_connection->active = true; 19 | $this->_connection->pdoInstance->exec(file_get_contents(dirname(__FILE__).'/../fixtures/data/sqlite.sql')); 20 | CActiveRecord::$db = $this->_connection; 21 | } 22 | 23 | 24 | protected function tearDown() 25 | { 26 | $this->_connection->active=false; 27 | } 28 | 29 | /** 30 | * @covers WFormRelationHasOne::setAttributes 31 | */ 32 | public function testSetAttributes() 33 | { 34 | $product = $this->_getProductWithRelation(); 35 | 36 | $product->attributes = array( 37 | 'name' => 'product_name', 38 | 'description' => array( 39 | 'size' => '12', 40 | ), 41 | ); 42 | 43 | $this->assertNotEmpty($product->description); 44 | $this->assertEquals('12', $product->description->size); 45 | 46 | $product->attributes = array( 47 | 'name' => 'product_name', 48 | ); 49 | $this->assertNotEmpty($product->description); 50 | 51 | $product->attributes = array( 52 | 'name' => 'product_name', 53 | 'description' => array(), 54 | ); 55 | $this->assertTrue($product->description->isNewRecord); 56 | 57 | $product = $this->_getProductWithRelation(1); 58 | $this->assertNotEmpty($product->description); 59 | 60 | $product->attributes = array( 61 | 'name' => 'product_name', 62 | 'description' => array( 63 | 'id' => 1, 64 | ), 65 | ); 66 | 67 | $this->assertNotEmpty($product->description); 68 | // check if exists description just updated 69 | $this->assertEquals('100x100', $product->description->size); 70 | } 71 | 72 | /** 73 | * @covers WFormRelationHasOne::validate 74 | * @dataProvider validateProvider 75 | */ 76 | public function testValidate($expectedResult, $relationOptions, $relationAttribute, $onFailComment = "") 77 | { 78 | $product = $this->_getProductWithRelation(null, $relationOptions); 79 | 80 | $product->attributes = array( 81 | 'name' => 'product_name', 82 | 'description' => $relationAttribute, 83 | ); 84 | 85 | $this->assertEquals($expectedResult, $product->validate(), $onFailComment); 86 | } 87 | 88 | public function validateProvider() 89 | { 90 | return array( 91 | // required=true 92 | array( 93 | 'result' => true, 94 | 'relationOptions' => array('required' => true), 95 | 'relationAttribute' => array( 96 | 'size' => '12x12' 97 | ), 98 | 'comment' => 'required, 1 valid related object' 99 | ), 100 | array( 101 | 'result' => false, 102 | 'relationOptions' => array('required' => true), 103 | 'relationAttribute' => array( 104 | 'size' => '' 105 | ), 106 | 'comment' => 'required, 1 invalid related object' 107 | ), 108 | array( 109 | 'result' => false, 110 | 'relationOptions' => array('required' => true), 111 | 'relationAttribute' => array(), 112 | 'comment' => 'required, 0 related objects' 113 | ), 114 | // required=false 115 | array( 116 | 'result' => true, 117 | 'relationOptions' => array('required' => false), 118 | 'relationAttribute' => array( 119 | 'size' => '12x12' 120 | ), 121 | 'comment' => 'not required, 1 valid related object' 122 | ), 123 | array( 124 | 'result' => false, 125 | 'relationOptions' => array('required' => false), 126 | 'relationAttribute' => array( 127 | 'size' => '' 128 | ), 129 | 'comment' => 'not required, 1 invalid related objects' 130 | ), 131 | array( 132 | 'result' => true, 133 | 'relationOptions' => array('required' => false), 134 | 'relationAttribute' => null, 135 | 'comment' => 'not required, 0 related objects' 136 | ), 137 | ); 138 | } 139 | 140 | /** 141 | * @covers WFormRelationHasOne::save 142 | * @dataProvider saveProvider 143 | */ 144 | public function testSave($expectedResult, $relationOptions, $relationAttribute, $onFailComment = "") 145 | { 146 | $product = $this->_getProductWithRelation(null, $relationOptions); 147 | 148 | $product->attributes = array( 149 | 'name' => 'product_name', 150 | 'description' => $relationAttribute, 151 | ); 152 | 153 | $this->assertEquals($expectedResult['saved'], $product->save(), $onFailComment); 154 | if ($expectedResult['relationsCount'] > 0) { 155 | $this->assertNotEmpty($product->description, $onFailComment); 156 | } else { 157 | $this->assertEmpty($product->description, $onFailComment); 158 | } 159 | 160 | } 161 | 162 | public function saveProvider() 163 | { 164 | return array( 165 | // required=true 166 | array( 167 | 'result' => array('saved' => true, 'relationsCount' => 1), 168 | 'relationOptions' => array('required' => true), 169 | 'relationAttribute' => array( 170 | 'size' => 'tag_name' 171 | ), 172 | 'comment' => 'required, 1 valid related object' 173 | ), 174 | array( 175 | 'result' => array('saved' => false, 'relationsCount' => 1), 176 | 'relationOptions' => array('required' => true), 177 | 'relationAttribute' => array( 178 | 'size' => '' 179 | ), 180 | 'comment' => 'required, 1 invalid related object' 181 | ), 182 | array( 183 | 'result' => array('saved' => false, 'relationsCount' => 1), 184 | 'relationOptions' => array('required' => true), 185 | 'relationAttribute' => null, 186 | 'comment' => 'required, 0 related objects' 187 | ), 188 | // required=false 189 | array( 190 | 'result' => array('saved' => true, 'relationsCount' => 1), 191 | 'relationOptions' => array('required' => false), 192 | 'relationAttribute' => array( 193 | 'size' => 'tag_name' 194 | ), 195 | 'comment' => 'not required, 1 valid related object' 196 | ), 197 | array( 198 | 'result' => array('saved' => false, 'relationsCount' => 1), 199 | 'relationOptions' => array('required' => false), 200 | 'relationAttribute' => array( 201 | 'size' => '' 202 | ), 203 | 'comment' => 'not required, 1 invalid related objects' 204 | ), 205 | array( 206 | 'result' => array('saved' => true, 'relationsCount' => 0), 207 | 'relationOptions' => array('required' => false), 208 | 'relationAttribute' => null, 209 | 'comment' => 'not required, 0 related objects' 210 | ), 211 | ); 212 | } 213 | 214 | /** 215 | * @covers WFormRelationHasOne::save 216 | */ 217 | public function testSaveIfNotSet() 218 | { 219 | $product = Product::model() ; 220 | $product->attachBehavior('wform', array( 221 | 'class' => 'WFormBehavior', 222 | 'relations' => array( 223 | 'description' => array('required' => false), 224 | ), 225 | )); 226 | $product = $product->findByPk(1); 227 | $product->attachBehavior('wform', array( 228 | 'class' => 'WFormBehavior', 229 | 'relations' => array( 230 | 'description' => array('required' => false), 231 | ), 232 | )); 233 | $product->afterFind(new CEvent($product)); 234 | 235 | 236 | $this->assertEquals(true, $product->save()); 237 | $this->assertNotEmpty($product->description); 238 | 239 | $product = Product::model(); 240 | $product->attachBehavior('wform', array( 241 | 'class' => 'WFormBehavior', 242 | 'relations' => array( 243 | 'description' => array('required' => false), 244 | ), 245 | )); 246 | $product = $product->with('description')->findByPk(1); 247 | $product->attachBehavior('wform', array( 248 | 'class' => 'WFormBehavior', 249 | 'relations' => array( 250 | 'description' => array('required' => false), 251 | ), 252 | )); 253 | $product->afterFind(new CEvent($product)); 254 | 255 | $this->assertEquals(true, $product->save()); 256 | 257 | $this->assertEmpty($product->description); 258 | } 259 | 260 | /** 261 | * @covers WFormRelationHasOne::getRelatedModel 262 | */ 263 | public function testGetRelatedModel() 264 | { 265 | $product = $this->_getProductWithRelation(); 266 | $relation = WFormRelation::getInstance($product, 'description'); 267 | 268 | $this->assertEmpty($relation->getRelatedModel(false)); 269 | // $this->assertNotEmpty($relation->getRelatedModel(true)); 270 | 271 | $product->attributes = array( 272 | 'name' => 'product_name', 273 | 'description' => array( 274 | 'id' => 1, 275 | 'size' => '10' 276 | ), 277 | ); 278 | 279 | $this->assertNotEmpty($relation->getRelatedModel()); 280 | 281 | $product = $this->_getProductWithRelation(1); 282 | $relation = WFormRelation::getInstance($product, 'description'); 283 | 284 | $this->assertNotEmpty($relation->getRelatedModel()); 285 | } 286 | 287 | /** 288 | * @covers WFormRelationHasOne::getActualRelatedModel 289 | */ 290 | public function testGetActualRelatedModel() 291 | { 292 | $product = $this->_getProductWithRelation(1, array('required' => false)); 293 | $relation = WFormRelation::getInstance($product, 'description', array('required' => false)); 294 | 295 | $this->assertNotEmpty($relation->getRelatedModel(false)); 296 | $this->assertNotEmpty($relation->getActualRelatedModel()); 297 | 298 | $product->attributes = array( 299 | 'name' => 'name', 300 | 'description' => null, 301 | ); 302 | 303 | 304 | $this->assertNotEmpty($relation->getActualRelatedModel()); 305 | $this->assertEmpty($relation->getRelatedModel(false)); 306 | 307 | $product->attributes = array( 308 | 'name' => 'name', 309 | 'description' => array( 310 | 'size' => '10', 311 | ), 312 | ); 313 | 314 | $this->assertNotEmpty($relation->getActualRelatedModel()); 315 | $this->assertNotEmpty($relation->getRelatedModel(false)); 316 | 317 | } 318 | 319 | /** 320 | * WFormRelationHasOne::lazyDelete 321 | * @dataProvider lazyDeleteProvider 322 | */ 323 | public function testLazyDelete($expectedResult, $relationOptions, $relationAttribute, $onFailComment = "") 324 | { 325 | $product = $this->_getProductWithRelation(1, $relationOptions); 326 | 327 | $product->attributes = array( 328 | 'name' => 'name', 329 | 'description' => $relationAttribute, 330 | ); 331 | 332 | 333 | $this->assertTrue(empty($product->description) == ($expectedResult['relationsCount'] == 0), $onFailComment); 334 | 335 | $product->save(); 336 | 337 | $this->assertTrue(empty($product->description) == ($expectedResult['relationsCount'] == 0), $onFailComment); 338 | 339 | $refreshedDescription = $product->getRelated('description', true); 340 | $this->assertTrue(empty($refreshedDescription) == ($expectedResult['relationsCount'] == 0), $onFailComment); 341 | 342 | 343 | $relatedIds = array(); 344 | $model = $product->getRelated('description', true); 345 | if ($model) 346 | $relatedIds[] = $model->primaryKey; 347 | 348 | $this->assertTrue(in_array($expectedResult['oldId'], $relatedIds) == $expectedResult['containsOld']); 349 | } 350 | 351 | public function lazyDeleteProvider() 352 | { 353 | return array( 354 | // required=true 355 | array( 356 | 'result' => array('relationsCount' => 1, 'oldId' => 1, 'containsOld' => true), 357 | 'relationOptions' => array('required' => false), 358 | 'relationAttribute' => array( 359 | 'size' => '10x10' 360 | ), 361 | 'comment' => 'new description' 362 | ), 363 | array( 364 | 'result' => array('relationsCount' => 2, 'oldId' => 1, 'containsOld' => true), 365 | 'relationOptions' => array('required' => false), 366 | 'relationAttribute' => array( 367 | 'id' => 1, 368 | 'size' => '10x10' 369 | ), 370 | 'comment' => 'old description' 371 | ), 372 | array( 373 | 'result' => array('relationsCount' => 0, 'oldId' => 1, 'containsOld' => false), 374 | 'relationOptions' => array('required' => false), 375 | 'relationAttribute' => null, 376 | 'comment' => 'empty description' 377 | ), 378 | ); 379 | } 380 | 381 | 382 | /** 383 | * WFormRelationHasMany::delete 384 | */ 385 | public function testDelete() 386 | { 387 | $product = $this->_getProductWithRelation(1); 388 | 389 | $this->assertNotEmpty($product->description); 390 | $id = $product->description->primaryKey; 391 | $this->assertTrue($product->delete()); 392 | $this->assertEmpty(ProductDescription::model()->findByPk($id)); 393 | } 394 | 395 | /** 396 | * WFormRelationHasMany::delete 397 | */ 398 | public function testDeleteWithoutCascade() 399 | { 400 | $product = $this->_getProductWithRelation(1, array('cascadeDelete' => false)); 401 | 402 | $this->assertNotEmpty($product->description); 403 | $id = $product->description->primaryKey; 404 | $this->assertTrue($product->delete()); 405 | $this->assertNotEmpty(ProductDescription::model()->findByPk($id)); 406 | } 407 | 408 | /** 409 | * @param null $id 410 | * @param array $relationOptions 411 | * @return Product 412 | */ 413 | protected function _getProductWithRelation($id = null, $relationOptions = array()) 414 | { 415 | $product = $id ? Product::model()->findByPk($id) : new Product(); 416 | $product->attachBehavior('wform', array( 417 | 'class' => 'WFormBehavior', 418 | 'relations' => array( 419 | 'description' => $relationOptions, 420 | ), 421 | )); 422 | $product->afterConstruct(new CEvent($product)); 423 | 424 | return $product; 425 | } 426 | } -------------------------------------------------------------------------------- /tests/unit/WFormRelationManyManyTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('PDO and SQLite extensions are required.'); 12 | 13 | $this->_connection = new CDbConnection('sqlite::memory:'); 14 | $this->_connection->active = true; 15 | $this->_connection->pdoInstance->exec(file_get_contents(dirname(__FILE__).'/../fixtures/data/sqlite.sql')); 16 | CActiveRecord::$db = $this->_connection; 17 | } 18 | 19 | 20 | protected function tearDown() 21 | { 22 | $this->_connection->active=false; 23 | } 24 | 25 | /** 26 | * @covers WFormRelationManyMany::setAttributes 27 | */ 28 | public function testSetAttributes() 29 | { 30 | $product = $this->_getProductWithRelation(); 31 | 32 | $product->attributes = array( 33 | 'name' => 'name', 34 | 'tags' => array( 35 | array( 36 | 'name' => 'tag_name', 37 | ), 38 | array( 39 | 'name' => 'tag_name2', 40 | ), 41 | ), 42 | ); 43 | 44 | $this->assertCount(2, $product->tags); 45 | $this->assertEquals('tag_name', $product->tags[0]->name); 46 | $this->assertEquals('tag_name2', $product->tags[1]->name); 47 | 48 | $product->attributes = array( 49 | 'name' => 'name', 50 | ); 51 | $this->assertCount(2, $product->tags); 52 | 53 | $product->attributes = array( 54 | 'name' => 'name', 55 | 'tags' => array(), 56 | ); 57 | $this->assertCount(0, $product->tags); 58 | 59 | $product = $this->_getProductWithRelation(1); 60 | 61 | $product->attributes = array( 62 | 'name' => 'name', 63 | 'tags' => array( 64 | array( 65 | 'id' => 1, 66 | ), 67 | ), 68 | ); 69 | 70 | $this->assertCount(1, $product->tags); 71 | // check if exists tags just updated 72 | $this->assertEquals('bad', $product->tags[0]->name); 73 | } 74 | 75 | /** 76 | * @covers WFormRelationManyMany::validate 77 | * @dataProvider validateProvider 78 | */ 79 | public function testValidate($expectedResult, $relationOptions, $relationAttribute, $onFailComment = "") 80 | { 81 | $product = $this->_getProductWithRelation(null, $relationOptions); 82 | 83 | $product->attributes = array( 84 | 'name' => 'name', 85 | 'tags' => $relationAttribute, 86 | ); 87 | 88 | $this->assertEquals($expectedResult, $product->validate(), $onFailComment); 89 | } 90 | 91 | /** 92 | * @covers WFormRelationManyMany::validate 93 | */ 94 | public function testUnsetInvalid() 95 | { 96 | $product = $this->_getProductWithRelation(null, array('unsetInvalid' => true)); 97 | 98 | $product->attributes = array( 99 | 'name' => 'name', 100 | 'tags' => array( 101 | array( 102 | 'name' => 'tag_name4' 103 | ), 104 | array( 105 | 'name' => '' 106 | ), 107 | ), 108 | ); 109 | 110 | $this->assertTrue($product->validate()); 111 | $this->assertCount(1, $product->tags); 112 | } 113 | 114 | public function validateProvider() 115 | { 116 | return array( 117 | // required=true 118 | array( 119 | 'result' => true, 120 | 'relationOptions' => array('required' => true), 121 | 'relationAttribute' => array( 122 | array( 123 | 'name' => 'tag_name' 124 | ), 125 | ), 126 | 'comment' => 'required, 1 valid related object' 127 | ), 128 | array( 129 | 'result' => false, 130 | 'relationOptions' => array('required' => true), 131 | 'relationAttribute' => array( 132 | array( 133 | 'name' => '' 134 | ), 135 | ), 136 | 'comment' => 'required, 1 invalid related object' 137 | ), 138 | array( 139 | 'result' => false, 140 | 'relationOptions' => array('required' => true), 141 | 'relationAttribute' => array(), 142 | 'comment' => 'required, 0 related objects' 143 | ), 144 | // required=false 145 | array( 146 | 'result' => true, 147 | 'relationOptions' => array('required' => false), 148 | 'relationAttribute' => array( 149 | array( 150 | 'name' => 'tag_name' 151 | ), 152 | ), 153 | 'comment' => 'not required, 1 valid related object' 154 | ), 155 | array( 156 | 'result' => false, 157 | 'relationOptions' => array('required' => false), 158 | 'relationAttribute' => array( 159 | array( 160 | 'name' => '' 161 | ), 162 | ), 163 | 'comment' => 'not required, 1 invalid related objects' 164 | ), 165 | array( 166 | 'result' => true, 167 | 'relationOptions' => array('required' => false), 168 | 'relationAttribute' => array(), 169 | 'comment' => 'not required, 0 related objects' 170 | ), 171 | // 'unsetInvalid' => true 172 | array( 173 | 'result' => true, 174 | 'relationOptions' => array('required' => false, 'unsetInvalid' => true), 175 | 'relationAttribute' => array( 176 | array( 177 | 'name' => '' 178 | ), 179 | ), 180 | 'comment' => 'unsetInvalid, not required, 1 invalid related objects' 181 | ), 182 | array( 183 | 'result' => true, 184 | 'relationOptions' => array('required' => true, 'unsetInvalid' => true), 185 | 'relationAttribute' => array( 186 | array( 187 | 'name' => '' 188 | ), 189 | ), 190 | 'comment' => 'unsetInvalid, required, 1 invalid related objects' 191 | ), 192 | ); 193 | } 194 | 195 | /** 196 | * @covers WFormRelationManyMany::save 197 | * @dataProvider saveProvider 198 | */ 199 | public function testSave($expectedResult, $relationOptions, $relationAttribute, $onFailComment = "") 200 | { 201 | $product = $this->_getProductWithRelation(null, $relationOptions); 202 | 203 | $product->attributes = array( 204 | 'name' => 'name', 205 | 'tags' => $relationAttribute, 206 | ); 207 | 208 | $this->assertEquals($expectedResult['saved'], $product->save(), $onFailComment); 209 | $this->assertCount($expectedResult['relationsCount'], $product->tags, $onFailComment); 210 | 211 | $product = $this->_getProductWithRelation(1, $relationOptions); 212 | 213 | $product->attributes = array( 214 | 'name' => 'name', 215 | 'tags' => $relationAttribute, 216 | ); 217 | 218 | $this->assertEquals($expectedResult['saved'], $product->save(), $onFailComment); 219 | $this->assertCount($expectedResult['relationsCount'], $product->tags, $onFailComment); 220 | } 221 | 222 | public function saveProvider() 223 | { 224 | return array( 225 | // required=true 226 | array( 227 | 'result' => array('saved' => true, 'relationsCount' => 1), 228 | 'relationOptions' => array('required' => true), 229 | 'relationAttribute' => array( 230 | array( 231 | 'name' => 'tag_name' 232 | ), 233 | ), 234 | 'comment' => 'required, 1 valid related object' 235 | ), 236 | array( 237 | 'result' => array('saved' => false, 'relationsCount' => 1), 238 | 'relationOptions' => array('required' => true), 239 | 'relationAttribute' => array( 240 | array( 241 | 'name' => '' 242 | ), 243 | ), 244 | 'comment' => 'required, 1 invalid related object' 245 | ), 246 | array( 247 | 'result' => array('saved' => false, 'relationsCount' => 0), 248 | 'relationOptions' => array('required' => true), 249 | 'relationAttribute' => array(), 250 | 'comment' => 'required, 0 related objects' 251 | ), 252 | // required=false 253 | array( 254 | 'result' => array('saved' => true, 'relationsCount' => 1), 255 | 'relationOptions' => array('required' => false), 256 | 'relationAttribute' => array( 257 | array( 258 | 'name' => 'tag_name' 259 | ), 260 | ), 261 | 'comment' => 'not required, 1 valid related object' 262 | ), 263 | array( 264 | 'result' => array('saved' => false, 'relationsCount' => 1), 265 | 'relationOptions' => array('required' => false), 266 | 'relationAttribute' => array( 267 | array( 268 | 'name' => '' 269 | ), 270 | ), 271 | 'comment' => 'not required, 1 invalid related objects' 272 | ), 273 | array( 274 | 'result' => array('saved' => true, 'relationsCount' => 0), 275 | 'relationOptions' => array('required' => false), 276 | 'relationAttribute' => array(), 277 | 'comment' => 'not required, 0 related objects' 278 | ), 279 | // 'unsetInvalid' => true 280 | array( 281 | 'result' => array('saved' => true, 'relationsCount' => 0), 282 | 'relationOptions' => array('required' => false, 'unsetInvalid' => true), 283 | 'relationAttribute' => array( 284 | array( 285 | 'name' => '' 286 | ), 287 | ), 288 | 'comment' => 'unsetInvalid, not required, 1 invalid related objects' 289 | ), 290 | array( 291 | 'result' => array('saved' => true, 'relationsCount' => 0), 292 | 'relationOptions' => array('required' => true, 'unsetInvalid' => true), 293 | 'relationAttribute' => array( 294 | array( 295 | 'name' => '' 296 | ), 297 | ), 298 | 'comment' => 'unsetInvalid, required, 1 invalid related objects' 299 | ), 300 | ); 301 | } 302 | 303 | /** 304 | * @covers WFormRelationManyMany::save 305 | */ 306 | public function testSaveIfNotSet() 307 | { 308 | $product = Product::model() ; 309 | $product->attachBehavior('wform', array( 310 | 'class' => 'WFormBehavior', 311 | 'relations' => array( 312 | 'tags' => array('required' => false), 313 | ), 314 | )); 315 | $product = $product->findByPk(1); 316 | $product->attachBehavior('wform', array( 317 | 'class' => 'WFormBehavior', 318 | 'relations' => array( 319 | 'tags' => array('required' => false), 320 | ), 321 | )); 322 | $product->afterFind(new CEvent($product)); 323 | 324 | 325 | $this->assertEquals(true, $product->save()); 326 | $this->assertCount(2, $product->tags); 327 | 328 | $product = Product::model(); 329 | $product->attachBehavior('wform', array( 330 | 'class' => 'WFormBehavior', 331 | 'relations' => array( 332 | 'tags' => array('required' => false), 333 | ), 334 | )); 335 | $product = $product->with('tags')->findByPk(1); 336 | $product->attachBehavior('wform', array( 337 | 'class' => 'WFormBehavior', 338 | 'relations' => array( 339 | 'tags' => array('required' => false), 340 | ), 341 | )); 342 | $product->afterFind(new CEvent($product)); 343 | 344 | $this->assertEquals(true, $product->save()); 345 | $this->assertCount(0, $product->tags); 346 | } 347 | 348 | /** 349 | * @covers WFormRelationManyMany::getRelatedModels 350 | */ 351 | public function testGetRelatedModels() 352 | { 353 | $product = $this->_getProductWithRelation(); 354 | $relation = WFormRelation::getInstance($product, 'tags'); 355 | 356 | $this->assertCount(0, $relation->getRelatedModels()); 357 | 358 | $product->attributes = array( 359 | 'name' => 'name', 360 | 'tags' => array( 361 | array( 362 | 'id' => 1, 363 | 'name' => 'tag_name' 364 | ), 365 | ), 366 | ); 367 | 368 | $this->assertCount(1, $relation->getRelatedModels()); 369 | 370 | $product = $this->_getProductWithRelation(1); 371 | $relation = WFormRelation::getInstance($product, 'tags'); 372 | 373 | $this->assertCount(2, $relation->getRelatedModels()); 374 | } 375 | 376 | /** 377 | * @covers WFormRelationManyMany::getActualRelatedModels 378 | */ 379 | public function testGetActualRelatedModels() 380 | { 381 | $product = $this->_getProductWithRelation(1); 382 | $relation = WFormRelation::getInstance($product, 'tags'); 383 | 384 | $this->assertCount(2, $relation->getRelatedModels()); 385 | $this->assertCount(2, $relation->getActualRelatedModels()); 386 | 387 | $product->attributes = array( 388 | 'name' => 'name', 389 | 'tags' => array(), 390 | ); 391 | 392 | $this->assertCount(2, $relation->getActualRelatedModels()); 393 | $this->assertCount(0, $relation->getRelatedModels()); 394 | 395 | $product->attributes = array( 396 | 'name' => 'name', 397 | 'tags' => array( 398 | array( 399 | 'name' => 'tag_name1', 400 | ), 401 | ), 402 | ); 403 | 404 | $this->assertCount(2, $relation->getActualRelatedModels()); 405 | $this->assertCount(1, $relation->getRelatedModels()); 406 | 407 | } 408 | 409 | /** 410 | * WFormRelationManyMany::lazyDelete 411 | * @dataProvider lazyDeleteProvider 412 | */ 413 | public function testLazyDelete($expectedResult, $relationOptions, $relationAttribute, $onFailComment = "") 414 | { 415 | $product = $this->_getProductWithRelation(1, $relationOptions); 416 | 417 | $product->attributes = array( 418 | 'name' => 'name', 419 | 'tags' => $relationAttribute, 420 | ); 421 | 422 | $this->assertCount($expectedResult['relationsCount'], $product->tags, $onFailComment); 423 | 424 | $product->save(); 425 | 426 | $this->assertCount($expectedResult['relationsCount'], $product->tags, $onFailComment); 427 | $this->assertCount($expectedResult['relationsCount'], $product->getRelated('tags', true), $onFailComment); 428 | 429 | $relatedIds = array(); 430 | foreach($product->getRelated('tags', true) as $model) { 431 | $relatedIds[] = $model->primaryKey; 432 | } 433 | 434 | $this->assertTrue(in_array($expectedResult['oldId'], $relatedIds) == $expectedResult['containsOld']); 435 | } 436 | 437 | public function lazyDeleteProvider() 438 | { 439 | return array( 440 | // required=true 441 | array( 442 | 'result' => array('relationsCount' => 1, 'oldId' => 1, 'containsOld' => false), 443 | 'relationOptions' => array('required' => false), 444 | 'relationAttribute' => array( 445 | array( 446 | 'name' => 'tag_name' 447 | ), 448 | ), 449 | 'comment' => '1 new file' 450 | ), 451 | array( 452 | 'result' => array('relationsCount' => 2, 'oldId' => 1, 'containsOld' => true), 453 | 'relationOptions' => array('required' => false), 454 | 'relationAttribute' => array( 455 | array( 456 | 'id' => 1, 457 | 'name' => 'tag_name1' 458 | ), 459 | array( 460 | 'name' => 'tag_name2' 461 | ), 462 | ), 463 | 'comment' => '1 old, 1 new file' 464 | ), 465 | array( 466 | 'result' => array('relationsCount' => 2, 'oldId' => 1, 'containsOld' => false), 467 | 'relationOptions' => array('required' => false), 468 | 'relationAttribute' => array( 469 | array( 470 | 'name' => 'tag_name1' 471 | ), 472 | array( 473 | 'name' => 'tag_name2' 474 | ), 475 | ), 476 | 'comment' => '2 new files' 477 | ), 478 | array( 479 | 'result' => array('relationsCount' => 0, 'oldId' => 1, 'containsOld' => false), 480 | 'relationOptions' => array('required' => false), 481 | 'relationAttribute' => array( 482 | ), 483 | 'comment' => '0 files' 484 | ), 485 | ); 486 | } 487 | 488 | /** 489 | * WFormRelationManyMany::delete 490 | */ 491 | public function testDelete() 492 | { 493 | $product = $this->_getProductWithRelation(1); 494 | 495 | $this->assertCount(2, $product->tags); 496 | 497 | 498 | $this->assertTrue($product->delete()); 499 | 500 | $sql = "SELECT COUNT(*) FROM products_2_tags WHERE product_id = 1"; 501 | 502 | $command = $this->_connection->createCommand($sql); 503 | $this->assertEquals(0, $command->queryScalar()); 504 | } 505 | 506 | public function testDeleteWithoutCascade() 507 | { 508 | $product = $this->_getProductWithRelation(1, array('cascadeDelete' => false)); 509 | 510 | $this->assertCount(2, $product->tags); 511 | 512 | 513 | $this->assertTrue($product->delete()); 514 | 515 | $sql = "SELECT COUNT(*) FROM products_2_tags WHERE product_id = 1"; 516 | 517 | $command = $this->_connection->createCommand($sql); 518 | $this->assertEquals(2, $command->queryScalar()); 519 | } 520 | 521 | /** 522 | * @param null $id 523 | * @param array $relationOptions 524 | * @return Product 525 | */ 526 | protected function _getProductWithRelation($id = null, $relationOptions = array()) 527 | { 528 | $product = $id ? Product::model()->findByPk($id) : new Product(); 529 | $product->attachBehavior('wform', array( 530 | 'class' => 'WFormBehavior', 531 | 'relations' => array( 532 | 'tags' => $relationOptions, 533 | ), 534 | )); 535 | $product->afterConstruct(new CEvent($product)); 536 | 537 | return $product; 538 | } 539 | } -------------------------------------------------------------------------------- /tests/unit/WFormRelationHasManyTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('PDO and SQLite extensions are required.'); 13 | 14 | $this->_connection = new CDbConnection('sqlite::memory:'); 15 | $this->_connection->active = true; 16 | $this->_connection->pdoInstance->exec(file_get_contents(dirname(__FILE__).'/../fixtures/data/sqlite.sql')); 17 | CActiveRecord::$db = $this->_connection; 18 | } 19 | 20 | 21 | protected function tearDown() 22 | { 23 | $this->_connection->active=false; 24 | } 25 | 26 | /** 27 | * @covers WFormRelationHasMany::setAttributes 28 | */ 29 | public function testSetAttributes() 30 | { 31 | $product = $this->_getProductWithRelation(); 32 | 33 | $product->attributes = array( 34 | 'name' => 'name', 35 | 'images' => array( 36 | array( 37 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 38 | 'file' => 'somefile.txt', 39 | ), 40 | array( 41 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 42 | 'file' => 'somefile2.txt', 43 | ), 44 | ), 45 | ); 46 | 47 | $this->assertCount(2, $product->images); 48 | $this->assertEquals(Attachment::OBJECT_TYPE_PRODUCT_IMAGE, $product->images[0]->object_type); 49 | $this->assertEquals('somefile.txt', $product->images[0]->file); 50 | 51 | $product->attributes = array( 52 | 'name' => 'name', 53 | ); 54 | $this->assertCount(2, $product->images); 55 | 56 | $product->attributes = array( 57 | 'name' => 'name', 58 | 'images' => array(), 59 | ); 60 | $this->assertCount(0, $product->images); 61 | 62 | $product = $this->_getProductWithRelation(1); 63 | 64 | $product->attributes = array( 65 | 'name' => 'name', 66 | 'images' => array( 67 | array( 68 | 'id' => 1, 69 | 'file' => 'newfile.txt', 70 | ), 71 | ), 72 | ); 73 | 74 | $this->assertCount(1, $product->images); 75 | // check if exists images just updated 76 | $this->assertEquals(Attachment::OBJECT_TYPE_PRODUCT_IMAGE, $product->images[0]->object_type); 77 | } 78 | 79 | /** 80 | * @covers WFormRelationHasMany::validate 81 | * @dataProvider validateProvider 82 | */ 83 | public function testValidate($expectedResult, $relationOptions, $relationAttribute, $onFailComment = "") 84 | { 85 | $product = $this->_getProductWithRelation(null, $relationOptions); 86 | 87 | $product->attributes = array( 88 | 'name' => 'name', 89 | 'images' => $relationAttribute, 90 | ); 91 | 92 | $this->assertEquals($expectedResult, $product->validate(), $onFailComment); 93 | } 94 | 95 | /** 96 | * @covers WFormRelationHasMany::validate 97 | */ 98 | public function testUnsetInvalid() 99 | { 100 | $product = $this->_getProductWithRelation(null, array('unsetInvalid' => true)); 101 | 102 | $product->attributes = array( 103 | 'name' => 'name', 104 | 'images' => array( 105 | array( 106 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 107 | 'file' => 'somefile.txt' 108 | ), 109 | array( 110 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 111 | 'file' => '' 112 | ), 113 | ), 114 | ); 115 | 116 | $this->assertTrue($product->validate()); 117 | $this->assertCount(1, $product->images); 118 | } 119 | 120 | public function validateProvider() 121 | { 122 | return array( 123 | // required=true 124 | array( 125 | 'result' => true, 126 | 'relationOptions' => array('required' => true), 127 | 'relationAttribute' => array( 128 | array( 129 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 130 | 'file' => 'somefile.txt' 131 | ), 132 | ), 133 | 'comment' => 'required, 1 valid related object' 134 | ), 135 | array( 136 | 'result' => false, 137 | 'relationOptions' => array('required' => true), 138 | 'relationAttribute' => array( 139 | array( 140 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 141 | 'file' => '' 142 | ), 143 | ), 144 | 'comment' => 'required, 1 invalid related object' 145 | ), 146 | array( 147 | 'result' => false, 148 | 'relationOptions' => array('required' => true), 149 | 'relationAttribute' => array(), 150 | 'comment' => 'required, 0 related objects' 151 | ), 152 | // required=false 153 | array( 154 | 'result' => true, 155 | 'relationOptions' => array('required' => false), 156 | 'relationAttribute' => array( 157 | array( 158 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 159 | 'file' => 'somefile.txt' 160 | ), 161 | ), 162 | 'comment' => 'not required, 1 valid related object' 163 | ), 164 | array( 165 | 'result' => false, 166 | 'relationOptions' => array('required' => false), 167 | 'relationAttribute' => array( 168 | array( 169 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 170 | 'file' => '' 171 | ), 172 | ), 173 | 'comment' => 'not required, 1 invalid related objects' 174 | ), 175 | array( 176 | 'result' => true, 177 | 'relationOptions' => array('required' => false), 178 | 'relationAttribute' => array(), 179 | 'comment' => 'not required, 0 related objects' 180 | ), 181 | // 'unsetInvalid' => true 182 | array( 183 | 'result' => true, 184 | 'relationOptions' => array('required' => false, 'unsetInvalid' => true), 185 | 'relationAttribute' => array( 186 | array( 187 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 188 | 'file' => '' 189 | ), 190 | ), 191 | 'comment' => 'unsetInvalid, not required, 1 invalid related objects' 192 | ), 193 | array( 194 | 'result' => true, 195 | 'relationOptions' => array('required' => true, 'unsetInvalid' => true), 196 | 'relationAttribute' => array( 197 | array( 198 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 199 | 'file' => '' 200 | ), 201 | ), 202 | 'comment' => 'unsetInvalid, required, 1 invalid related objects' 203 | ), 204 | ); 205 | } 206 | 207 | /** 208 | * @covers WFormRelationHasMany::save 209 | * @dataProvider saveProvider 210 | */ 211 | public function testSave($expectedResult, $relationOptions, $relationAttribute, $onFailComment = "") 212 | { 213 | $product = $this->_getProductWithRelation(null, $relationOptions); 214 | 215 | $product->attributes = array( 216 | 'name' => 'name', 217 | 'images' => $relationAttribute, 218 | ); 219 | 220 | $this->assertEquals($expectedResult['saved'], $product->save(), $onFailComment); 221 | $this->assertCount($expectedResult['relationsCount'], $product->images, $onFailComment); 222 | } 223 | 224 | public function saveProvider() 225 | { 226 | return array( 227 | // required=true 228 | array( 229 | 'result' => array('saved' => true, 'relationsCount' => 1), 230 | 'relationOptions' => array('required' => true), 231 | 'relationAttribute' => array( 232 | array( 233 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 234 | 'file' => 'somefile.txt' 235 | ), 236 | ), 237 | 'comment' => 'required, 1 valid related object' 238 | ), 239 | array( 240 | 'result' => array('saved' => false, 'relationsCount' => 1), 241 | 'relationOptions' => array('required' => true), 242 | 'relationAttribute' => array( 243 | array( 244 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 245 | 'file' => '' 246 | ), 247 | ), 248 | 'comment' => 'required, 1 invalid related object' 249 | ), 250 | array( 251 | 'result' => array('saved' => false, 'relationsCount' => 0), 252 | 'relationOptions' => array('required' => true), 253 | 'relationAttribute' => array(), 254 | 'comment' => 'required, 0 related objects' 255 | ), 256 | // required=false 257 | array( 258 | 'result' => array('saved' => true, 'relationsCount' => 1), 259 | 'relationOptions' => array('required' => false), 260 | 'relationAttribute' => array( 261 | array( 262 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 263 | 'file' => 'somefile.txt' 264 | ), 265 | ), 266 | 'comment' => 'not required, 1 valid related object' 267 | ), 268 | array( 269 | 'result' => array('saved' => false, 'relationsCount' => 1), 270 | 'relationOptions' => array('required' => false), 271 | 'relationAttribute' => array( 272 | array( 273 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 274 | 'file' => '' 275 | ), 276 | ), 277 | 'comment' => 'not required, 1 invalid related objects' 278 | ), 279 | array( 280 | 'result' => array('saved' => true, 'relationsCount' => 0), 281 | 'relationOptions' => array('required' => false), 282 | 'relationAttribute' => array(), 283 | 'comment' => 'not required, 0 related objects' 284 | ), 285 | // 'unsetInvalid' => true 286 | array( 287 | 'result' => array('saved' => true, 'relationsCount' => 0), 288 | 'relationOptions' => array('required' => false, 'unsetInvalid' => true), 289 | 'relationAttribute' => array( 290 | array( 291 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 292 | 'file' => '' 293 | ), 294 | ), 295 | 'comment' => 'unsetInvalid, not required, 1 invalid related objects' 296 | ), 297 | array( 298 | 'result' => array('saved' => true, 'relationsCount' => 0), 299 | 'relationOptions' => array('required' => true, 'unsetInvalid' => true), 300 | 'relationAttribute' => array( 301 | array( 302 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 303 | 'file' => '' 304 | ), 305 | ), 306 | 'comment' => 'unsetInvalid, required, 1 invalid related objects' 307 | ), 308 | ); 309 | } 310 | 311 | /** 312 | * @covers WFormRelationHasMany::save 313 | */ 314 | public function testSaveIfNotSet() 315 | { 316 | $product = Product::model() ; 317 | $product->attachBehavior('wform', array( 318 | 'class' => 'WFormBehavior', 319 | 'relations' => array( 320 | 'images' => array('required' => false), 321 | ), 322 | )); 323 | $product = $product->findByPk(1); 324 | $product->attachBehavior('wform', array( 325 | 'class' => 'WFormBehavior', 326 | 'relations' => array( 327 | 'images' => array('required' => false), 328 | ), 329 | )); 330 | $product->afterFind(new CEvent($product)); 331 | 332 | 333 | $this->assertEquals(true, $product->save()); 334 | $this->assertCount(1, $product->images); 335 | 336 | $product = Product::model(); 337 | $product->attachBehavior('wform', array( 338 | 'class' => 'WFormBehavior', 339 | 'relations' => array( 340 | 'images' => array('required' => false), 341 | ), 342 | )); 343 | $product = $product->with('images')->findByPk(1); 344 | $product->attachBehavior('wform', array( 345 | 'class' => 'WFormBehavior', 346 | 'relations' => array( 347 | 'images' => array('required' => false), 348 | ), 349 | )); 350 | $product->afterFind(new CEvent($product)); 351 | 352 | $this->assertEquals(true, $product->save()); 353 | $this->assertCount(0, $product->images); 354 | } 355 | 356 | /** 357 | * @covers WFormRelationHasMany::getRelatedModels 358 | */ 359 | public function testGetRelatedModels() 360 | { 361 | $product = $this->_getProductWithRelation(); 362 | $relation = WFormRelation::getInstance($product, 'images'); 363 | 364 | $this->assertCount(0, $relation->getRelatedModels()); 365 | 366 | $product->attributes = array( 367 | 'name' => 'name', 368 | 'images' => array( 369 | array( 370 | 'id' => 1, 371 | 'file' => 'newfile.txt', 372 | ), 373 | ), 374 | ); 375 | 376 | $this->assertCount(1, $relation->getRelatedModels()); 377 | 378 | $product = $this->_getProductWithRelation(1); 379 | $relation = WFormRelation::getInstance($product, 'images'); 380 | 381 | $this->assertCount(1, $relation->getRelatedModels()); 382 | } 383 | 384 | /** 385 | * @covers WFormRelationHasMany::getActualRelatedModels 386 | */ 387 | public function testGetActualRelatedModels() 388 | { 389 | $product = $this->_getProductWithRelation(1); 390 | $relation = WFormRelation::getInstance($product, 'images'); 391 | 392 | $this->assertCount(1, $relation->getRelatedModels()); 393 | $this->assertCount(1, $relation->getActualRelatedModels()); 394 | 395 | $product->attributes = array( 396 | 'name' => 'name', 397 | 'images' => array(), 398 | ); 399 | 400 | $this->assertCount(1, $relation->getActualRelatedModels()); 401 | $this->assertCount(0, $relation->getRelatedModels()); 402 | 403 | $product->attributes = array( 404 | 'name' => 'name', 405 | 'images' => array( 406 | array( 407 | 'file' => 'newfile1.txt', 408 | ), 409 | array( 410 | 'file' => 'newfile2.txt', 411 | ), 412 | ), 413 | ); 414 | 415 | $this->assertCount(2, $relation->getRelatedModels()); 416 | $this->assertCount(1, $relation->getActualRelatedModels()); 417 | } 418 | 419 | /** 420 | * WFormRelationHasMany::lazyDelete 421 | * @dataProvider lazyDeleteProvider 422 | */ 423 | public function testLazyDelete($expectedResult, $relationOptions, $relationAttribute, $onFailComment = "") 424 | { 425 | $product = $this->_getProductWithRelation(1, $relationOptions); 426 | 427 | $product->attributes = array( 428 | 'name' => 'name', 429 | 'images' => $relationAttribute, 430 | ); 431 | 432 | $this->assertCount($expectedResult['relationsCount'], $product->images, $onFailComment); 433 | 434 | $product->save(); 435 | 436 | $this->assertCount($expectedResult['relationsCount'], $product->images, $onFailComment); 437 | $this->assertCount($expectedResult['relationsCount'], $product->getRelated('images', true), $onFailComment); 438 | 439 | $relatedIds = array(); 440 | foreach($product->getRelated('images', true) as $model) { 441 | $relatedIds[] = $model->primaryKey; 442 | } 443 | 444 | $this->assertTrue(in_array($expectedResult['oldId'], $relatedIds) == $expectedResult['containsOld']); 445 | } 446 | 447 | public function lazyDeleteProvider() 448 | { 449 | return array( 450 | // required=true 451 | array( 452 | 'result' => array('relationsCount' => 1, 'oldId' => 1, 'containsOld' => false), 453 | 'relationOptions' => array('required' => false), 454 | 'relationAttribute' => array( 455 | array( 456 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 457 | 'file' => 'somefile.txt' 458 | ), 459 | ), 460 | 'comment' => '1 new file' 461 | ), 462 | array( 463 | 'result' => array('relationsCount' => 2, 'oldId' => 1, 'containsOld' => true), 464 | 'relationOptions' => array('required' => false), 465 | 'relationAttribute' => array( 466 | array( 467 | 'id' => 1, 468 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 469 | 'file' => 'somefile.txt' 470 | ), 471 | array( 472 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 473 | 'file' => 'somefile2.txt' 474 | ), 475 | ), 476 | 'comment' => '1 old, 1 new file' 477 | ), 478 | array( 479 | 'result' => array('relationsCount' => 2, 'oldId' => 1, 'containsOld' => false), 480 | 'relationOptions' => array('required' => false), 481 | 'relationAttribute' => array( 482 | array( 483 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 484 | 'file' => 'somefile.txt' 485 | ), 486 | array( 487 | 'object_type' => Attachment::OBJECT_TYPE_PRODUCT_IMAGE, 488 | 'file' => 'somefile.txt' 489 | ), 490 | ), 491 | 'comment' => '2 new files' 492 | ), 493 | array( 494 | 'result' => array('relationsCount' => 0, 'oldId' => 1, 'containsOld' => false), 495 | 'relationOptions' => array('required' => false), 496 | 'relationAttribute' => array( 497 | ), 498 | 'comment' => '0 files' 499 | ), 500 | ); 501 | } 502 | 503 | /** 504 | * WFormRelationHasMany::delete 505 | */ 506 | public function testDelete() 507 | { 508 | $product = $this->_getProductWithRelation(1); 509 | 510 | $this->assertCount(1, $product->images); 511 | $id = $product->images[0]->primaryKey; 512 | $this->assertTrue($product->delete()); 513 | $this->assertEmpty(Attachment::model()->findByPk($id)); 514 | } 515 | 516 | /** 517 | * WFormRelationHasMany::delete 518 | */ 519 | public function testDeleteWithoutCascade() 520 | { 521 | $product = $this->_getProductWithRelation(1, array('cascadeDelete' => false)); 522 | 523 | $this->assertCount(1, $product->images); 524 | $id = $product->images[0]->primaryKey; 525 | $this->assertTrue($product->delete()); 526 | $this->assertNotEmpty(Attachment::model()->findByPk($id)); 527 | } 528 | 529 | /** 530 | * @param null $id 531 | * @param array $relationOptions 532 | * @return Product 533 | */ 534 | protected function _getProductWithRelation($id = null, $relationOptions = array()) 535 | { 536 | $product = $id ? Product::model()->findByPk($id) : new Product(); 537 | $product->attachBehavior('wform', array( 538 | 'class' => 'WFormBehavior', 539 | 'relations' => array( 540 | 'images' => $relationOptions, 541 | ), 542 | )); 543 | $product->afterConstruct(new CEvent($product)); 544 | 545 | return $product; 546 | } 547 | } --------------------------------------------------------------------------------