You are Here: Articles » Counting Users' Votes with a CakePHP Sum Cache

Counting Users' Votes with a CakePHP Sum Cache

Tweaking Cake's inbuilt counter cache functionality is a handy way of tracking user-submitted votes

Tagged with Web Development and CakePHP
Posted on 6/6/09 by Paul Herron

CakePHP's counter cache functionality is a handy way of keeping track of how many associated records something has. If I wanted to know the number of comments an article has, for example, I could do the following in my model association:

  1. <?php

  2. class Comment extends AppModel {

  3.  

  4.   var $actsAs = array('CounterCache');

  5.  

  6.   var $belongsTo = array(

  7.       'Article' => array(
                            'className' => 'Article',

  8.         'foreignKey' => 'article_id',

  9.         'conditions' => '',

  10.         'fields' => '',

  11.         'order' => '',

  12.         'counterCache' => true

  13.       )

  14.   );

  15.  

  16. }

  17. ?>

This would update a comment_count field in my articles table each time a comment is added or removed. Caching this value at the update stage is desirable, as it stops us having to do expensive count operations in PHP or MySQL each time we retrieve the records.

We can tap into Cake's counter cache logic for a related purpose: keeping track of the votes something has. The way I tend to implement this is a slight variation on the theme. It's handy to allow both up and down votes, so instead of simply storing how many votes something has, the aim is to store the overall sum of those votes. If one person votes an image up (+1) and one votes it down (-1), the vote sum is 0. If 20 people all vote up (+1), the vote sum would be 20. Of course, we can keep a regular counter cache alongside this so we always know how many votes were cast. This lets us say something like "this image has a score of 10, based on 20 up votes and 10 down votes".

So, we can represent this with Image and Vote models based on the following schema:

  1. CREATE TABLE images (

  2.   id int(8) UNSIGNED NOT NULL AUTO_INCREMENT,

  3.   title varchar(255) DEFAULT NULL,

  4.   vote_count int(8) DEFAULT 0,

  5.   vote_sum int(8) DEFAULT 0,

  6.   created datetime,

  7.   modified datetime,

  8.   PRIMARY KEY (id)

  9. );

    CREATE TABLE votes (

  10.   id int(8) UNSIGNED NOT NULL AUTO_INCREMENT,

  11.   image_id int(8) DEFAULT NULL,
      score int(2) DEFAULT 0 COMMENT="Holds the value of the vote, e.g. 1 for an up vote, -1 for a down vote.",

  12.   created datetime,

  13.   modified datetime,

  14.   PRIMARY KEY (id)

  15. );

Then, we can extend the Model::updateCounterCache() method into our AppModel, adding some logic to sum the appropriate fields. The code is a variation of something I originally saw posted by Andy Dawson.

  1. /**

  2. * Updates the sum and counter cache of belongsTo associations after a save or delete operation

  3. *

  4. * @param array $keys Optional foreign key data, defaults to the information $this->data

  5. * @param boolean $created True if a new record was created, otherwise only associations with

  6. *                                  'counterScope' defined get updated

  7. * @return void

  8. * @access public

  9. */

  10.   function updateCounterCache($keys = array(), $created = false) {

  11.     parent::updateCounterCache($keys);

  12.    

  13.     if (empty($keys)) {

  14.       $keys = $this->data[$this->alias];

  15.     }

  16.     foreach ($this->belongsTo as $parent => $assoc) {

  17.       if (!empty($assoc['sumCache'])) {

  18.         if ($assoc['sumCache'] === true) {

  19.           $assoc['sumCache'] = Inflector::underscore($this->alias) . '_sum';

  20.         }

  21.         if ($this->{$parent}->hasField($assoc['sumCache'])) {

  22.           $conditions = array($this->escapeField($assoc['foreignKey']) => $keys[$assoc['foreignKey']]);

  23.           if (isset($assoc['sumScope'])) {

  24.             $conditions = array_merge($conditions, (array)$assoc['sumScope']);

  25.           }

  26.           $fields = 'SUM(score) AS score';

  27.           $recursive = -1;

  28.           list($edge) = array_values($this->find('first', compact('conditions', 'fields', 'recursive')));

  29.           $sum = ife(empty ($edge['score']), 0, $edge['score']);

  30.          

  31.           $this->{$parent}->updateAll(

  32.             array($assoc['sumCache'] => $sum),

  33.             array($this->{$parent}->escapeField() => $keys[$assoc['foreignKey']])

  34.           );

  35.         }

  36.       }

  37.     }

  38.   }

With that done, we can make sure our sum cache and counter cache get used by adding them to our belongsTo model association:

  1. <?php

  2. class Vote extends AppModel {

  3.  

  4.   var $actsAs = array('CounterCache');

  5.  

  6.   var $belongsTo = array(

  7.       'Image' => array(
                                    'className' => 'Image',

  8.         'foreignKey' => 'image_id',

  9.         'conditions' => '',

  10.         'fields' => '',

  11.         'order' => '',

  12.         'counterCache' => true,

  13.         'sumCache' => true

  14.       )

  15.   );

  16.  

  17. }

  18. ?>

All being well, we now have a way of accessing vote totals without hammering the database. Also, because the values are stored in regular database fields, we can do standard Cakey things with them. For example, $this->Image->find('all', array('order' => 'vote_sum desc')); would grab the images in descending order of popularity.

Comments

Iain Mullan wrote on 10/7/09:

Great tip! This is just what I was looking for. Although I do have a couple of questions...

If you're using the Cake's core counterCache facility, why are you including a CounterCache behaviour?

And also, it would be good to be able to customise the name of the field being summed. In your case it's vote but in mine it's amount.

I've tweaked the code a little to allow for this, and also a default. So, in the belongsTo definition:

     'counterCache' => true,
     'sumCache' => true,

     'sumField' => 'amount'  // the field to perform the SUM operation on

 

And in the updateCounterCache method, replace your line 26 with:

     if (!isset($assoc['sumField'])) {
          $assoc['sumField'] = 'amount'; // default value
     }
     $fields = 'SUM('.$assoc['sumField'].') AS '.$assoc['sumField'].'';

and replace your line 29 with:

     $sum = ife(empty ($edge[$assoc['sumField']]), 0, $edge[$assoc['sumField']]);

 

As you see i've used amount as the default ... maybe there's a better word to use as a default??

Thanks again,

Iain

 

Leave a Comment

CAPTCHA[Refresh]

« Articles

Article Tags

Show all articles, or just those tagged as:

Feed

The articles RSS feed is available.

Elsewhom

  • Ubuntu Blog.
    Not quite everything about Ubuntu, but close
  • @TheKeyBoard.
    Be a developer, not a typist!
    Chris Hartjes' blog on CakePHP, life and more
  • Modern Life.
    A blog about the web - development, design, search engines and statistics. Sometimes controversial, occasionally satirical and usually on the nose.
  • The Tech.
    MIT's Student Newspaper
  • Ars Technica.
    The art of technology

See More…

Back to top.