14

I'm adding certain fields from a content type into a custom form using field_attach_form(). When the form is submitted I'm processing those fields by calling field_attach_form_validate() and field_attach_submit() from #validate and #submit callbacks.

At that point, I want to compare the post-submit, prepared node object to the original node and only bother to node_save() it if any of the fields have changed. Therefore, I begin by loading the original node using entity_load_unchanged().

Unfortunately, the field arrays in the original node object don't match the field arrays in the prepared node object which is waiting to be saved, even if no changes have been made to the fields, so a simple "$old_field == $new_field" comparison is impossible. For example, a simple text field appears like this in the original:

$old_node->field_text['und'][0] = array(
  'value' => 'Test',
  'format' => NULL,
  'safe_value' => 'Test',
);

Whereas in the prepared node it appears like this.

$node->field_text['und'][0] = array(
  'value' => 'Test',
);

You might think to just compare the 'value' key, but then you run into fields made up of other elements which don't have 'value' keys. For example, let's look at an address field where there is no 'value' key and there are keys in both the old and prepared nodes which don't have counterparts.

Old node

$old_node->field_address['und'][0] = array(
  'country' => 'GB',
  'administrative_area' => 'Test',
  'sub_administrative_area' => NULL,
  'locality' => 'Test',
  'dependent_locality' => NULL,
  'postal_code' => 'Test',
  'thoroughfare' => 'Test',
  'premise' => 'Test',
  'sub_premise' => NULL,
  'organisation_name' => 'Test',
  'name_line' => 'Test',
  'first_name' => NULL,
  'last_name' => NULL,
  'data' => NULL,
);

Prepared node

$node->field_address['und'][0] = array(
  'element_key' => 'node|page|field_address|und|0',
  'thoroughfare' => 'Test',
  'premise' => 'Test',
  'locality' => 'Test',
  'administrative_area' => 'Test',
  'postal_code' => 'Test',
  'country' => 'GB',
  'organisation_name' => 'Test',
  'name_line' => 'Test',
);

For empty fields, there is yet another discrepancy.

Old node

$old_node->field_text = array();

Prepared node

$node->field_text = array(
  'und' => array(),
);

Can I generically compare any field's old and new value to detect whether it has changed or not?
Is this just an impossibility?

3
  • I think you can play with _field_invoke() or something related to prepare full field structure from "prepared" node, render both fields and simply compare these HTML strings. Just an idea.
    – kalabro
    Jul 24, 2013 at 13:22
  • @kalabro Yeah that's definitely the way to go, I can't help feeling that would be quite bad for performance though - to make it generic you'd need to load every bit of field info individually using the form submission. Or I guess you could write an aggregated query to get the data, but then important hooks may not fire. Conceptually it seems possible, but I think an implementation would be quite complicated
    – Clive
    Jul 24, 2013 at 14:01
  • @kalabro I don't quite understand this idea. Could you post some pseudocode to demonstrate how to prepare the field structure and then render it as you described?
    – morbiD
    Jul 25, 2013 at 10:30

7 Answers 7

11
+100

This, at long last, should work as a generic solution. Thanks to Clive and morbiD for all the input.

Pass both versions of the node to the following function. It will:

  1. Pull all of the detected content type's editable fields and their editable columns (i.e. items that could possibly appear on the custom form) from the database in a single query.

  2. Ignore fields and columns that are completely empty in both versions.

  3. Treat a field that has a different number of values between the two versions as a change.

  4. Iterate through every field, value, and column and compare the two versions.

  5. Compare items non-identically (!=) if they are numeric and identically (!==) if they are anything else.

  6. Immediately return TRUE on the first change it detects (since one change is enough to know we need to resave the node).

  7. Return FALSE if no change is detected after all the values are compared.

  8. Recursively compare field collections by loading them and their schema and passing the results to itself. This SHOULD even allow it to compare nested field collections. The code should NOT have any dependency on the Field Collection module.

Let me know if there are any more bugs or typos in this code.

