A CakePHP behavior to allow quick row-level filtering of models via bitmasks.
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.
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.
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.
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.
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.
You can use the built-in custom finder findBitmasked
:
$statuses = [Comment::STATUS_ACTIVE, Comment::STATUS_FEATURED];
$comments = $this->Comments->find('bits', bits: $statuses)->toArray();
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'
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
).
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.
- Basics: https://sandbox.dereuromark.de/sandbox/tools-examples/bitmasks
- Filtering: https://sandbox.dereuromark.de/sandbox/tools-examples/bitmask-search
- Enums: https://sandbox.dereuromark.de/sandbox/tools-examples/bitmask-enums
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.