Many to Many

Hi,

I know this topic has been beaten to death but I still can’t find a conclusive answer for dealing with many to many relationships.

I have two tables (and models) Portfolio & Service. There is a link table PortfolioService that has PortfolioId and ServiceId as PKs.

I am using the CAdvancedArBehavior behavior to handle the Many to Many saving. All is working well. But…

I am using checkBoxList to render a list of services that a user can check on the portfolio. It works well except when the validation fails for submitting the form. The checkBoxList is cleared and the posted values are not repopulated.


<?php echo $form->labelEx($model, 'services'); ?>

        <?php echo $form->checkBoxList($model, 'services',

            CHtml::listData(Service::model()->findAll(), 'id', 'name'),

            array('attributeitem' => 'id', 'checkAll' => 'Check All')); ?>

        <?php echo $form->error($model, 'services'); ?>



I have tried using Relation Widget but I don’t want to use it in my app.

Question: is there a way to use checkBoxList using CAdvancedArBehavior? If not, can you please point me to a "complete" reference for manually managing the Many to Many relationships - I.e. using a 3rd Model to link the 2 parent models.

Thanks!

This may be the same problem you would encounter in an update action.

This post has an explanation.

In your case try this:

Portfolio.php




  public $serviceIds=array();

  ...

  public function afterFind()

  {

    if(!empty($this->services))

    {

      foreach($this->services as $n=>$service)

        $this->serviceIds[]=$service->id;

    }

  }



PortfolioController.php




  ...

  $model->services = $_POST['Portfolio']['serviceIds'];

  ...



_form.php




<?php

  echo $form->checkBoxList($model, 'serviceIds',

    CHtml::listData(Service::model()->findAll(), 'id', 'name'),

    array('attributeitem' => 'id', 'checkAll' => 'Check All'));

?>



/Tommy

Hi Tommy,

Thanks. I had to play around a little to get it to work. FYI, I have to set both $model->services & $model->serviceIds in the controller. It is a little ugly and I’ll be looking for a cleaner solution. I will post back anything that I find.

Portfolio Model




    public $serviceIds = array();


    public function afterFind()

    {

        if (!empty($this->services))

        {

            foreach ($this->services as $n => $service)

                $this->serviceIds[] = $service->id;

        }


        parent::afterFind();

    }



Portfolio Controller




if (isset($_POST['Portfolio']))

        {

            

            $model->attributes = $_POST['Portfolio'];

            $model->services = $_POST['Portfolio']['serviceIds'];

            $model->serviceIds = $_POST['Portfolio']['serviceIds'];

            

            if ($model->save())

                $this->redirect(array('portfolio/admin'));

        }



_form View




<div class="row oneLineLabel">

        <?php echo $form->labelEx($model, 'services'); ?>

        <?php echo $form->checkBoxList($model, 'serviceIds',

            CHtml::listData(Service::model()->findAll(), 'id', 'name'),

            array('checkAll' => 'Check All')); ?>

        <?php echo $form->error($model, 'services'); ?>

    </div>



Thanks,

Matt

You can add a safe validator (or some other validator) for serviceIds




  public $serviceIds = array();


  public function afterFind()

  {

    ...

  }

  ...

  public function rules()

  {

    return array(

      ...

      array('serviceIds', 'safe'),

    );

  }



then change the controller code to:




if (isset($_POST['Portfolio']))

{

  $model->attributes = $_POST['Portfolio'];

  $model->services = $model->serviceIds;

  if ($model->save())

    $this->redirect(array('portfolio/admin'));

}



/Tommy

Works like a charm! Thanks!!!!!

