Yii 1.1: esaverelatedbehavior

ESaveRelatedBehavior enables ActiveRecord models to save HAS_MANY relational active records and MANY_MANY relations.
47 followers

The ESaveRelatedBehavior enables ActiveRecord models to save HAS_MANY relational active records and MANY_MANY relations along with the main model.

It provides two new methods:

saveWithRelated()
Saves the model and all specified related models
returns false if any model has not been saved

saveRelated()
Saves the specified related models only and
returns false if any model has not been saved

Features:
- handles many_many and has_many relations
- fills has_many relations with validated objects
- allows selection of scenario for has_many relations
- processes everything within a transaction
- relations can be set with data arrays or object arrays
- only specified relations are saved
- massive assignment works, since data is set on relation directly
- adds 2 methods to activeRecord, no use of beforeSave/afterSave,
therefore the behavior can be added to all activeRecord classes,
it will only do its work when the new methods are called
- uses standard SQL
- NO HANDLING OF COMPOSITE KEYS yet

Requirements

Yii Framework 1.1 or above PHP version 5.3 or above

Usage

Extract the release file under 'protected/components'
Add the following code to your models:

public function behaviors(){
        return array('ESaveRelatedBehavior' => array(
         'class' => 'application.components.ESaveRelatedBehavior')
     );
}

MANY_MANY relations

When a MANY_MANY relation is defined in the relations() function,
instances of the foreign model can be related while saving the model.

Examples:

post model defines many_many relation with categories:

'categories'=>array(
    self::MANY_MANY, 'category', 'post_category(post_id, category_id)'
)

To add new rows to the m:n table (post_category) do:

$post = new post();
$post->categories = category::model()->findAll();
$post->saveWithRelated('categories');

This saves the new post and updates the m:n table with every category.

The typical usage to assign data send from a checkBoxList would be:

$post->categories = array(1, 2, 3);
$post->saveWithRelated('categories');

So you can either pass an array of objects or an array of the primary keys of the foreign table.

You can also pass a single object or a single primary key:

$post->categories = category::model()->findByPk(1);
$post->categories = 1;
$post->saveWithRelated('categories');

If you are saving several relations you would do:

$post->saveWithRelated( array('relation1', 'relation2') );

If saving was not successful, then $post->categories will return the data that was set.
If saving was successful, then the relation will return the related models.

When updating a model then all entries in the m:n table for this model are first deleted.
(Howto prevent deletion see section "advanced usage")

HAS_MANY relations

When a HAS_MANY relation is defined in the relations() function, instances of the foreign model can be added while saving the model.

Examples:

author model defines has_many relation with posts:

'posts'=>array(
    self::HAS_MANY, 'post', 'author_post(author_id, post_id)'
)

To save a new author along with 2 posts you can now do:

$author = new author();
$author->posts = array(new post(), new post());
$author->saveWithRelated('posts');

This saves the new author together with two (empty) posts.

The typical usage though would be to assign data send from a tabular form. Each array entry represents the data for one post:

$author->posts = array(
   array(
       'title' => 'My first post',
       'content' => 'This is my first post.'
   ),
   array(
       'title' => 'My second post',
       'content' => 'This is my second post.'
   )
);
$author->saveWithRelated('posts');

So similar to many_many relations you can either pass an array of objects or an array of data that represents the related objects.

You can also pass a single object:

$author->posts = new post();
$author->saveWithRelated('posts');

If you are saving several relations you would do:

$author->saveWithRelated( array('relation1', 'relation2') );

If saving was not successful, then $author->posts will return an array of validated post models which can be used to display the tabular input form along with validation messages.
If saving was successful, then the relation will return the related models.

When updating a model then all records related to this model are first deleted.
(You can prevent deletion: see section "advanced usage")

You can also specify the scenario to be used for the insertion of the related models:

$model->saveWithRelated( array('relationName1' => array('scenario' => 'special')));

You can specify the scenario to be used for the insertion of the last related model:

$model->saveWithRelated( array('relationName1' => array('lastScenario' => 'special')));

This can be useful if you have to validate aggregated values.
Say you have a field A in the model and field B in the related models.
Now you would like to check if the sum of values in field B equals the value in field A.
You can do this with an SQL statement in a validator which is active for the last scenario.

