Search code examples
cakephpcakephp-2.x

Get original associations after using Containable behavior in CakePHP


Background: CakePHP 2.6.3. A pretty stable app. New behavior (MyCustomBehavior) created to output some extra info. I have a Model MyModel acting as Containable (defined in AppModel) and then MyCustom (defined in MyModel). MyCustomBehavior is written in a way that it needs to work with the Model's associations with other Models in my app.

Problem: Whenever I contain related models in my find() call of MyModel, I cannot get a complete list of MyModel associations because Containable behavior unbinds the models that are not contained. However, if I don't set contain in my find() options or set 'contain' => false everything works as expected.

Sample MyModel->belongsTo

public $belongsTo = array(
    'MyAnotherModel' => array(
        'className' => 'MyAnotherModel',
        'foreignKey' => 'my_another_model_id',
        'conditions' => '',
        'fields' => '',
        'order' => ''
    ),
    'Creator' => array(
        'className' => 'User',
        'foreignKey' => 'user_id',
        'conditions' => '',
        'fields' => '',
        'order' => ''
    ),
    'Approver' => array(
        'className' => 'User',
        'foreignKey' => 'approver_id',
        'conditions' => '',
        'fields' => '',
        'order' => ''
    ),
    'Status' => array(
        'className' => 'Status',
        'foreignKey' => 'status_id',
        'conditions' => '',
        'fields' => '',
        'order' => ''
    ),
);

Sample find()

$this->MyModel->find('all', array(
    'fields' => array(...),
    'conditions' => array(...),
    'contain' => array('Approver', 'Status')
));

Result of MyModel->belongsTo in MyCustomBehavior::beforeFind()

$belongsTo = array(
    'Approver' => array(
        ...
    ),
    'Status' => array(
        ...
    ),
);

Expected MyModel->belongsTo in MyCustomBehavior::beforeFind()

$belongsTo = array(
    'MyAnotherModel' => array(
        ...
    ),
    'Creator' => array(
        ...
    ),
    'Approver' => array(
        ...
    ),
    'Status' => array(
        ...
    ),
);

Obvious solution: One dumb way to solve the problem is to simply set Containable behavior in MyModel instead of AppModel to control the order of loading the behaviors, i.e., public $actsAs = ['MyCustom', 'Containable']. This solution is not the best because there may be other behaviors in other models that depend on Containable, so the order of Containable needs to set in each model in app explicitly and hope that I didn't break the app somewhere.

A similar(related) question was asked on SO here but has no answers.

Need a more robust solution that can address the needs of MyCustomBehavior without having to make changes in rest of the app and looking out for any unexpected behavior.


Solution

  • Attempt 1 (Imperfect, error prone):

    One way to recover all the original associations is to call

    $MyModel->resetBindings($MyModel->alias);
    $belongsToAssoc = $MyModel->belongsTo;    // will now have original belongsTo assoc
    

    However, this approach it may fail (SQL error 1066 Not unique table/alias) to work correctly if I had used joins in my find call (using default alias) to explicitly join to an already associated model. This is because Containable will also attempt to join all these tables restored by resetBindings() call resulting in join being performed twice with same alias.

    Attempt 2 (perfect#, no known side effects##):
    Further digging through the core Containable behavior and docs led me to object $MyModel->__backOriginalAssociation and $MyModel->__backAssociation (weird enough that ContainableBehavior never used $__backContainableAssociation as the variable name suggests) that was created and used by this behavior to perform resetBindings(). So, my final solution was to simply check if Containable is enabled on my modal (redundant in my case because it is attached in AppModel and is never disabled or detached throughout the app) and check if the object is set on the model.

    // somewhere in MyCustomBehavior
    private function __getOriginalAssociations(Model $Model, $type = 'belongsTo') {
        if(isset($Model->__backAssociation['belongsTo']) && !empty($Model->__backAssociation['belongsTo'])) {   // do an additional test for $Model->Behaviors->enabled('Containable') if you need
            return $Model->__backAssociation[$type];
        }
    
        return $Model->$type;
    }
    
    public function beforeFind(Model $Model, $query) {
        // somewhere in MyCustomBehavior::beforeFind()
        ...
        $belongsToAssoc = $this->__getOriginalAssociations($MyModel, 'belongsTo');    // will now have original belongsTo assoc
        ...
    return $query;
    }
    

    $__backAssociation holds model associations temporarily to allow for dynamic (un)binding. This solution can definitely be further improved by merging results of $Model->belongsTo and $Model->__backAssociation['belongsTo'] (or hasMany, hasOne, hasAndBelongsToMany) to include any models that were bound on the fly. I don't need it, so I will skip the code for merging.


    # Perfect for my own use case and my app setup.
    ## No side effects were found in my testing that is limited by my level of expertise/skill.
    Disclaimer: My work in this post is licensed under WTF Public License(WTFPL). So, do what the f**k you want with the material. Additionally, I claim no responsibility for any financial, physical or mental loss of any kind whatsoever due to the use of above material. Use at your own risk and do your own f**king research before attempting a copy/paste. Don't forget to take a look at cc by-sa 3.0 because SO says "user contributions licensed under cc by-sa 3.0 with attribution required." (check the footer on this page. I know you never noticed it before today! :p)