Skip to content

Commit cf9cede

Browse files
[12.x] feat: Make custom eloquent castings comparable for more granular isDirty check (#55945)
* Make castable properties comparable for more granular dirty checks * formatting --------- Co-authored-by: Taylor Otwell <taylor@laravel.com>
1 parent b613f90 commit cf9cede

File tree

3 files changed

+102
-0
lines changed

3 files changed

+102
-0
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace Illuminate\Contracts\Database\Eloquent;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
7+
interface ComparesCastableAttributes
8+
{
9+
/**
10+
* Determine if the given values are equal.
11+
*
12+
* @param \Illuminate\Database\Eloquent\Model $model
13+
* @param string $key
14+
* @param mixed $firstValue
15+
* @param mixed $secondValue
16+
* @return bool
17+
*/
18+
public function compare(Model $model, string $key, mixed $firstValue, mixed $secondValue);
19+
}

src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -990,6 +990,21 @@ protected function serializeClassCastableAttribute($key, $value)
990990
);
991991
}
992992

993+
/**
994+
* Compare two values for the given attribute using the custom cast class.
995+
*
996+
* @param string $key
997+
* @param mixed $original
998+
* @param mixed $value
999+
* @return bool
1000+
*/
1001+
protected function compareClassCastableAttribute($key, $original, $value)
1002+
{
1003+
return $this->resolveCasterClass($key)->compare(
1004+
$this, $key, $original, $value
1005+
);
1006+
}
1007+
9931008
/**
9941009
* Determine if the cast type is a custom date time cast.
9951010
*
@@ -1800,6 +1815,19 @@ protected function isClassSerializable($key)
18001815
method_exists($this->resolveCasterClass($key), 'serialize');
18011816
}
18021817

1818+
/**
1819+
* Determine if the key is comparable using a custom class.
1820+
*
1821+
* @param string $key
1822+
* @return bool
1823+
*/
1824+
protected function isClassComparable($key)
1825+
{
1826+
return ! $this->isEnumCastable($key) &&
1827+
$this->isClassCastable($key) &&
1828+
method_exists($this->resolveCasterClass($key), 'compare');
1829+
}
1830+
18031831
/**
18041832
* Resolve the custom caster class for a given key.
18051833
*
@@ -2265,6 +2293,8 @@ public function originalIsEquivalent($key)
22652293
}
22662294

22672295
return false;
2296+
} elseif ($this->isClassComparable($key)) {
2297+
return $this->compareClassCastableAttribute($key, $original, $attribute);
22682298
}
22692299

22702300
return is_numeric($attribute) && is_numeric($original)

tests/Database/EloquentModelCustomCastingTest.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use GMP;
77
use Illuminate\Contracts\Database\Eloquent\Castable;
88
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
9+
use Illuminate\Contracts\Database\Eloquent\ComparesCastableAttributes;
910
use Illuminate\Contracts\Database\Eloquent\SerializesCastableAttributes;
1011
use Illuminate\Database\Capsule\Manager as DB;
1112
use Illuminate\Database\Eloquent\Model;
@@ -53,6 +54,11 @@ public function createSchema()
5354
$table->increments('id');
5455
$table->decimal('amount', 4, 2);
5556
});
57+
58+
$this->schema()->create('documents', function (Blueprint $table) {
59+
$table->increments('id');
60+
$table->json('document');
61+
});
5662
}
5763

5864
/**
@@ -64,6 +70,7 @@ protected function tearDown(): void
6470
{
6571
$this->schema()->drop('casting_table');
6672
$this->schema()->drop('members');
73+
$this->schema()->drop('documents');
6774
}
6875

6976
#[RequiresPhpExtension('gmp')]
@@ -176,6 +183,25 @@ public function testModelWithCustomCastsWorkWithCustomIncrementDecrement()
176183
$this->assertEquals('3.00', $model->amount->value);
177184
}
178185

186+
public function test_model_with_custom_casts_compare_function()
187+
{
188+
// Set raw attribute, this is an example of how we would receive JSON string from the database.
189+
// Note the spaces after the colon.
190+
$model = new Document();
191+
$model->setRawAttributes(['document' => '{"content": "content", "title": "hello world"}']);
192+
$model->save();
193+
194+
// Inverse title and content this would result in a different JSON string when json_encode is used
195+
$document = new \stdClass();
196+
$document->title = 'hello world';
197+
$document->content = 'content';
198+
$model->document = $document;
199+
200+
$this->assertFalse($model->isDirty('document'));
201+
$document->title = 'hello world 2';
202+
$this->assertTrue($model->isDirty('document'));
203+
}
204+
179205
/**
180206
* Get a database connection instance.
181207
*
@@ -410,3 +436,30 @@ class Member extends Model
410436
'amount' => Euro::class,
411437
];
412438
}
439+
440+
class Document extends Model
441+
{
442+
public $timestamps = false;
443+
444+
protected $casts = [
445+
'document' => StructuredDocumentCaster::class,
446+
];
447+
}
448+
449+
class StructuredDocumentCaster implements CastsAttributes, ComparesCastableAttributes
450+
{
451+
public function get($model, $key, $value, $attributes)
452+
{
453+
return json_decode($value);
454+
}
455+
456+
public function set($model, $key, $value, $attributes)
457+
{
458+
return json_encode($value);
459+
}
460+
461+
public function compare($model, $key, $value1, $value2)
462+
{
463+
return json_decode($value1) == json_decode($value2);
464+
}
465+
}

0 commit comments

Comments
 (0)