Bitmasked – Using bitmasks in CakePHP

Introduction

Based on Mark Story’s Post about this and the need in one of my apps for this lead me to believe that a behavior would be most appropriate to handle this. So I gave it a try.

There already is a Bitmasked Behavior out there – but it uses a secondary table which will get joined to the primary one. I wanted something that works on the same table (and an existing field), though.
It is supposed to be simple and yet powerful.

Use cases

In some cases a boolean flag in your model is not enough anymore. It might need a second flag – or even a third. You could do that and add those new fields – or combine them all using a bitmask. You can work with quite a few flags this way using a single database field.

The theoretical limit for a 64-bit integer [SQL: BIGINT unsigned] would be 64 bits (2^64). But don’t use bitmasks if you seem to need more than a hand full. Then you obviously do something wrong and should better use a joined table etc.
I highly recommend using tinyint(3) unsigned which can hold up to 8 bits – more than enough. It still only needs 1 byte.

There are two different ways to check against those bitmasks.
The first one – in my implementation the default one – assumes that you are using them similar to a list of multiple selects/checkboxes. Those can be individually edited in a form and saved as this single field. On retrieval you usually want to get only those records with certain bit groups set (a kind of OR condition).
The second use case is on retrieving data based on the value of single flags. Where it doesn’t matter if other flags are also set or not. This case is covered by the last chapter and those "contains" methods of the behavior which create a SQL snippet to be used in the condition.

Bitmasked Behavior

Basically it encodes the array of bit flags into a single bitmask on save and vice versa on find.
I created it as an extension of my pretty well working Enum stuff. It can use this type of enum declaration for our bitmask, as well.
We use constants as this is the cleanest approach to define model based field values that need to be hardcoded in your application.

The code can be found in the Tools Plugin.

Basic Usage

We first want to attach the behavior via public $actsAs = array('Tools.Bitmasked' => array(...)) or at runtime:

$this->Comment->Behaviors->attach('Bitmasked', 
    array('mappedField'=>'statuses', 'field' => 'status'));

The mappedField param is quite handy if you want more control over your bitmask. It stores the array under this alias and does not override the bitmask key. So in our case status will always contain the integer bitmask and statuses the verbose array of it.

We then need to define the bitmask in our model:

const STATUS_ACTIVE = 1;
const STATUS_PUBLISHED = 2;
const STATUS_APPROVED = 4;
const STATUS_FLAGGED = 8;

public static function statuses($value = null) {
    $options = array(
        self::STATUS_ACTIVE => __('Active'),
        self::STATUS_PUBLISHED => __('Published'),
        self::STATUS_APPROVED => __('Approved'),
        self::STATUS_FLAGGED => __('Flagged'),
    );	
    return parent::enum($value, $options);
}

Please note that you need to define MyModel::enum by extending my MyModel or by putting it into your AppModel manually if you want to use that. You don’t have to, of course.

Either way, we need an array of bits that we want to work with.
They should start at 1 and can go as high as your database field is designed for (2, 4, 8, 16, 32, …) or common sense cries "stop".

The behavior tries to find the plural method of your field name automatically (statuses for status here). You can always override this by manually setting param bits to your method name. You may also directly assign the bits to this param.
The advantage of the static model method is that we can use it everywhere else, as well. For instance in our forms to display some multiple checkbox form field for those flags.

Now, in the add/edit form we can add the field:

echo $this->Form->input('statuses', array('options'=>Comment::statuses(), 'multiple'=>'checkbox'));

It will save the final bitmask to the field status.

Searching for a record can be done using the bitmask itself:

$conditions = array('status' => BitmaskedComment::STATUS_ACTIVE | BitmaskedComment::STATUS_APPROVED);
$comment = $this->Comment->find('first', array('conditions' => $conditions));

If you want to search for a specific record, you can also use the array structure, though:

$conditions = array('statuses' => array(BitmaskedComment::STATUS_ACTIVE, BitmaskedComment::STATUS_APPROVED));
$comment = $this->Comment->find('first', array('conditions' => $conditions));

Note that it uses the mappedField – in this case statuses – for the array lookup.

Retrieving the record will then transform the bitmask value back into an array of bits.
If you use a mappedField, you will find it there instead of an overwritten field value.

Extended Usage

In your model you should define a rule for it if you want at least one flag to be selected:

public $validate = array(
    'status' => array(
        'notEmpty' => array(
            'rule' => 'notEmpty',
            'last' => true
        )
    )
);

You can always add more rules manually – for example if you want to make sure only some combinations are valid etc.

There are cases where you want to get all records that have a specific bit set, no matter what the other bits are set to.
In this case you currently need to wrap it like so:

// contains BitmaskedComment::STATUS_PUBLISHED:
$conditions = $this->Comment->containsBit(BitmaskedComment::STATUS_PUBLISHED);
$res = $this->Comment->find('all', array('conditions' => $conditions));

// dos not contains BitmaskedComment::STATUS_PUBLISHED:
$conditions = $this->Comment->containsNotBit(BitmaskedComment::STATUS_PUBLISHED);
$res = $this->Comment->find('all', array('conditions' => $conditions));

Tip: Looking at the test cases is usually one of the best ways to figure out how a behavior is supposed to work.

Manual usage

In some cases you might want to control encoding/decoding yourself (or have to by using saveField() method which does not trigger the behavior).
Let’s also get the enums of a different model here (using a static method – see my enums for details) for testing purposes:

$this->UserSetting->Behaviors->attach('Tools.Bitmasked', 
    array('field' => 'choices', 'bits' => 'ProfileChoice::types'));
$choices = (int) $this->UserSetting->field('my_choices', array('id' => $uid)); // A numeric value (0 ... x)
$choicesArray = $this->UserSetting->decodeBitmask($choices); // Now the array for the checkboxes

if ($this->request->is(array('post', 'put'))) {
    $choicesArray = $this->request->data['UserSetting']['choices']; // our posted form array
    $choices = $this->UserSetting->encodeBitmask($choicesArray); // back to integer
    $this->UserSetting->id = $uid;
    $this->UserSetting->saveField('my_choices', $choices);
    // flash message and redirect
} else {
    $this->request->data['UserSetting']['choices'] = $choicesArray;
}

// in the view:
echo $this->Form->input('choices', array(
    'options' => ProfileChoice::types(), 'multiple' => 'checkbox'));

CakePHP 3.x

For CakePHP 3 this has only slightly changed regarding the syntax.
The full up-to-date documentation is now to be found inside the Tools plugin documentation.

Update 2020-11

Psalm released a version that provides now "int-mask" functionality to make sure you always have a valid bitmask setup.

5.00 avg. rating (93% score) - 1 vote

5 Comments

  1. Did I really forget the link to the code? Thx, Costa! 🙂 Fixed in in the post.

  2. hello,

    can we do the above in javascript? If yes, what can be the approach?

  3. hi, the basic usage section fails to mention the very basic ‘field’ property to operate on 🙂

Leave a Reply

Your email address will not be published.

This site uses Akismet to reduce spam. Learn how your comment data is processed.