RSS
 
24. Jun. 2010

Static Enums or “Semihardcoded Attributes”

24 Jun

There are many cases where an additional model + table + relation would be total overhead. Like those little “status”, “level”, “type”, “color”, “category” attributes. Often those attributes are implemented as “enums” in SQL – but cake doesn’t support them natively. And it should not IMO. You might also want to read this ;)

If there are only a few values to choose from and if they don’t change very often, you might want to consider the following approach. I use it a hundred times. Its very efficient and easily expandable.

Why not using hardcoded integer values like 0, 1, 2 etc? The answer is simple: It is usually really bad style to do so (“magic numbers” should be avoided). What if you want to update your code a few weeks later – you probably don’t even know anymore what 1 or 2 stand for. Constants like TYPE_ACTIVE TYPE_PENDING, though, are way more descriptive.

Let’s get started

a) Add tinyint(2) unsigned field called for example “status” (singular) “Tinyint(2 / 3) unsigned” covers 0…127 / 0…255 – which should always be enough for enums. if you need more, you SHOULD make an extra relation as real table. Do not use tinyint(1) as cake interprets this as a toggle field, which we don’t want!

b) Put this in your AppModel (or better use the Tools Plugin and all its auto-build in functionality right away):

/**
 * static enums
 * @access static
 */
public static function enum($value, $options, $default = '') {
    if ($value !== null) {
        if (array_key_exists($value, $options)) {
            return $options[$value];
        }
        return $default;
    }
    return $options;
}

c) Put something like this in any model where you want to use enums:

/*
 * static enum: Model::function()
 * @access static
 */
 public static function statuses($value = null) {
    $options = array(
        self::STATUS_NEW => __('statusNew',true),
        self::STATUS_UNREAD => __('statusUnread',true),
        self::STATUS_READ => __('statusRead',true),
        self::STATUS_ANSWERED => __('statusAnswered',true),
        self::STATUS_DELETED => __('statusDeleted',true),
    );
    return parent::enum($value, $options);
}
 
const STATUS_NEW = 0; # causes sound, then marks itself as "unread"
const STATUS_UNREAD = 1;
const STATUS_READ = 2;
const STATUS_ANSWERED = 4;
const STATUS_DELETED = 5;
// add more - order them as you like

d) Use them in your controller logic, model functions, view forms, …:

//view form
...
echo $this->Form->input('status', array('options' => Notification::statuses()));
//controller action
...
if ($this->data['Notification']['status'] == Notification::STATUS_READ)) {...}
//controller logic on find
...
$options = array('conditions' => array('Notification.user_id' => $uid, 'Notification.status <=' => Notification::STATUS_UNREAD));
$notifications = $this->Notification->find('all', $options);
//view index/view
...
<?php echo h($notification['Notification']['title']);?>
<?php echo Notification::statuses($notification['Notification']['status']); // returns translated text ?>

=> NOTE: example with “statuses”, could also be priorities, gender, types, categories, … etc anything that is not often changed or extended

That’s it!

Conclusion: fast and easy to extend in the future if neccessary. It also saves a lot of overhead by using (tiny)ints instead of strings AND it does not need any additional table joins! which makes it even more performant.

Further advantages – can be used from anywhere (model, controller, view, behavior, component, …) – reorder them by just changing the order of the array values in the model – auto-translated right away (i18n without any translation tables – very fast) – create multiple static functions for different views (“optionsForAdmins”, “optionsForUsers” etc). the overhead is minimal.

What you cant do: – sort by the value of the keys (only by keys): small, medium, low => no sorting by their name ASC/DESC, only by key ASC/DESC

Final tips: If you use them in an index view many times repeatedly, if would make sense to write them into an array before using them. otherwise the translation will be transformed every time. caching would also work!

Validation/emptyFields: use ‘empty’ attribute in form helper options array for default “blank” with either 'empty' => array(0 => 'xyz') to allow 0 values or 'empty' => 'xyz' to require one value (combined with validation rule “numeric”).

Do not try to set default values in your view. It’s better and cleaner to leverage the controller for this. See “Default Values” chapter.

Combination with LazyLoading

This approach gets more complicated in combination with “Lazy Loading” of Models (if even implemented). In this case the related models are not imported until actually needed. So if you use constants from your model before this happens, you get a fatal error! You would need to App::import() all models which you need for static enum access. This can be done in the controller actions and adds not more than 1-2 lines of code.

I am working on a PHP5.3+ ONLY solution right now which uses the brand new __callStatic() method and works well with LazyLoading. Stay tuned…

For Cake2.0, all you have to do is make sure that the classes are defined before you access their constants:

App::uses('MyModel', 'PluginName.Model');

You can do that right before you use it or define it globally in your Controller class for example.

UPDATE August 2011 – Enums in baking templates

Include your enums in your templates for “cake bake” in order to have them in your views out of the box. All you need to do:

a) Add the pluralized static method of the enum field to the model (e.g. status => statuses):

/**
     * @static
     */
    public static function statuses($value = null) {
        $array = array(
            self::STATUS_INACTIVE => __('Inactive', true),
            self::STATUS_ACTIVE => __('Active', true),
        );    
        return parent::enum($value, $array);
    }
    const STATUS_INACTIVE = 0;
    const STATUS_ACTIVE = 1;

b) Adjust your bake templates

