Skip to main content

Virtual or Calculated fields in Drupal views

Mar 07 '17

A recent Drupal 7 project I worked on had some data stored in an unusual way that I wanted to export in a cleaner, better defined format using the Views and Views Data Export modules.  I didn’t want to change the stored data but I wanted to create a new field to present this data in a more convenient format.

Hook_views_data is a powerful hook at the heart of Views. It gives you the ability to add database tables, define relationships and fields for those tables, define sorting and filtering etc. Among all this functionality, it’s easy to miss the fact that it gives us a simple way to add extra “virtual” or “calculated” fields. These look and feel like real fields in a view but don’t actually exist in the database.

Before I knew more about hook_views_data, I tried manipulating data with other views api hooks. These were usually in the wrong part of the process and resulted in a less than ideal solution.

The following approach is nothing new but it manipulates the data into the desired shape early in the process. There are modules that enable you to create calculated fields with the UI. That works and maybe is fine in some situations, but it usually leads to code being stored in the database and executed with eval() which is undesirable for several reasons. The best place for code is in files and saved in source control.

The following example is somewhat contrived but similar to my real world problem. Let’s say we have a content type called House. It has the following among its fields:

field_floor_area   The floor area in square feet as an integer.
field_rooms   The number of bedrooms and bathrooms combined in one text field.

field_rooms is formatted in an unusual way. It was imported from an external source. Typical content is something like this:

Bedrooms:2
Bathrooms:1

Here, a single field contains both the number of bedrooms and the number of bathrooms on two lines.

We want our view to present the number of bedrooms and bathrooms as separate fields. Just for fun, let’s add another field to also give the floor area in square meters.

We need to build a custom module for our code. Add this to the .module file.

/**
 * Implements hook_views_api()
 */
function MODULE_NAME_views_api($module = NULL, $api = NULL) {
  return array('api' => '3.0');
}

In this and the following code, substitute your own module name for MODULE_NAME. e.g., I called my module views_data_demo so my real function name above is views_data_demo_views_api.

Create a file named MODULE_NAME_views.inc in the root directory of your module. See the documentation for hook_views_api if you prefer to put it in a subdirectory. Here’s the code in that file.

/**
 * Implements hook_views_data().
 */
function MODULE_NAME_views_data() {
  $data = array();
 
  $data['node']['floor_area_sqm'] = array(
    'title' => t('Floor area (sq m)'),
    'help' => t('House floor area in square meters.'),
    'field' => array(
      'handler' => 'MODULE_NAME_floor_area_sqm',
    ),
  );
 
  $data['node']['bedrooms'] = array(
    'title' => t('Bedrooms'),
    'help' => t('Number of bedrooms.'),
    'field' => array(
      'handler' => 'MODULE_NAME_bedrooms',
    ),
  );
 
  $data['node']['bathrooms'] = array(
    'title' => t('Bathrooms'),
    'help' => t('Number of bathrooms.'),
    'field' => array(
      'handler' => 'MODULE_NAME_bathrooms',
    ),
  );
 
  return $data;
}

That’s pretty much the minimum needed to define a field. I’m using the “node” key here because my view is based on nodes (my House content type). The fields will appear in Views in the “Content” group. That key would need to be something else if your view is not based on content, e.g., users or taxonomy_term_data. Alternatively, you can use a key of “views” and then fields will appear as “Global” and always appear in the “Add fields” list.

The handler value is the name of a class which extends views_handler_field. Yes, Views uses real object orientated code, even in Drupal 7.

Since Drupal 7 doesn’t use namespaces or PSR-4 autoloading like Drupal 8, you can put these classes anywhere. I chose to put them in a single file called MODULE_NAME_handlers.inc in the root of my module. Be sure to add a files[] = MODULE_NAME_handlers.inc line to the .info file. I put several classes in that one file (don’t do that in Drupal 8 where it’s strictly one class per file).

Let’s look at the floor space handler first to simply illustrate the key concept. Code for the rooms handlers is given at the end of this article.

/**
 * Handler for floor_area_sqm field.
 */
class views_data_demo_floor_area_sqm extends views_handler_field {
 
  const ONE_SQM_IN_SQFT = 10.7639;
 
  /**
   * Overrides views_handler_field::render().
   */
  function render($values) {
    if (empty($values->field_field_floor_area[0]['raw']['value'])) {
      return '';
    }
 
    $square_feet = $values->field_field_floor_area[0]['raw']['value'];
 
    // Convert to square meters.
    $square_meters = round($square_feet / self::ONE_SQM_IN_SQFT);
 
    // Sanitize and return.
    return $this->sanitize_value($square_meters);
  }
 
  /**
   * Overrides views_handler_field::query().
   */
  function query() {
    // do nothing -- to override the parent query.
  }
}

The key thing is to override the render method and return the value you want. Take a look at $values to see what it contains. You’ll see fields that have been added to the view and entities related to this row so you can dig out the data you want. Note the call to $this->sanitize(). That’s important if you make use of raw data as I have here.

Of course, you can execute whatever code you want to produce the desired value. You could even lookup a database or external data source. The render method is called for every row in the view’s output so if you do anything “heavy” you probably need to pay attention to performance. Instance variables may be useful for local caching depending in the structure of your data. I haven’t studied Views code but the class appears to be instantiated once for all rows.

With this (and the rooms handlers below) in place, the new “virtual“ fields are available to add to the view just like any other field. The fields list ends up like this:

Fields list in edit view.

Rooms is added with “Exclude from display” checked.

Here’s the final result, exported with views_data_export.

"Title","Floor area (sq ft)","Floor area (sq m)","Bedrooms","Bathrooms"
"House 1","1700","158","3","2"
"House 2","1200","111","2","1"
"House 3","2800","260","4","3"

A good article here shows how to do the same thing in Drupal 8.

Following is the code for the rooms handlers:

/**
 * Handler for bedrooms field.
 */
class views_data_demo_bedrooms extends views_data_demo_rooms {
  function render($values) {
    return $this->get_value($values, 'Bedrooms');
  }
}
 
/**
 * Handler for bathrooms field.
 */
class views_data_demo_bathrooms extends views_data_demo_rooms {
  function render($values) {
    return $this->get_value($values, 'Bathrooms');
  }
}
 
/**
 * Base class for handling sub fields of the "Rooms" field.
 */
class views_data_demo_rooms extends views_handler_field {
 
  /**
   * Split the "Rooms" value into a key / value array.
   */
  protected function get_key_values($values) {
    $key_values = array();
    if (empty($values->field_field_rooms[0]['raw']['value'])) {
      return $key_values;
    }
 
    $sub_fields = explode(PHP_EOL, $values->field_field_rooms[0]['raw']['value']);
 
    foreach ($sub_fields as $sub_field) {
      $parts = explode(':', $sub_field);
 
      if (count($parts) !== 2) {
        // Invalid data
        continue;
      }
 
      $key_values[trim($parts[0])] = trim($parts[1]);
    }
 
    return $key_values;
  }
 
  /**
   * Return the sanitized value for a specific sub field.
   */
  protected function get_value($values, $key) {
    $key_values = $this->get_key_values($values);
    return isset($key_values[$key]) ? $this->sanitize_value($key_values[$key]) : '';
  }
 
  /**
   * Overrides views_handler_field::query().
   */
  function query() {
    // do nothing -- to override the parent query.
  }
}