SunShop Modifications Part 5

In this section we look at my Top Sellers plugin. This gives us a concrete example of writing a plugin. However, with this example we’ll use my full plugin “framework” which includes my Registered Object mechanism. We’ll also see my persistent-cache mechanism in use, which we’ll then explore in a later post.

Top Sellers Plugin Invocation

We invoke the two plugins as follows, in the Top Sellers page template. We use “bb_” as the prefix to avoid namespace collisions:

$plugin[bb_topseller_30days]
$plugin[bb_topseller_alltime]

Here is the first plugin:

$ADDON_NAME     = "BB Best Sellers Last 30 Days";
$ADDON_VERSION  = "1.0";
$CLASS_NAME     = "bb_topseller_30days";
require_once $abs_path."/include/classes/class.topseller.php";

class bb_topseller_30days {
    public $interval = 30;
    public $module = 'bb_topseller_30days';
    public $model;

    function __construct() {
        $this->model = new Topseller(array('module' => $this->module));
    }

   function render () {
        return $this->model->topseller_list($this->interval);

   }

   function message ($message, $type="error") {
        return $this->model->message($message, $type);

   }

   function install () {
        $this->model->install();

   }

   function uninstall () {
        $this->model->uninstall();

   }

}

Because I am creating quite a number of custom plugins, I set up the plugin code itself as a skeleton “boilerplate” and moved all the “real” code over to the helper object. Every one of my plugins looks very similar to the above. Each contains the required class constants and the five methods required for every plugin. The helper class, in turn, contains those same five methods, and contains the “real” code.

Here is the complete helper class:

class Topseller {
    public $parms = array();
    public $module;
    public static $useTextCache = 1;

    public function __construct($parms) {
        foreach($parms as $key => $value) {
            $this->parms[$key] = $value;
        }
        $this->module = $this->parms['module'];
    }

    function topseller_list($interval = null) {
        global $DB_site, $dbprefix, $settings;
        $dbcache = BBRegistry::getRegisteredObject('BBDBCache', 'class.bb.dbcache.php');
        if(self::$useTextCache) {
            $plainKey = 'bb_topseller'.$interval;
            if($dbcache->haveText($plainKey)) {
                return $dbcache->getText($plainKey);
            }
        }
        $dbcache->setExpire(60);
        $ranks = array();
        // Allow replaced products per request 2/8/2013
        $sql = "select productid, value from ss_products_attributes where name = 'substitute' limit 1000";

        $subs = $dbcache->query($sql);
        $substitute = array();
        foreach($subs as $sub) {
            $productid = (int)$sub['productid'];
            $value = (int)$sub['value'];
            $productid = (string)$productid;
            $value = (string)$value;
            $substitute[$productid] = $value;
        }

        $sql = $this->topseller_sql($interval);
        $reviews = $dbcache->query($sql);
        $text = "";
        $count = 0;
        foreach ($reviews as $review) {

            $productid = (string)$review['productid'];
            if(array_key_exists($productid, $substitute)) {
                $review['productid'] = $substitute[$productid];
            }

            if(!($count % 5)) {
                $text .= "<tr>\n";
            }
            $review['rank'] = ++$count;
            $review['thumb_image'] = htmlspecialchars($review['thumb_image']);
            $text .= template('list_topseller_item.html', array('review' => $review));
            if(!($count % 5)) {
                $text .= "</tr>\n";
            }
        }
        if($count % 5) {
            $text .= "</tr>\n";
        }
        if(self::$useTextCache) {
            $dbcache->setText($plainKey, $text);
        }
        return $text;
    }

    function topseller_sql($interval = null) {
        $where = $interval ?
            "WHERE o.order_date >= CAST(date_add( now( ) , INTERVAL -$interval DAY ) AS DATE)" :
            "";
        $sql = "SELECT
            op.productid,
            SUM( op.price ) AS revenue,
            p.title,
            p.thumb_image,
            p.short_desc,
            p.tagline,
            m.name AS author
        FROM ss_orders_products op
        INNER JOIN ss_orders o ON op.orderid = o.id
        INNER JOIN ss_products p ON p.id = op.productid
        INNER JOIN ss_manufacturers m ON m.id = p.manufacturer
        $where
        GROUP BY op.productid
        ORDER BY revenue DESC
        LIMIT 0 , 50";
        return $sql;
    }

   function message ($message, $type="error") {

       $out  = '<script type="text/javascript">function news_init () { document.getElementById("message").innerHTML = \'';

       $out .= js_clean(template('misc_'.(($type=="error")?'error':'alert').'_alert.html', array('error' => array('message' => $message))));

       $out .= '\'; } window.onload = news_init;</script>';

       return $out;

   }

   function install () {

       global $DB_site, $dbprefix;

       $DB_site->query("INSERT INTO `".$dbprefix."modules_plugins` set
                        `module`='$this->module',
                        `internalname`='enabled',
                        `name`='Enabled', `moptions`='',
                        `options`='0::1->Off::On',
                        `value`='1',
                        `help`='',
                        `size`='0',
                        `dorder`='1',
                        `field_type`='dropdown'");

   }

   function uninstall () {

       global $DB_site, $dbprefix;

       $DB_site->query("DELETE FROM `".$dbprefix."modules_plugins` where `module`='$this->module'");

   }
}

Let’s look at the “easy” boilerplate part first.

Lines 90-100. Function message() is required for every plugin (so far as I can tell). This is a copy/paste from some other plugin.

Lines 102-117. Function install(), as a minimum, must insert the plugin’s enabled/disabled option into ss_modules_plugins. Your plugin may have any number of configuration parameters. Their existence is established via this function. To be sure, most plugins have this code in their install() function, not pushed off to the helper object.

Lines 119-125. Function uninstall() removes all of this plugin’s data from the database. Note that enable/disable disables the plugin from executing without destroying its configuration parameters. uninstall() removes all traces from the database. Because this helper object is used by more than one plugin, we pass the module name in via the object constructor.

Transient Data Cache

Line 4 is the flag stating whether or not to use my cache feature. The topseller plugin was written first, and caching grafted on later as I was working on page-load performance improvements. The topsellers calculation can take 1-3 seconds at peak load times of the day, and is therefore a good cache candidate.

Line 15 shows our Registered Object mechanism in use. If we trouble to load something from cache, we certainly want that same result available to us throughout the life of this page load! That’s the purpose of our Registered Object mechanism. We’ll explain the caching feature later. For now, skip over the if tests like line 16, and references to $dbcache like line 22.

The meat of the matter begins with line 37. We build the query with lines 67-88. In lines 41-57 we loop over our result set, creating one HTML table cell per product item, 5 items per row. Since we are displaying exactly 50 results, lines 58-60 should never execute, but they’re there in case we finish with a partial row.

I have a minor sleight of hand on line 51. The $count begins with zero. We check to see if we need to begin a new row BEFORE incrementing the count, on line 48. On line 54, we check for generating an end-of-row tag AFTER incrementing the count. Thus both checks can use the ($count % 5) calculation.

What’s Next

In part 6, we’ll look at my data cache mechanism.