/*
 * Pass both versions of the node to this function. Returns TRUE if it detects any changes and FALSE if not.
 * Pass field collections as an array keyed by field collection ID.
 *
 * @param object $old_entity
 *   The original (stored in the database) node object.
 *   This function may also pass itself a FieldCollectionItemEntity object to compare field collections.
 * @param object $new_entity
 *   The prepared node object for comparison.
 *   This function may also pass itself a FieldCollectionItemEntity object to compare field collections.
 */
function _fields_changed($old_entity, $new_entity) {
  // Check for node or field collection.
  $entity_is_field_collection = (get_class($old_entity) == 'FieldCollectionItemEntity');
  
  $bundle = ($entity_is_field_collection ? $old_entity->field_name : $old_entity->type);

  // Sanity check. Exit and throw an error if the content types don't match.
  if($bundle !== ($entity_is_field_collection ? $new_entity->field_name : $new_entity->type)) {
    drupal_set_message('Content type mismatch. Unable to save changes.', 'error');
    return FALSE;
  }

  // Get field info.
  $field_read_params = array(
    'entity_type' => ($entity_is_field_collection ? 'field_collection_item' : 'node'),
    'bundle' => $bundle
  );
  $fields_info = field_read_fields($field_read_params);

  foreach($fields_info as $field_name => $field_info) {
    $old_field = $old_entity->$field_name;
    $new_field = $new_entity->$field_name;

    // Check the number of values for each field, or if they are populated at all.
    $old_field_count = (isset($old_field[LANGUAGE_NONE]) ? count($old_field[LANGUAGE_NONE]) : 0);
    $new_field_count = (isset($new_field[LANGUAGE_NONE]) ? count($new_field[LANGUAGE_NONE]) : 0);

    if ($old_field_count != $new_field_count) {
      // The two versions have a different number of values. Something has changed.
      return TRUE;
    } elseif ($old_field_count > 0 && $new_field_count > 0) {
      // Both versions have an equal number of values. Time to compare.

      // See if this field is a field collection.
      if ($field_info['type'] == 'field_collection') {

        foreach ($new_field[LANGUAGE_NONE] as $delta => $values) {
          $old_field_collection = entity_load_unchanged('field_collection_item', $values['entity']->item_id);
          $new_field_collection = $values['entity'];
       
          if (_fields_changed($old_field_collection, $new_field_collection)) {
            return TRUE;
          }
        }
        unset($delta, $values);
        
      } else {
        foreach($old_field[LANGUAGE_NONE] as $delta => $value) {
          foreach($field_info['columns'] as $field_column_name => $field_column_info) {
            $old_value = $old_field[LANGUAGE_NONE][$delta][$field_column_name];
            $new_value = $new_field[LANGUAGE_NONE][$delta][$field_column_name];
            $field_column_type = $field_column_info['type'];
  
            // As with the overall field, exit if one version has a value and the other doesn't.
            if (isset($old_value) != isset($new_value)) {
              return TRUE;
            } elseif (isset($old_value) && isset($new_value)) {
              // The column stores numeric data so compare values non-identically.
              if (in_array($field_column_type, array('int', 'float', 'numeric'))) {
                if ($new_value != $old_value) {
                  return TRUE;
                }
              }
              // The column stores non-numeric data so compare values identically,
              elseif ($new_value !== $old_value) {
                return TRUE;
              }
            } else {
              // Included for clarity. Both values are empty so there was obviously no change.
            }
          } 
          unset($field_column_name, $field_column_info);
        }
        unset($delta, $value);
      }
    } else {
      // Included for clarity. Both values are empty so there was obviously no change.
    }
  }
  unset($field_name, $field_info);
  // End of field comparison loop.

  // We didn't find any changes. Don't resave the node.
  return FALSE;
}

EDIT (7/30/2013) Tightened up field collection support. Added support for fields with multiple values.

1
  • This is great, I feel like this should be in some sort of api module for developers to use.
    – Jelle
    Aug 31, 2016 at 13:19
3

Here's another, simpler, approach that avoids the complex server side value comparisons, and would work with any form:

  1. Use jQuery to detect if the form values have changed
  2. Set a hidden element value to indicate the form has changed.
  3. Check the hidden element value server side and process as required.

