Mini Tutorial: Patching Enterprise_TargetRule

Quick Note:

I made use of ellipses (…) many times in this tutorial in order to save space. Please note that an ellipsis denotes a block of code that has been left out for the sake of clarity and readability, but is vital to the class or function from which it has been truncated.

The Problem:

Enterprise_TargetRule, the module behind ‘Rule-Based Product Relations’ has caused issues for many Magento users in the past. The most often occurring issue was that the products populated by the TargetRule would always be the same (x) products across the site, regardless of whether or not they actually fit with the rule’s specified actions. It turns out that this problem was popping up only in a given type of rule. One in which the actions specify to match products based on the category of the matched product.

When a rule is specified to display products that are the “same as the matched [attribute],” Magento will apply conditions to the query specified by the rule. For example, if you were to choose to display products with the same Price as the matched product, Magento would build a query based on the product’s price. Category is a different type of attribute in that a product can have multiple categories. In that case, Magento would display products based on ALL categories of the product, and (worse), will display those first matching category before those in the deeper ones.

To demonstrate the root of the issue, we can peek into app/code/core/Enterprise/TargetRule/Model/Resource/Index.php, and come across a function called _prepareRuleActionSelectBind() that is responsible for binding the rule’s actions into data for the database query by returning a key => value array for the adapter to interpret.

    $k = $bindData['bind'];
    $v = $object->getProduct()->getDataUsingMethod($bindData['field']);

In this case, $bindData['field'] will be ‘category_ids’, and therefore, $v will equal an array of ALL Category Ids to which the product belongs, with the earliest created category being selected first. Through that process, the query will almost always select the same products (the first products from the highest level categories) across the whole store!

Further, when the products are finally selected (from the wrong category), they will also be ordered by entity_id, meaning the selection will return the same x products every time. This is a definite issue in that the point of related products and up-sells is to bring the customer a variety of similar products.

Related: Mini Tutorial: How to Add a Category Landing Page in Magento

The Solution:

The ideal solution to the issue at hand would be to display products from the matched category in the rule conditions, (i.e. the ones selected in the Products To Match, as shown below).

Edit-Rule-Rleated-By-Category

However, it is possible to specify products to match without using a category as a condition. A rule that matches products with a Price greater than (x) could also be told to display products with matching the category. As a result, a threefold logic check to find the ideal category from which to display products is necessary.

Related: Mini Tutorial: Alternative Configurable Products Display in Magento

Module Setup:

Before we get started, we’ll need to create a custom module. app/etc/modules/Demac_TargetRule.xml:

<?xml version="1.0"?>
<config>
    <modules>
        <Demac_TargetRule>
            <version>0.1.0</version>
        </Demac_TargetRule>
    </modules>
    <global>
        <models>
            <enterprise_targetrule_resource>
                <rewrite>
                    <index>Demac_TargetRule_Model_Resource_Index</index>
                </rewrite>
            </enterprise_targetrule_resource>
        </models>
        <helpers>
            <demac_targetrule>
                <class>Demac_TargetRule_Helper</class>
            </demac_targetrule>
        </helpers>
    </global>
</config>

app/code/Local/Demac/TargetRule/etc/config.xml:

<?xml version="1.0"?>
<config>
    <modules>
        <Demac_TargetRule>
            <active>true</active>
            <codePool>local</codePool>
        </Demac_TargetRule>
    </modules>
</config>

Finally, app/code/Local/Demac/TargetRule/Model/Resource/Index.php:

<?php
class Demac_TargetRule_Model_Resource_Index extends Enterprise_TargetRule_Model_Resource_Index {

}

Category Selection:

Now, we’re ready to figure out exactly which category/categories from which to display products.

1) First, the product’s categories are compared to the categories specified by the rule in the “products to match” section, if category is one of the conditions specified. If any of the products’ categories match, the displayed products will be selected from the matching categories that are deepest in the hierarchy.

2) If no matches are found, the products will be selected from the “Current Category” (a value registered by magento automatically, based on the current session), as long as the product belongs to the current category.

3) If no current category is registered, the module will then display products based on the deepest categories in the tree that the matched product belongs to. Start by copying _prepareRuleActionSelectBind() into  app/code/Local/Demac/TargetRule/Model/Resource/Index.php and modify it accordingly, adding the two functions defined below – getProperMatches() and getDeepestCategory():

<?php
class Demac_TargetRule_Model_Resource_Index extends Enterprise_TargetRule_Model_Resource_Index {
    protected function _prepareRuleActionSelectBind($object, $actionBind, $rule = null)
    {
    ...

     $k = $bindData['bind'];
        $v = $object->getProduct()->getDataUsingMethod($bindData['field']);

        // BUGFIX - mapping correct categories only!
        if ($bindData['field'] == "category_ids" && $rule) {
            $v = $this->getProperMatches($v, $rule);
        }

    ...
    }

