Skip to content

Commit

Permalink
Merge pull request #75 from felabrecque/fix/model-changes-and-observer
Browse files Browse the repository at this point in the history
Revert PR #74 and fix getChanges()
  • Loading branch information
freekmurze authored Jan 17, 2025
2 parents 1f14aba + 350ddca commit 8a9ec6a
Show file tree
Hide file tree
Showing 5 changed files with 152 additions and 4 deletions.
37 changes: 36 additions & 1 deletion src/Concerns/UsesCipherSweet.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ trait UsesCipherSweet
{
public static EncryptedRow $cipherSweetEncryptedRow;

// Keeps which attributes were really dirty when saving
protected array $cipherSweetSavingUnencryptedAttributes = [];

protected static function bootUsesCipherSweet()
{
static::observe(ModelObserver::class);
Expand Down Expand Up @@ -73,7 +76,7 @@ public function deleteBlindIndexes(): void
public function decryptRow(): void
{
$this->setRawAttributes(static::$cipherSweetEncryptedRow->setPermitEmpty(config('ciphersweet.permit_empty', false))
->decryptRow($this->getAttributes()), false);
->decryptRow($this->getAttributes()), true);
}

public function scopeWhereBlind(
Expand All @@ -94,6 +97,38 @@ public function scopeOrWhereBlind(
return $query->orWhereExists(fn (Builder $query): Builder => $this->buildBlindQuery($query, $column, $indexName, $value));
}

public function excludeNonChangedEncryptedAttributesFromChanges(): self
{
// Changes will contain the encrypted fields, event when none of these fields were changed
// (because Laravel will compare the unencrypted value with the encrypted one which will never match)
$changes = $this->getChanges();

// Remove all encrypted attributes that were not previously dirty
if (! empty($changes)) {
foreach (static::$cipherSweetEncryptedRow->listEncryptedFields() as $field) {
if (! array_key_exists($field, $this->cipherSweetSavingUnencryptedAttributes)) {
unset($changes[$field]);
} else {
// Use unencrypted value instead of encrypted
$changes[$field] = $this->cipherSweetSavingUnencryptedAttributes[$field];
}
}

$this->changes = $changes;
}

$this->cipherSweetSavingUnencryptedAttributes = [];

return $this;
}

public function saveDirtyAttributesForCipherSweet(): self
{
$this->cipherSweetSavingUnencryptedAttributes = $this->getDirty();

return $this;
}

private function buildBlindQuery(
Builder $query,
string $column,
Expand Down
9 changes: 9 additions & 0 deletions src/Observers/ModelObserver.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,22 @@ public function retrieved(CipherSweetEncrypted $model): void

public function saving(CipherSweetEncrypted $model): void
{
$model->saveDirtyAttributesForCipherSweet();

Check failure on line 26 in src/Observers/ModelObserver.php

View workflow job for this annotation

GitHub Actions / phpstan

Call to an undefined method Spatie\LaravelCipherSweet\Contracts\CipherSweetEncrypted::saveDirtyAttributesForCipherSweet().

$model->encryptRow();

// NOTE: If other listeners are called after this, all the encrypted attributes will appear in the dirty list
// since each field will contain their encrypted value.
// Having "Listener priority" might fix this (put the encrypter at the lowest priority
// so all other listeners are called first), but Laravel doesn't support that (yet).
}

public function saved(CipherSweetEncrypted $model): void
{
$model->decryptRow();

$model->excludeNonChangedEncryptedAttributesFromChanges();

Check failure on line 40 in src/Observers/ModelObserver.php

View workflow job for this annotation

GitHub Actions / phpstan

Call to an undefined method Spatie\LaravelCipherSweet\Contracts\CipherSweetEncrypted::excludeNonChangedEncryptedAttributesFromChanges().

$model->updateBlindIndexes();
}
}
77 changes: 77 additions & 0 deletions tests/ModelTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

use Spatie\LaravelCipherSweet\Tests\TestClasses\User;

it('can have correct attributes|dirty|changes properties',function () {
expect(User::count(), 0);

$user = User::create([
'name' => 'John Doe',
'password' => bcrypt('password'),
'email' => '[email protected]',
]);

expect($user)
->name->toBe('John Doe')
->getAttribute('name')->toBe('John Doe')
->created_at->not()->toBeNull()
->updated_at->not()->toBeNull()
->isDirty()->toBeFalse()
->isClean()->toBeTrue()
->getDirty()->toBeEmpty()
->getOriginal('name')->toBe('John Doe')
->wasChanged()->toBeFalse()
->getChanges()->toBeEmpty();

expect(User::count(), 1);

$user = User::first();

expect($user)
->name->toBe('John Doe')
->getAttribute('name')->toBe('John Doe')
->created_at->not()->toBeNull()
->updated_at->not()->toBeNull()
->isDirty()->toBeFalse()
->isClean()->toBeTrue()
->getDirty()->toBeEmpty()
->getOriginal('name')->toBe('John Doe')
->wasChanged()->toBeFalse()
->getChanges()->toBeEmpty();

$user->name = 'New name';

expect($user)
->name->toBe('New name')
->getAttribute('name')->toBe('New name')
->isDirty()->toBeTrue()
->isClean()->toBeFalse()
->getDirty()->toBe(['name' => 'New name'])
->getOriginal('name')->toBe('John Doe')
->wasChanged()->toBeFalse()
->getChanges()->toBeEmpty();

$createdAt = $user->created_at;
$updatedAt = $user->updated_at;

$this->travel(1)->seconds();
$user->save();
$this->travelBack();

expect($user)
->name->toBe('New name')
->getAttribute('name')->toBe('New name')
->created_at->toEqual($createdAt)
->updated_at->toBeGreaterThan($updatedAt)
->isDirty()->toBeFalse()
->isClean()->toBeTrue()
->getDirty()->toBeEmpty()
->getOriginal('name')->toBe('New name')
->wasChanged('name')->toBeTrue()
->wasChanged('password')->toBeFalse()
->wasChanged('email')->toBeFalse()
->getChanges()->toBe([
'name' => 'New name',
'updated_at' => (string) $user->updated_at,
]);
});
31 changes: 29 additions & 2 deletions tests/ObserverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,40 @@
Log::spy();

User::observe(UserObserver::class);

$this->travel(1)->seconds(); // to force updated_at to be updated
$user->update(['email' => '[email protected]']);
$this->travelBack();

Log::shouldHaveReceived('info')
->with("saving: dirty=2") // name attribute + encrypted email attribute
->once();

Log::shouldHaveReceived('info')
->with("saved: changed=3") // the name, updated_at & email attribute
->once();
});

it('can exclude unmodified attributes from changes', function () {
$user = User::create([
'name' => 'John Doe',
'password' => bcrypt('password'),
'email' => '[email protected]',
]);

Log::spy();

User::observe(UserObserver::class);

$this->travel(1)->seconds(); // to force updated_at to be updated
$user->update(['name' => 'John Doe2']);
$this->travelBack();

Log::shouldHaveReceived('info')
->with("saving: dirty=2")
->with("saving: dirty=2") // name attribute + encrypted email attribute (encrypted are always present)
->once();

Log::shouldHaveReceived('info')
->with("saved: dirty=2")
->with("saved: changed=2") // the name & updated_at attribute
->once();
});
2 changes: 1 addition & 1 deletion tests/TestClasses/UserObserver.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class UserObserver
{
public function saved(User $user)
{
$msg = "saved: dirty=".sizeof($user->getDirty());
$msg = "saved: changed=".sizeof($user->getChanges());
\Log::info($msg);
}

Expand Down

0 comments on commit 8a9ec6a

Please sign in to comment.