You could use a jQuery dirty form plugin such as https://github.com/codedance/jquery.AreYouSure

Although others that let you listen to the form changed / dirty status would also work.

Add a listener to set the value of a hidden form element:

Set the hidden form element to a default value of 'changed' to save by default for those users with javascript disabled (~2%).

e.g:

// Clear initial state for js-enabled user
$('input#hidden-indicator').val('')
// Add changed listener
$('#my-form').areYouSure({
    change: function() {
      // Set hidden element value
      if ($(this).hasClass('dirty')) {
        $('input#hidden-indicator').val('changed');
      } else {
        $('input#hidden-indicator').val('');
      }
    }
 });

You can then check the value of the hidden element

if ($form_state['values']['hidden_indicator'] == 'changed') { /* node_save($node) */ }

in your form validate / submit handlers.

2
  • 2
    Nice solution, although obviously there are some users without js. Also, check out Drupal.behaviors.formUpdated in the misc/form.js file of drupal core. Another thing to note is that with the way some wysiwyg editors and their drupal modules work, detecting a changed value is not always as straight forward as it should be.
    – rooby
    Jul 25, 2013 at 4:31
  • Yes, setting a default value of 'changed' for the hidden element would save by default for those few users without js enabled - small percentage. Interesting note regarding Drupal.behaviors.formUpdated perhaps the val() could be linked in with that though it looks like it will trigger without the value actually changing (e.g includes click event), whereas the dedicated plugins are better at detecting actual changed form values. Jul 25, 2013 at 4:36
0

I'm not sure that is perfect, but why not taking it the other way around, by comparing the forms instead of the node objects ?

I'm not sure you're if you're strictly in a node form, but anyway you can render the form with your old node and your new node :

module_load_include('inc', 'node', 'node.pages');
node_object_prepare($new_node);
$new_form = drupal_get_form($new_node->node_type . '_node_form', $new_node);
node_object_prepare($old_node);
$old_form = drupal_get_form($old_node->node_type . '_node_form', $old_node);

Compare your forms...

I hope it's a good track... let me know.

2
  • I had already looked into drupal_get_form() but I didn't realise you could pass $node to it as a 2nd parameter. However, I've just tested your example code above and unfortunately, while the array structures returned are the same, the values are not. Have a look at this recursive array_diff_assoc() for the address field I'm testing with: i.imgur.com/LUDPu1R.jpg
    – morbiD
    Jul 24, 2013 at 15:16
  • I see that array_diff_assoc, but would you have time to give the dpm of both drupal_get_form ? There might be a way around this. Jul 24, 2013 at 15:33
0

Here is a method using hook_node_presave($node). It is just a mockup, if you think it helps, test it and improve it to your needs!

  /**
   * Implements hook_node_presave().
   *
   * Look for changes in node fields, before they are saved
   */
  function mymodule_node_presave($node) {

    $changes = array();

    $node_before = node_load($node->nid);

    $fields = field_info_instances('node', $node->type);
    foreach (array_keys($fields) as $field_name) {

      $val_before = field_get_items('node', $node_before, $field_name);
      $val = field_get_items('node', $node, $field_name);

      if ($val_before != $val) {

        //if new field values has more instances then old one, it has changed
        if (count($val) != count($val_before)) {
          $changes[] = $field_name;
        } else {
          //cycle throught 1 or multiple field value instances
          foreach ($val as $k_i => $val_i) {
            if (is_array($val_i)) {
              foreach ($val_i as $k => $v) {
                if (isset($val_before[$k_i][$k]) && $val_before[$k_i][$k] != $val[$k_i][$k]) {
                  $changes[] = $field_name;
                }
              }
            }
          }
        }
      }
    }
    dpm($changes);
  }

I suppose that, for each field value, the instances that are defined in $node must be defined and equal in $node_before. I don't care for field value's fields that are in $node_before and are not in $node, I suppose they stay the same.