Portfolio Model




    public $serviceIds = array();


    public function afterFind()

    {

        if (!empty($this->services))

        {

            foreach ($this->services as $n => $service)

                $this->serviceIds[] = $service->id;

        }


        parent::afterFind();

    }


    public function rules()

    {

        // NOTE: you should only define rules for those attributes that

        // will receive user inputs.

        return array(

            array('status, description, title, url, services', 'required'),

            array('status', 'numerical', 'integerOnly' => true),

            array('title, url', 'length', 'max' => 255),

            array('serviceIds', 'safe'),

            // The following rule is used by search().

            // Please remove those attributes that should not be searched.

            array('id, status, description, title, url', 'safe', 'on' => 'search'),

        );

    }



Portfolio Controller




if (isset($_POST['Portfolio']))

        {  

            $model->attributes = $_POST['Portfolio'];

            $model->services = $model->serviceIds;

            

            if ($model->save())

                $this->redirect(array('portfolio/admin'));

        }



_form View




<div class="row oneLineLabel">

        <?php echo $form->labelEx($model, 'services'); ?>

        <?php echo $form->checkBoxList($model, 'serviceIds',

            CHtml::listData(Service::model()->findAll(), 'id', 'name'),

            array('checkAll' => 'Check All')); ?>

        <?php echo $form->error($model, 'services'); ?>

    </div>



Much appreciated!

Matt

Thank you a lot for this elegant solution!

I need to do similar thing to that u have described in the first post, and found this solution in Google. It saved me a lot of time.

This post was execellent because I was grinding my teeth over the many to many relationship for checkboxes and saving the form data and this post just solved in 15min.

I’m not sure how this exactly works but it works.

Great job and great sharing community!!!

Hey! I’m new to PHP but already love this framework.

I have to do practically the same implementation of this example, a Restaurant can have many Services, and viceversa.

However, for some reason, the application is saving the Restaurant data, but not the Services. It doesn’t throw any error, and doesn’t seems to be entering the afterFind() function.

Any ideas why could this be happening?

The afterFind() function should be called automatically, right?

Many thanks in advance for your help!

Esteban.

Hi ecairol,

There could be a few reasons for that.

[list=1]

