Skip to content

Latest commit

 

History

History
164 lines (125 loc) · 7.38 KB

Bitmasked.md

File metadata and controls

164 lines (125 loc) · 7.38 KB

Bitmasked Behavior

A CakePHP behavior to allow quick row-level filtering of models via bitmasks.

Introduction

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 functionality. It can use this type of enum declaration for these bitmasks, as well. It uses (class) constants as this is the cleanest approach to define model based field values that need to be hardcoded in your application. They are usually defined in your entities.

Technical limitation

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

Advantage

The main advantage is the storage of e.g. up to 8 bits in 1 byte compared to `8+ bytes (= 8x more space) as seperate flags on the table row. With thousands and millions of records this can sum up. It only uses a single field, which keeps DB/app migration at a minimum, if you only introduce new ones on top. Also easy to transfer this data around, since we don't have to check and process multiple fields on the record.

Usage

Attach it to your model's Table class in its initialize() method like so:

$this->addBehavior('Tools.Bitmasked', $options);

If you want to alias the field for output:

$this->addBehavior('Tools.Bitmasked', ['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.

Note: If you use an alias, make sure that either also that alias is whitelisted for patching, or you don't use beforeMarshal event here.

Defining the selectable values

We first define values and make sure they follow the bitmask scheme:

1, 2, 4, 8, 16, 32, 64, 128, ...

I recommend using a DRY enum approach, using your entity:

// A bunch of bool values
public const STATUS_ACTIVE = 1;
public const STATUS_FEATURED = 2;
public const STATUS_APPROVED = 4;
public const STATUS_FLAGGED = 8;

...

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

Please note that you need to define Entity::enum() by extending my Tools Entity base class or by putting it into your own base class manually. You don’t have to use the enum approach, though.

Of course, it only makes sense to use bitmasks, if those values can co-exist, if you can select multiple at once. Otherwise you would want to store them separately anyway. Obviously you could also just use four or more boolean fields to achieve the same thing.

So now, in the add/edit form we can:

echo $this->Form->control('statuses', ['options' => Comment::statuses(), 'multiple' => 'checkbox']);

Tip: Usually, you have passed down the current entity for the form building anyway, then you don't need static access:

echo $this->Form->create($comment);
echo $this->Form->control('statuses', ['options' => $comment->statuses(), 'multiple' => 'checkbox']);
...

It will save the final bitmask to the database field status as integer. For example "active and approved" would become 9.

Note: When using mappedField, one needs to manually include error handling for the actual field:

echo $this->Form->control('statuses', ['type' => 'select', 'multiple' => 'checkbox']);
echo $this->Form->error('status');

Alternatively, you can map the error over before the entity gets passed to the view layer.

Custom finder

You can use the built-in custom finder findBitmasked:

$statuses = [Comment::STATUS_ACTIVE, Comment::STATUS_FEATURED];
$comments = $this->Comments->find('bits', bits: $statuses)->toArray();

Using Search plugin

If you use Search plugin, you can easily make a filter as multi-checkbox (or any multi-select type):

echo $this->Form->control('status', ['options' => Comment::statuses(), 'multiple' => 'checkbox', 'empty' => ' - no filter - ']);

And in your Table searchManager() setup:

$searchManager
    // We need to map the posted "status" key to the finder required "bits" key
    ->finder('status', ['finder' => 'bits', 'map' => ['bits' => 'status']])

This way the array of checkboxes selected will be turned into the integer bitmask needed for the query to work.

When using select dropdows or checkboxes, you usually want to use type contain instead of exact matching:

$this->Comments->find('bits', bits: $statuses, options: ['type' => 'contain'])->toArray();

It can also be useful to add a state for "no bits set" (all without any bits):

$statuses[0] = ' - n/a (no flags) - ';

This is usually only the case for dropdowns. For checkboxes in filter forms no selection means usually "ignore this filter".

If you want to use AND instead of default OR mode, use this config:

'type' => 'contain', 'containMode' => 'and'

Custom usage

If you build more complex finders or queries for your data, you might find the following info useful:

"contains" (looking for any that contains this type) is translated to field & {type} = {type} in the ORM, e.g. status & 1 = 1. Once you are looking for a combination of types, it will be an OR of those elements, e.g. status & 1 = 1 OR status & 2 = 2.

Using the finder you do not have to care about the SQL details here, as it will translate to this automatically.

"exact" (default) only looks at the values exclusively. If you are looking for multiple ones at once that are exclusive, this will need to be translated to IN (...) using only the type integers directly (not the bitmasked combinations), e.g. IN (1, 2, 4).

Configuration

The default onMarshal expects you to require validation (not empty, ...) on this field. If you don't need that, and it is nullable, you can also set the event to e.g. afterMarshal.

If you use fields config to whitelist the fields for patching, you should also whitelist the alias field if you defined one and if you are using onMarshal.

Set bits to your MyEnum::class if you want to use backed enums. Setting enum to this class is only needed if you use a different way of setting the bits, e.g. using 'bits' => MyEnum::tryFrom(0)->options() etc.

Demo

Outview

You can read more about how it began in my blog post.

If you want to use a more DB or Config driven approach towards enums, you can also look into other plugins and CakePHP resources available, e.g. this implementation.