3
  • Maybe I'm missing something, but doesn't hook_node_presave() imply node_save() has been called? We're trying to avoid calling node_save() if no fields have been changed.
    – morbiD
    Jul 25, 2013 at 10:16
  • True, this hook is called inside node_save(). But you can still cancel saving, by calling drupal_goto() inside mymodule_node_presave().
    – dxvargas
    Jul 25, 2013 at 15:24
  • 2
    @hiphip That's really not a good idea, you'll leave the node save in an inconsistent state if you redirect in the middle of it
    – Clive
    Jul 26, 2013 at 13:39
0

This is just some code I cobbled together. All credit must go to @eclecto for doing all of the leg work. This is just a (similarly untested) variation which takes the node objects directly, reduces the DB hits a bit and takes care of language negotiation.

function _node_fields_have_changed($old_node, $new_node) {
  // @TODO Sanity checks (e.g. node types match).

  // Get the fields attached to the node type.
  $params = array('entity_type' => 'node', 'bundle' => $old_node->type);
  foreach (field_read_fields($params) as $field) {
    // Get the field data for both nodes.
    $old_field_data = field_get_items('node', $old_node, $field['field_name']);
    $new_field_data = field_get_items('node', $new_node, $field['field_name']);

    // If the field existed on the old node, but not the new, it's changed.
    if ($old_field_data && !$new_field_data) {
      return TRUE;
    }
    // Ditto but in reverse.
    elseif ($new_field_data && !$old_field_data) {
      return TRUE;
    }

    foreach ($field['columns'] as $column_name => $column) {
      // If there's data in both columns we need an equality check.
      if (isset($old_field_data[$column_name]) && isset($new_field_data[$column_name])) {
        // Equality checking based on column type.
        if (in_array($column['type'], array('int', 'float', 'numeric')) && $old_field_data[$column_name] != $new_field_data[$column_name]) {
          return TRUE;
        }
        elseif ($old_field_data[$column_name] !== $new_field_data[$column_name]) {
          return TRUE;
        }
      }
      // Otherwise, if there's data for one column but not the other,
      // something changed.
      elseif (isset($old_field_data[$column_name]) || isset($new_field_data[$column_name])) {
        return TRUE;
      }
    } 
  }

  return FALSE;
}
1
  • 1
    You got me thinking along the same lines with my new version. I even included the node type sanity check.
    – Eric N
    Jul 26, 2013 at 17:45
0

The answer provided is great and it helped me, but there is something I had to correct.

// See if this field is a field collection.
if ($field_info['type'] == 'field_collection') {
  foreach ($old_field[LANGUAGE_NONE] as $delta => $values) {
    $old_field_collection = entity_load_unchanged('field_collection_item', $values['entity']->item_id);
    $new_field_collection = $values['entity'];

    $fields_changed = array_merge($fields_changed, erplain_api_fields_changed($old_field_collection, $new_field_collection));
  }
  unset($delta, $values);
}

In the foreach() loop, I had to change from $new_field to $old_field. I don't know if this is a new version of Drupal or only my code (might be due to another code somewhere else), but I don't have access to $new_field['entity'].