    public function getProperMatches($v, $rule) {
        $conditions = $rule->getConditions()->getConditions();
        $currentCategory = Mage::registry('current_category');

        foreach ($conditions as $condition) {
            if ($condition->getAttribute() == "category_ids") {
                $conditionArray = explode(",", $condition->getValue());
            }
        }
        if ($conditionArray && $matches = $this->getMatchingCategoryIds($conditionArray, $v)) {
            $v = $this->getDeepestCategories($matches);
        }
        elseif ($currentCategory && in_array($currentCategory->getId(), $v)) {
            $v = $currentCategory->getId();
        }
        else {
            // If no current category, check which category is deepest in the tree, return its ID as $v
            $v = $this->getDeepestCategories($v);
        }

        return $v;
    }
        /***
     Returns array of matching categories based on rule conditions
     */
    public function getMatchingCategoryIds($conditions, $productCategories) {
        $matches = array();
        foreach ($conditions as $conditionCategoryId) {
            if (in_array($conditionCategoryId, $productCategories)) {
                array_push($matches, $conditionCategoryId);
            }
        }
        return $matches;
    }

    /***
    Returns array of deepest categories from array passed to it
     */
    public function getDeepestCategories($categoryIds) {
        $levelCheck = array();
        $winner = array();
        foreach ($categoryIds as $categoryId){
            $cat = Mage::getModel('catalog/category')->load($categoryId);
            if ($cat) {
                $depth = $cat->getLevel();

                if (isset($levelCheck["depth"]) && $depth < $levelCheck["depth"]) {
                    continue;
                }
                elseif (isset($levelCheck["depth"]) && $depth == $levelCheck["depth"]) {
                    array_push($winner, $categoryId);
                }
                else {
                    array_push($winner, $categoryId);
                }
                $levelCheck = array("winner" => $winner, "depth" => $depth);
            }
        }
        return $winner;
    }
}

The getProperMatches() function is called if the current $bindData value is ‘category_id’, which would be the case if the rule’s display products actions include ‘is the same as matched category.’ getDeepestCategory() determines the deepest category in the hierarchy based on the array of category ids passed in.

Randomization of Selection:

Once the matching category ids have been determined, they are stored in the variable $v, which gets imploded and passed back to the db adapter and forms the proper sql query.

However, the query that is generated is ordered by entity_id, which means that any selection here will grab the first x number of products in a given category, and they will be THE SAME PRODUCTS EVERY TIME for a given category. If we want to randomize the selection a little more, we need to copy the _getProductIdsByRule() function from Enterprise_TargetRule_Model_Resource_Index to Demac_TargetRule_Model_Resource_Index and modify it accordingly.


protected function _getProductIdsByRule($rule, $object, $limit, $excludeProductIds = array())
 {

  ...

  $select = $collection->getSelect();

  $select->reset(Zend_Db_Select::COLUMNS);
  $select->columns('entity_id', 'e');
  // BUGFIX: randomly order the select so that the products returned are not the same
  $select->order('rand()');
  $select->limit($limit);

 $bind = $this->_prepareRuleActionSelectBind($object, $actionBind, $rule);
 $result = $this->_getReadAdapter()->fetchCol($select, $bind);

 return $result;
}

The key in the function above is that the select is modified to order randomly every time, so that the product selection will be different, even if it is selecting from the same category.

The Frontend Shuffle:

Once the product selection takes place, the matching products for a given subject product are stored in a database table matching the rule type, enterprise_targetrule_index_related, enterprise_targetrule_index_upsell, and enterprise_targetrule_index_crosssell. These relations are stored until the index is refreshed, which occurs on product save or rule save. Therefore, if a more ‘random’ selection is desired than the same 4 products displaying as matching one product, you can adjust the rule to select up to 20 products in a relation (by adjusting the settings in the ‘rule information’ tab), and shuffle every time it is pulled from the db.

To accomplish the latter step simply copy the getProductIds() function form Enterprise_TargetRule_Model_Resource_Index into Demac_TargetRule_Model_Resource_Index and add the line underneath BUGFIX below:

public function getProductIds($object)
 {
 ...

  // BUGFIX
  if ($rotationMode == Enterprise_TargetRule_Model_Rule::ROTATION_SHUFFLE) {
    shuffle($productIds);
  }
  return array_slice($productIds, 0, $object->getLimit());
}

The Takeaway

The rewrite class above solves a number of the reported issues with Magento Enterprise_TargetRule.

  • First, it determines the proper category ids to match for a rule in which ‘is the same as matched category’ is selected, and adds those to the select.
  • Next, it reorders the select statement, so that the matching products are randomly selected according to the terms of the rule.
  • Finally, it shuffles the products that are stored in the database index table when products are pulled for display on the frontend.