CakePHP 4: Need assistance writing a behavior

Hello, everyone. I need some assistance writing a behavior. What I want to do is write an entry to a histories table when an entity has changed fields on submit. Each model has its own histories table. For example, I have a model called IncidentReports that hasMany IncidentHistories. I’ll describe the methods, then explain where I need help.

Here’s what I have in my HistoryBehavior so far:

First, a beforeSave that captures all of the beforeSave values on the form:

function beforeSave($event, $entity, $options)
{
    $this->beforeSaveValues = null;
        $this->recursive = 1;
        $this->beforeSaveValues = $this->getTable()->get($entity->id);
    return $this->beforeSaveValues;
}

So far, so good. Next, I have an afterSave() that grabs $this->beforeSaveValues and stores it in a variable called $original. This should be compared to the afterSave() values stored in $current.

function afterSave(EventInterface $event, $entity, $options)
{
    $identity = Router::getRequest()->getAttribute('identity'); 
    $userId = $identity->full_name; 
    $original = $this->beforeSaveValues; 
    $this->recursive = 1;
    $current = $this->getTable()->get($entity->id);
    $table = $this->getTable()->getAlias();

    if ($original != null) {
        $record = (int) $current->id;
        if ($record > 0) {
        $changes = $this->differences($original, $current);
            if (!empty($changes)) {
                $this->logHistory($entity->id, $userId, $changes, $table);
            }
        }
    }
    return true;
}

afterSave calls a method differences that should be checking for changes between old and new data:

function differences($from, $to) 
{ 
    $ignore = ['created', 'modified', 'created_user_id', 'modified_user_id', 'deleted', 'deleted_user_id']; 
    $changed = [];
    foreach ($from as $key => $value) { 
      $v1 = $to[$key];
      $v2 = $value;

      // The fields might contain HTML which should be stripped to make sure a real change was made

        $v1 = $this->stripHtml($v1); // Custom method I won't display here. 
        $v2 = $this->stripHtml($v2);
        if($v1 == 0) $v1 = '';
        if($v2 == 0) $v2 = '';

      // if it's not an ignore field and the values don't match, add it
      if (!in_array($key, $ignore) && $v1 != $v2) {
        $changed[$key] = ['from' => $value, 'to' => $to[$key]];
      }
    } 
    return $changed;
}

Finally, my logHistory method:

function logHistory($id, $userId, $changes, $table) {
    $date = date('Y-m-d H:i:s');
    $type = $table;
    foreach ($changes as $field => $values) {
        $history = $this->getTable()->newEmptyEntity();
        $data = [
          'incident_id' => $id,
          'type' => $type,
          'date' => $date,
          'user' => $userId,
          'status' => $field,
          'from' => $values['from'],
          'to' => $values['to'],
        ];
        $history = $this->getTable()->patchEntity($history, $data); 
        $this->getTable()->save($history);
    }
}

WHERE I NEED HELP:

I’m unsure about this line in my afterSave() method:

$changes = $this->differences($original, $current);

When I debug t$original and $current vars in differences($from, $to), I can see the data is getting there, but nothing ever reaches the foreach loop. I feel like I’m missing something in the function call. Until I can jump this hurdle, I can’t troubleshoot the rest of my behavior. Any suggestions?

I haven’t dug into your current logic, but a cursory scan suggests you’re not using a couple of Entity features that could help you.

When you patch an entity it will get a dirty property that tells you what has change. It will also be able to report what the original values were (described in the same section).

These tools may simplify your task.

You should, in theory, be able to patch your entity, prepare the history, then do both saves as a single transaction to insure there is never a mismatch between the data and the history.

Yes, these are definitely useful. Thank you! I’m working out how to incorporate them into my behavior.

Is this of any use?

Thanks. I did look at that, but it’s not written for 4.0. Composer won’t even install it. What I have in work right now is something I’m porting over from an old 2.x app. @dreamingmind gave me a good direction earlier, so I’m working that angle now. I’m getting closer! I moved my afterSave() back into my IncidentReportsTable and I’ve modified it thus:

function afterSave(EventInterface $event, $entity, $options)
{   
    $identity = Router::getRequest()->getAttribute('identity'); 
    $userId = $identity->full_name; 
    $this->recursive = 1;
    // Get the original and changed field values for use in behavior
    $clean = $entity->getOriginalValues(); 
    $dirty = $entity->getDirty(); 

    if ($clean != null) {
        $record = (int) $entity->id;
        if ($record > 0) {
        $changes = $this->differences($clean, $dirty);
            if (!empty($changes)) {
                $this->logHistory($entity->id, $userId, $changes, $table);
            }
        }
    }
    return true;
} 