1
  • I just tested the _fields_changed() function on a fresh Drupal 7.41 install and saving a node with a field_collection gives me this $old_field and $new_field. It looks to me like you might be calling _fields_changed() with the $old_entity and $new_entity parameters the wrong way round (or you've accidentally swapped the variable names in your code somewhere).
    – morbiD
    Nov 3, 2015 at 15:48
0

Thanks for the post, really saved me a lot of time. I fixed a bunch of the warnings and notices that function was outputting:

/*
 * Pass both versions of the node to this function. Returns an array of
 * fields that were changed or an empty array if none were changed.
 * Pass field collections as an array keyed by field collection ID.
 *
 * @param object $old_entity
 *   The original (stored in the database) node object.
 *   This function may also pass itself a FieldCollectionItemEntity object to compare field collections.
 * @param object $new_entity
 *   The prepared node object for comparison.
 *   This function may also pass itself a FieldCollectionItemEntity object to compare field collections.
 */
function _fields_changed($old_entity, $new_entity) {
  $fields_changed = array();

  // Check for node or field collection.
  if (is_object($old_entity)) {
    $entity_is_field_collection = (get_class($old_entity) == 'FieldCollectionItemEntity');
    $bundle = !empty($entity_is_field_collection) ? $old_entity->field_name : $old_entity->type;
  }

  // Sanity check. Exit and throw an error if the content types don't match.
  if (is_object($new_entity)) {
    if ($bundle !== (!empty($entity_is_field_collection) ? $new_entity->field_name : $new_entity->type)) {
      drupal_set_message('Content type mismatch. Unable to save changes.', 'error');
      return FALSE;
    }
  }

  // Get field info.
  $field_read_params = array(
    'entity_type' => !empty($entity_is_field_collection) ? 'field_collection_item' : 'node',
  );

  if (!empty($bundle)) {
    $field_read_params['bundle'] = $bundle;
  }

  $fields_info = field_read_fields($field_read_params);

  foreach ($fields_info as $field_name => $field_info) {
    $old_field = isset($old_entity->$field_name) ? $old_entity->$field_name : NULL;
    $new_field = isset($new_entity->$field_name) ? $new_entity->$field_name : NULL;

    // Check the number of values for each field, or if they are populated at all.
    $old_field_count = (isset($old_field[LANGUAGE_NONE]) ? count($old_field[LANGUAGE_NONE]) : 0);
    $new_field_count = (isset($new_field[LANGUAGE_NONE]) ? count($new_field[LANGUAGE_NONE]) : 0);

    if ($old_field_count != $new_field_count) {
      // The two versions have a different number of values. Something has changed.
      $fields_changed[] = $field_name;
    }
    elseif ($old_field_count > 0 && $new_field_count > 0) {
      // Both versions have an equal number of values. Time to compare.

      // See if this field is a field collection.
      if ($field_info['type'] == 'field_collection') {

        foreach ($new_field[LANGUAGE_NONE] as $delta => $values) {
          $old_field_collection = NULL;
          if (!empty($values['entity']->item_id)) {
            $old_field_collection = entity_load_unchanged('field_collection_item', $values['entity']->item_id);
          }

          $new_field_collection = NULL;
          if (isset($values['entity'])) {
            $new_field_collection = $values['entity'];
          }

          $fields_changed = array_merge($fields_changed, _fields_changed($old_field_collection, $new_field_collection));
        }
        unset($delta, $values);

      }
      else {
        foreach ($old_field[LANGUAGE_NONE] as $delta => $value) {
          foreach ($field_info['columns'] as $field_column_name => $field_column_info) {
            $old_value = isset($old_field[LANGUAGE_NONE][$delta][$field_column_name]) ? $old_field[LANGUAGE_NONE][$delta][$field_column_name] : NULL;
            $new_value = isset($new_field[LANGUAGE_NONE][$delta][$field_column_name]) ? $new_field[LANGUAGE_NONE][$delta][$field_column_name] : NULL;
            $field_column_type = $field_column_info['type'];

            // As with the overall field, exit if one version has a value and the other doesn't.
            if (isset($old_value) != isset($new_value)) {
              $fields_changed[] = $old_field;
            }
            elseif (isset($old_value) && isset($new_value)) {
              // The column stores numeric data so compare values non-identically.
              if (in_array($field_column_type, array(
                'int',
                'float',
                'numeric'
              ))) {
                if ($new_value != $old_value) {
                  $fields_changed[] = $field_name;
                }
              }
              // The column stores non-numeric data so compare values identically,
              elseif ($new_value !== $old_value) {
                $fields_changed[] = $field_name;
              }
            }
            else {
              // Included for clarity. Both values are empty so there was obviously no change.
            }
          }
          unset($field_column_name, $field_column_info);
        }
        unset($delta, $value);
      }
    }
    else {
      // Included for clarity. Both values are empty so there was obviously no change.
    }
  }
  unset($field_name, $field_info);
  // End of field comparison loop.

  return $fields_changed;
}
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.