foreach ($fields as $field) {
    if (strpos($action, 'add') !== false && $field === $primaryKey) {
        ...
    } elseif ($schema[$field]['type'] === 'integer' && method_exists($modelClass, $enumMethod = lcfirst(Inflector::camelize(Inflector::pluralize($field))))) {
        echo "\t\techo \$this->Form->input('{$field}', array('options' => " . Inflector::camelize($modelClass) . "::" . $enumMethod . "()));\n";
    } ...

this is the code for the form.ctp template

... 
} elseif ($schema[$field]['type'] == 'integer' && method_exists($modelClass, $enumMethod = lcfirst(Inflector::camelize(Inflector::pluralize($field))))) {
    echo "\t\t<td>\n\t\t\t<?php echo " . $modelClass . "::" . $enumMethod . "(\${$singularVar}['{$modelClass}']['{$field}']); ?>\n\t\t</td>\n";
} 
...

and this for index.ctp (view.ctp works similar)

So if the bake script finds the static method “statuses” in the model it will then present the dropdown in the forms as well as the translated (+i18n) value in the views (index/view).

UPDATE October 2011 – Regroup and Reorder

With a little trick it is now possible to return only a small subset (and in different order) for specific form elements. This is the improved enum method for your AppModel:

/**
 * @param string $value or array $keys or NULL for complete array result
 * @param array $options (actual data)
 * @return mixed string/array
 */
public static function enum($value, $options, $default = null) {
    if ($value !== null && !is_array($value)) {
        if (array_key_exists($value, $options)) {
            return $options[$value];
        }
        return $default;
    } elseif ($value !== null) {
        $newOptions = array();
        foreach ($value as $v) {
            $newOptions[$v] = $options[$v];
        }
        return $newOptions;
    }
    return $options;
}

The current version can now also be found in my Tools plugin.

Lets say, we have our status array from above. But for users we dont want them to be able to set the record to “deleted”. And lets say we are in the Message Model:

'options' => Message::statuses(array(Message::STATUS_NEW, Message::STATUS_UNREAD, Message::STATUS_READ, Message::STATUS_ANSWERED))

We pass this on as options for the FormHelper and the deleted status is not available. No extra methods or configuration required. The order in which the keys are passed will decide the order of the translated enum values.

Update 2012-02-26 – Cake2 and Bitmaps

If you are looking for combining several booleans into a single database field check out my Bitmasked Behavior.

Other approaches

Mine is not the only solution to the problem (although I think mine is in most enum cases the best^^). Others have their own approach on this:

  • You can use the ArrayDatasource of cakephp/datasources (although this is a little bit more verbose and not quite as flexible)
  • Miles uses the EnumerableBehavior
  • Cake3.x might actually some day natively recognize enums (there is an open PR for it) – I will then update this post.

Update for CakePHP 3.x

With CakePHP 3 you have both Table and Entity classes instead of just Model classes. Thus you need to move those enum methods to the Entity classes. In 3.x it will be even easier to use it, since you can directly access any entity statically via

App\Model\Entity\MyEntityName::enumMethod()

But – and that’s the really neat thing now – since those entities are passed down to the views for forms and alike, you can also directly access them there:

echo $this->Form->input('status', array('options' => $user->statuses()));

So all in all the 2.x code here should be fairly easy to upgrade for 3.x.

Static Enums or “Semihardcoded Attributes”
1 vote, 5.00 avg. rating (96% score)
 
5 Comments

Posted by Mark in CakePHP

 

Tags: , ,

Leave a Reply

Tip:
If you need to post a piece of code use {code type=php}...{/code}.
Allowed types are "php", "mysql", "html", "js", "css".

Please do not escape your post (leave all ", <, > and & as they are!). If you have encoded characters and need to reverse ("decode") it, you can do that here!
 

 
  1. Mark

    August 21, 2011 at 13:26

    Just added the HowTo for baking enums right away. Those enums then work out of the box.

     
  2. func0der

    December 23, 2011 at 15:14

    Hey,

    i don't get the use of the "enum" function in the AppModel.

    What's its use.

    Greetings
    func0der

     
  3. Mark

    December 23, 2011 at 19:43

    It should be pretty obvious from the examples.
    The function is the main functionality behind the enums.

    PS: Added tips for it to work in Cake2.0

     
  4. Tony Coyle

    January 29, 2014 at 01:03

    If you keep the intended default value in the list of constants set to `0`. Add the following code to your form.ctp Bake template and you get a nice way of selecting a default value from a list in the resulting add.ctp/admin_add.ctp form whilst leaving the user selected value in the edit.ctp/admin_edit.ctp forms.

    /* ...from the Model */
      const PERIOD_FORTHNIGHTLY = 0;
      const PERIOD_WEEKLY = 1;
      const PERIOD_MONTHLY = 2;
     
    /* ...for the Bake form.ctp */
    $default = (strpos($action, 'add') !== false) ? 0 : null ;
     
    echo "\t\t\t\t\tForm->input('{$field}', array('options'=>".Inflector::camelize($modelClass)."::".$enumMethod."(),'default' => $default));?>\n";
     
  5. Tony Coyle

    January 29, 2014 at 01:19

    Further to the last comment + a bug fix. Cake docs recommend using 'default' = &gt; 0 to specify no default value, which may interfere with constants set as `0`. So instead I went for constants starting from 1 and not 0m aking sure 1 was the default value for the set of values. So

    public static function generatePeriods($value = null) {
          $array = array(
              self::PERIOD_WEEKLY => __('Weekly', true),
              self::PERIOD_FORTHNIGHTLY => __('Fortnightly', true),
              self::PERIOD_MONTHLY => __('Monthly', true)
          );    
          return parent::enum($value, $array);
      }
      const PERIOD_FORTHNIGHTLY = 1;
      const PERIOD_WEEKLY = 2;
      const PERIOD_MONTHLY = 3;
     
    /*...and then in form.ctp bake template */
     
    $default = (strpos($action, 'add') !== false) ? 1 : 0 ;
     
    echo "\t\t\t\t\tForm->input('{$field}', array('options'=>".Inflector::camelize($modelClass)."::".$enumMethod."(),'default' => ".$default."));?>\n";