Now, when differences() is called in my behavior, I’m seeing data hit the foreach() loop, but it’s not ignoring the fields I need it to ignore, and I’m getting a lot of NULLs in my incident_histories table. But the fact that it’s writing something is more than I had before.

@Zuluru, @dreamingmind: I have an update that you guys might be able to help with.

Here’s the current state of my afterSave method:

function afterSave(EventInterface $event, $entity, $options)
{   
    $identity = Router::getRequest()->getAttribute('identity'); 
    $userId = $identity->full_name; 
    $this->recursive = 1;
    // Get the original and changed field values for use in behavior
    $clean = $entity->extractOriginalChanged($entity->getVisible());
    $dirty = $entity->getDirty(); 

    if ($clean != null) {
        $record = (int) $entity->id;
        if ($record > 0) {
        $changes = $this->differences($clean, $dirty);
            if (!empty($changes)) {
                $this->logHistory($entity->id, $userId, $changes, $table);
            }
        }
    }
    return true;
}  

All I need is to get the dirty values, and I’ll have a pair of clean arrays I can pass to my behavior to start the comparison. How do I get the dirty values after calling getDirty(), which only appears to get the field names?

How about:

  • get the dirty field names
  • loop on them comparing the original value for that field to the current property for that field

of course those will all not match :slight_smile: .

but looping on the dirty names will give you direct access to the old and new value of the fields that changed.

1 Like

Yes, like @dreamingmind said, but to be clear, if you have $field with the name of the field you’re looking at, then $entity->$field is the new (and hence “dirty”) value, and $entity->getOriginal($field) will be the old value.

2 Likes

So, good news! I’ve (almost) got this working. Current state of afterSave():

function afterSave(EventInterface $event, $entity, $options)
{   
    $identity = Router::getRequest()->getAttribute('identity'); 
    $userId = $identity->full_name; 
    $this->recursive = 1;
    $current = $this->get($entity->id); 
    // Get the original and changed field values for use in behavior
    $clean = $entity->extractOriginalChanged($entity->getVisible()); 
    $dirty = $entity->getDirty(); 
    foreach ($dirty as $d) { // ADDED AFTER DISCUSSION
        $dirtyVal[$d] = $current[$d]; 
    } 
    if ($clean != null) {
        $record = (int) $entity->id;
        if ($record > 0) {
        $changes = $this->differences($clean, $dirtyVal); 
            if (!empty($changes)) {
                $this->logHistory($entity->id, $userId, $changes);
            } 
        }
    }
    return true;
}

(If there’s a cleaner way to do that foreach(), I’m all ears, but this worked for me.)

So when I say “almost,” I mean that it recognizes differences in dropdown values and writes those values to my table (yay!), but not in text fields. When I debug, however, I see that the different text values are making it into the differences method of my behavior. For example, I have a plain text field called root_cause where I typed the following:

The quick brown fox

Debug of $v1 and $v2 in differences() method shows that the field changed from '' to The quick brown fox

 APP/Model/Behavior/HistoryBehavior.php (line 22)
'The quick brown fox'

APP/Model/Behavior/HistoryBehavior.php (line 23)
''

But debugging $changed in the same method comes up blank.

APP/Model/Behavior/HistoryBehavior.php (line 35)
[
]

So no change gets written to my incident_histories table. Is there a trick to comparing text strings in Cake 4? This works in Cake 2.

Try !== there. The != comparison is not type sensitive, and returns true for a lot of things when compared to null.

Tried it, but I don’t think that’s it. The differences method is being called no problem, and the changed values are making it in there, but for some reason, It’s skipping over text fields. If I change a dropdown and a text field in the same update, it only records the dropdown change. Weird!

@Zuluru: You did give me something to look for in my differences method, where I had the following:

if($v1 == 0) $v1 = '';
if($v2 == 0) $v2 = ''; 

Changing the operators from == to === fixed it. My text field changes are now being recorded. Now I know that == is a weaker comparison, but I’m not sure why it would affect my text fields, especially when they shouldn’t evaluate to zero, regardless of whether they’re blank. And I don’t get why it affected them when they contained text. Any ideas?

At any rate, my histories behavior is now working as expected! My thanks to both of you!

When it comes to ==, an empty string, false, null and 0 are all equal.