Advanced usage

To save the related data only do:

$model->saveRelated( array('relationName1','relationName2') );

If you do not want to delete existing related records before insertion do

$model->saveWithRelated( array('relationName1' => array('append' => true)));

You will have to make sure that the new records are not dublicates of existing records, the behavior does not handle that.

Update

v1.6
Fixed bug that occurred when saving MANY_MANY relations when parent object had errors

v1.5
Added parameter 'lastScenario'

v1.4
Small code change to make it compatible to PHP versions < 5.3.
This has not been checked on all versions though, so I left requirements at PHP >= 5.3

v1.3
Corrected error: When using saveWithRelated for new models they can contain validation errors and hence will not be saved, so their id is not known. Therefore no models can be related yet. MANY_MANY-relations will now not be saved in this case. For HAS_MANY relations the models will only be validated (no saving attempt,because the foreign key would be missing).

v1.2
Corrected error: when checking for a model class for a mn-table the autoloader caused an exception when the class file did not exist

v1.1
- added support for scenario selection when adding has_many relations
- changed the way options are specified when saving relations
(now an array is used so that 'append' and 'scenario' can both be specified

v1.0
- first version

Total 20 comments

#17883 report it
williamquitian at 2014/08/05 05:07pm
Bug

when the model has another connection to the database

protected function saveR($relations, $saveModel)
    {
        $connection = $this->owner->dbConnection;
 
        $result = true; $t = false;
        if (!$connection->currentTransaction) { // only start transaction if none is running already
            $t = $connection->beginTransaction();
        }
#17480 report it
Tahir Yasin at 2014/06/19 07:01am
Detailed tutorial for saving multiple related models using ESaveRelatedBehavior component
#15991 report it
sluderitz at 2014/01/07 03:22pm
Anser to comments

@Anonymous Joe Thank you for the bug report, today I got the same problem.

@Pasta Thank you for your suggestion to fix the bug. The code change does remove the bug but will also remove a feature: On HAS_MANY relations the saved related objects should still be validated even when the parent object had errors on saving.

I fixed the bug and uploaded a new version

#13414 report it
Pasta at 2013/05/28 05:13am
bug

Anonymous Joe, i have the same bug.

I removed the bug so

ESaveRelatedBehavior.php line 262

} elseif ($relation instanceof CHasManyRelation && $this->owner->primaryKey) {

http://badphpcoder.blogspot.ru/2013/05/yii-savewithrelated-bug.html

but I do not know how much this decision right

#11406 report it
Anonymous Joe at 2013/01/11 10:24am
Error when saving

Hi, I'm having error saving. My table are as below:-

articles
- id (PK)
- content
 
tags
- id (PK)
- name
 
article_tag
- article_id (PK)
- tag_id (PK)

My Article model has the following relation:-

'tags' => array(self::MANY_MANY, 'Tag', 'article_tag(tag_id, article_id)'),

However, when I save, I get the following error.

Table "tags" does not have a column named "article_tag(tag_id, article_id)".

Is there something I'm missing here.

#11264 report it
sluderitz at 2013/01/02 10:42am
Answer to comments

@undsof: For this extension it actually was desired behavior to only save safe attributes for the related models. This data usually comes from a web form. But you are right, there could be situations in which you would like to set extra attributes on the array that do not come from user input. If I'll ever need that functionality I will include it in the extension. For the moment I will keep it simple.

#11202 report it
undsoft at 2012/12/26 01:41pm
Scenario

When copying the models, it may be a good idea to copy 'scenario' property, because there may be some work scenario specific done in beforeSave().

#11201 report it
undsoft at 2012/12/26 01:18pm
Safe attributes.

There's a problem with line 276.

$obj->attributes = is_object($value) ? $value->attributes : $value;

This means that only safe attributes will be saved, which is probably not desired behaviour. I may have other attributes to save.

#10405 report it
jengtong at 2012/10/26 01:47am
Not Compatible with Table Prefix

If the linking table is a table name with prefix, say {{some_table}}, your regular expression with only retrieve the "some_table" without "{{}}" and result error in getting table name. I suggest to change the regex to this:

'/^\s({{0,2}\s.+?\s}{0,2})\s(\s(.+)\s,\s(.+)\s)\s*$/s'

This will include the "{{}}".

#8944 report it
sluderitz at 2012/07/09 09:18am
Answers to comments

@sucotronic (Bug when saving a new record) This has been fixed last year with version v1.3 (see update comments) Please check if you are using the latest version

@melengo (Not Run) I am using the extention with Yii 1.1.10 and did not have problems so far. Could you explain the errors that you get?

#8906 report it
melengo at 2012/07/06 02:16pm
Not Run

CAN NOT go smoothly IN Yii VERSION 1.1.10, PLEASE upgrading EXTENSION

#7772 report it
cass at 2012/04/17 03:45pm
Great!

Fab extension. Well done, very handy!

#7740 report it
Vitalets at 2012/04/13 11:06am
Some corrections

Hi! Extension looks quite good, I've used something similar before, but by myself.

There are two things I modified:

1.As it was mentioned below: you get ALL the foreign models! what if there are 100,000?
I added param ensure. If you don't want to check existance of related models by loading them all, just use this:

$model->saveWithRelated( array('relationName1' => array('ensure' => false)));

2.When you use append param you will have to make sure that the new records are not dublicates of existing records, the behavior does not handle that.
I've modified code, so now rows are not dublicated in nmTable.

Modified code (to be inserted into ESaveRelatedBehavior.php starting from line 209):

if ($relation instanceof CManyManyRelation && !$this->owner->isNewRecord) {
                if (preg_match( // extract infos about mn linking table
                    '/^\s*\{{0,2}\s*(.+?)\s*\}{0,2}\s*\(\s*(.+)\s*,\s*(.+)\s*\)\s*$/s',
                    $relation->foreignKey, $matches
                )) {
                    $info = array(
                        'mnTable' => $matches[1],
                        'mnFk1' => $matches[2],
                        'mnFk2' => $matches[3]
                    );
                } else {
                    throw new CException("Unable to get table and foreign key information from MANY_MANY relation definition (".$relation->foreignKey.")");
                }
 
                //ADDED: config param 'ensure': check for existance of related models or not. Default: true
                $ensure = isset($config['ensure']) ? $config['ensure']: true;
                $possibleModels = array();
                if($ensure) {
                    $model = new $relation->className;
                    $possibleModels = $model->findAll(new CDbCriteria(array( // find all models, that can be related (used to make sure only existing records are linked)
                        'index' => $model->getMetaData()->tableSchema->primaryKey
                    )));
                } 
                //END ADDED
 
                if (!@$config['append']) {
                    $criteria = new CDbCriteria;
                    $criteria->compare($info['mnFk1'], $this->owner->primaryKey);
                    $commandBuilder->createDeleteCommand($info['mnTable'], $criteria)->execute(); // delete current links to related model
                } else {
                    //ADDED: select keys for existing related records
                    $criteria = new CDbCriteria;
                    $criteria->compare($info['mnFk1'], $this->owner->primaryKey);
                    $criteria->select = $info['mnFk2'];
                    $nmRows = $commandBuilder->createFindCommand($info['mnTable'], $criteria)->queryColumn();
                    //END ADDED
                }
 
                $hasMnTableClass = @class_exists($info['mnTable']);
                foreach($data as $id) {
                    if (is_object($id)) { // get id if object was given
                        $id = $id->primaryKey;
                    }
 
                    //ADDED: if requred - check that related model exists
                    if($ensure && !array_key_exists($id, $possibleModels)) continue;
 
                    //if record in nm already exists --> do not insert it twice
                    if(isset($nmRows) && in_array($id, $nmRows)) {
                       unset($possibleModels[$id]);
                       continue;
                    }    
                    //END ADDED                    
 
                    if ($hasMnTableClass) { // use class for inserting records into mn linking table if it exists
                        $obj = new $info['mnTable'];
                        $obj->attributes = array(
                            $info['mnFk1'] => $this->owner->primaryKey,
                            $info['mnFk2'] => $id
                        );
                        if (!$obj->save()) {
                            $result = false;
                        }
                    } else { // otherwise make and execute insert command
                        $commandBuilder->createInsertCommand(
                            $info['mnTable'],
                            array(
                                $info['mnFk1'] => $this->owner->primaryKey,
                                $info['mnFk2'] => $id
                            )
                        )->execute();
                    }
                    unset($possibleModels[$id]); // this makes sure that id will not be inserted twice if submitted data attempts to do so.
 
                }
                if ($result) {
                    unset($this->owner->$relationName); // saving was successful, clear the relation, so accessing it will return the related records
                }
 
            } elseif ($relation instanceof CHasManyRelation) { // Handle has_many relations
...
}

It would be nice if author will add these features to extension!

cheers

#7614 report it
sucotronic at 2012/04/03 07:04am
Bug when saving a new record

If you use the 'saveWithRelated' method, and the model you're saving has errors, the behaviour is going to crash in the dbcriteria construction. To avoid this, you've encapsulate the foreach just after the second if clause:

...
if ($saveModel && !$this->owner->save()) { // save owner model if saveWithRelated was called
    $result = false;
} else {
    foreach ((array)$relations as $key => $relationName) { 
...
#6874 report it
sluderitz at 2012/02/10 10:26am
@jackfiallos (Added Delete Self record...)

Thanx for the feedback. I have not included delete methods, because many2many-relations and hasMany-related objects can be delete by passing array() to the relation. For deleting the object itself and the related objects I say: When using a relational database, then you can setup the foreign keys in a way that related objects are automatically deleted as well.

#6873 report it
sluderitz at 2012/02/10 10:17am
@el chief, requests

Thanx for feedback

  1. For many_many relations this extension only manages the data in the connecting mn-table. It does not handle creation of the related objects (if it did, then passing associative arrays could be useful). Personally I only ever pass an array of primary keys (like array(1,6,9) - usually coming from a checkboxlist) to this kind of relation.

  2. You can set a single object for has_many relations. Currently there is no way to check for having passed exactly one object. You can also set the relation to array() to delete all related objects.

  3. There are some reasons why I get all foreign models: The extension does not throw an error, when an invalid object-id is passed, the id is just ignored. If you do insert a wrong id in a DBMS like Postgres, a foreign key error will be thrown and the transaction automatically rolls back, which was not the kind of behaviour I wanted. The extension is mainly build for handling user input, i.e. the user clicking a few checkboxes to select related objects. When handling many records, this could be a performance issue, the the extension would have to be changed. Instead of getting all related objects, a SQL statement could be generated to get all valid ids. I may actually do this, its just more lines of code. Or as you suggested, the DB FK checks could be used.

  4. Rollback for transaction errors is performed on line 290 (v1.5)

#6666 report it
grigori at 2012/01/27 05:50am
procedural style does not fit the overall yii code style

I prefer https://github.com/yiiext/with-related-behavior It has a generic object mapping for related entities and a common validation routine.

#6454 report it
el chief at 2012/01/11 04:04pm
Nice extension...a few requests
  1. you can set an associative array for a has_many, but not many_many. why not? this would be easier when dealing with json input

  2. adding hasone functionality would be good too. hasone is essentially hasmany, but you would set a single model/object/associative array instead of an array of them. should also deal with null hasones

  3. line 220, you get ALL the foreign models! what if there are 100,000? you could just let the database use its foreign key checks to ensure this.

  4. doesn't rollback transaction if there is a database error?

thanks!

el_chief

#5332 report it
sluderitz at 2011/10/05 11:06am
Composite keys support

I am not working on composite keys support at the moment. So far I never needed it, I have cases where it could be used, but I prefer to introduce an extra primary key (usualy an id column) and set a unique constraint on the other multi-column PK. This is then so much easier to handle.

#5297 report it
MichaelH at 2011/10/02 11:05am
@sluderitz

yes php versions below 5.3 will give an arror when using a double double column (::) after a variable name

$class = new $relation->className; seems to be working on php 5.2 (havn't tested any other version)

have you done any work on handeling composite keys? i am looking for thisfunctionality an will start adding it to your extension if no work is done yet.

grtz Michael

Leave a comment

Please to leave your comment.

Create extension