[*]Have you installed CAdvancedArBehavior?




    public function behaviors()

    {

        return array(

            'CAdvancedArBehavior' => array(

                'class' => Yii::getPathOfAlias('behaviors') . '.CAdvancedArBehavior',),

    }



[*]Are your relations setup correctly?

Portfolio:




    public function relations()

    {

        // NOTE: you may need to adjust the relation name and the related

        // class name for the relations automatically generated below.

        return array(

            'services' => array(self::MANY_MANY, 'Service', 'tbl_portfolio_service(portfolio_id, service_id)'),

        );

    }



Service:




    public function relations()

    {

        // NOTE: you may need to adjust the relation name and the related

        // class name for the relations automatically generated below.

        return array(

            'portfolios' => array(self::MANY_MANY, 'Portfolio', 'tbl_portfolio_service(service_id, portfolio_id)'),

        );

    }



[/list]

Matt

Haha, that was it Matt. I’ve just installed the extension and it worked.

Sorry, I’m pretty new to this, but thanks a lot for your help!!

Esteban,

By the way, how would you do if the Many-to-Many table (in this case PortfolioService) had another column besides the two primary keys?

When would you made the INSERT for this third column, on the Controller (just after the $model.save()) or the Model (inside the afterFind() function)?

Good question, not sure; I haven’t needed that yet. I will take a look soon and get back to you.

Matt

Regarding extra columns in the mn-table:

The best solution I found was to make a model for the mn-table as well and setup a HAS_MANY relation in the main model. Then you’ll have to use a behaviour that handles saving of HAS_MANY relations.

Also checkout my new extension, may be it helps: http://www.yiiframework.com/extension/esaverelatedbehavior/

For the extra attribute I am using a different approach for performance reasons, so that the ids are only setup when the attribute is actually accessed. Using afterFind always triggers an extra database query.

Here you go:




public $_serviceIds = null;

public function getServiceIds() {

    if($this->_serviceIds === null) {

        $this->_serviceIds = array();

        if(!$this->isNewRecord) {

            foreach($this->services as $service)

                $this->_serviceIds[]=$service->primaryKey;

        }

    }

    return $this->_serviceIds;

}

public function setServiceIds($value) {

    $this->_serviceIds = $value;

}



Hello Matt, thanks for this post, I’ve lost hours trying to make these relations work until I found it.

But now the ids of selected checkboxes are not being sent to the create action in the controller, i already checked my relations and installed CAdvancedArBehavior. Anything else I should check?

Thanks in advance!

In my case:

Artist




'events'=>array(self::MANY_MANY, 'Event', 'event_artist(artist_id, event_id)'),



Event




'artists'=>array(self::MANY_MANY, 'Artist', 'event_artist(event_id, artist_id)'),



Behaviour




return array( 

			'CAdvancedArBehavior' => array(

				'class' => 'application.extensions.CAdvancedArBehavior'));



_view




<div class="row">

	<?php echo $form->labelEx($model, 'events'); ?>

	<?php echo $form->checkBoxList($model, 'eventsIds', CHtml::listData(Event::model()->findAll(), 'id', 'name'), array('checkAll' => 'Check All')); ?>

    <?php echo $form->error($model, 'events'); ?>

</div>



HTML output




<input id="Artist_eventsIds_1" type="checkbox" name="Artist[eventsIds][]" value="13">



Controller




$model->attributes = $_POST['Artist'];

$model->image=CUploadedFile::getInstance($model,'image');

$model->events = $model->eventsIds;

			

Yii::log('##### Selected events ' . $model->eventsIds); // this is empty



Have you setup your model with the following?




    public $serviceIds = array();


    public function afterFind()

    {

        if (!empty($this->services))

        {

            foreach ($this->services as $n => $service)

                $this->serviceIds[] = $service->id;

        }


        parent::afterFind();

    }


    public function rules()

    {

        // NOTE: you should only define rules for those attributes that

        // will receive user inputs.

        return array(

            array('serviceIds', 'safe'),

        );

    }



Thank you so much! Don’t you think to make a short tutorial? It’ll be easier to understand newbies like me

Seems to work quite well, with one major problem;

Let’s say you want to be able to generate a checkBoxList(or any selectable list) on your services form as well as the Portofolio form.

the code for this would look something like this:

Service Model:




    public $portofolioIds = array();


    public function afterFind()

    {

        if (!empty($this->portofolios))

        {

            foreach ($this->portofolios as $n => $portofolio)

                $this->potofolioIds[] = $portofolio->id;

        }


        parent::afterFind();

    }


    public function rules()

    {

        // NOTE: you should only define rules for those attributes that

        // will receive user inputs.

        return array(

            array('status, description, title, url, services', 'required'),

            array('status', 'numerical', 'integerOnly' => true),

            array('title, url', 'length', 'max' => 255),

            array('portofolioIds', 'safe'),

            // The following rule is used by search().

            // Please remove those attributes that should not be searched.

            array('id, status, description, title, url', 'safe', 'on' => 'search'),

        );

    }



Service Controller:




if (isset($_POST['Service']))

        {  

            $model->attributes = $_POST['Service'];

            $model->services = $model->portofolioIds;

            

            if ($model->save())

                $this->redirect(array('service/admin'));

        }



And finally the _form View




<div class="row oneLineLabel">

        <?php echo $form->labelEx($model, 'portofolios'); ?>

        <?php echo $form->checkBoxList($model, 'portofolioIds',

            CHtml::listData(Portofolio::model()->findAll(), 'id', 'name'),

            array('checkAll' => 'Check All')); ?>

        <?php echo $form->error($model, 'portofolios'); ?>

    </div>



The problem seems that this generates an infinite loop of afterFind functions, how would one go about resolving this.

Take into account that I am not an expert with yii.

This code works like a charm …!

But during edit how do i remain old checkboxes remain selected??

Perfect!!!

Works like a charm! Thanks!!