Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/build-ci-atlas.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ jobs:

- name: Create MongoDB Atlas Local
timeout-minutes: 5

run: |
docker run --name mongodb -p 27017:27017 --detach mongodb/mongodb-atlas-local:latest
until docker exec --tty mongodb mongosh --eval "db.runCommand({ ping: 1 })"; do
Expand All @@ -49,6 +50,9 @@ jobs:
until docker exec --tty mongodb mongosh --eval "db.runCommand({ serverStatus: 1 })"; do
sleep 1
done
until docker exec --tty mongodb mongosh --eval "db.runCommand({ ping: 1 })"; do
sleep 1
done

- name: Setup cache environment
id: extcache
Expand Down
57 changes: 43 additions & 14 deletions src/Eloquent/DocumentModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Illuminate\Support\Str;
use MongoDB\BSON\Binary;
use MongoDB\BSON\Decimal128;
use MongoDB\BSON\Document;
use MongoDB\BSON\ObjectID;
use MongoDB\BSON\Type;
use MongoDB\BSON\UTCDateTime;
Expand All @@ -37,15 +38,13 @@
use function func_get_args;
use function in_array;
use function is_array;
use function is_numeric;
use function is_object;
use function is_scalar;
use function is_string;
use function ltrim;
use function method_exists;
use function sprintf;
use function str_contains;
use function str_starts_with;
use function strcmp;
use function strlen;
use function trigger_error;

Expand All @@ -57,6 +56,22 @@
use HybridRelations;
use EmbedsRelations;

/**
* Non-scalar, non-date cast types excluded from castAttribute() comparison.
* These fall through to BSON Document comparison in originalIsEquivalent().
* Date types are excluded separately via isDateAttribute().
* Everything else in $primitiveCastTypes produces a scalar and is compared via castAttribute().
*
* @var list<string>
*/
private static array $nonScalarCastTypes = [
'array',
'json',
'json:unicode',
'object',
'collection',
];

/**
* The parent relation instance.
*/
Expand Down Expand Up @@ -386,30 +401,44 @@
return false;
}

if ($this->isDateAttribute($key)) {
$attribute = $attribute instanceof UTCDateTime ? $this->asDateTime($attribute) : $attribute;
$original = $original instanceof UTCDateTime ? $this->asDateTime($original) : $original;
// For primitive casts that produce scalar-comparable values, apply the cast before
// comparing. This preserves Eloquent's behavior where int(1) and string('1') are
// equivalent on a field cast to int, and where re-assigning the same encrypted
// plaintext is not dirty. Non-scalar types (array/object/collection/json) fall through
// to BSON comparison. Date types fall through to UTCDateTime conversion below.
if (
$this->hasCast($key, static::$primitiveCastTypes)
&& ! $this->isDateAttribute($key)
&& ! $this->hasCast($key, self::$nonScalarCastTypes)
) {
return $this->castAttribute($key, $attribute) === $this->castAttribute($key, $original);
}

// Comparison on DateTimeInterface values
// phpcs:disable SlevomatCodingStandard.Operators.DisallowEqualOperators.DisallowedEqualOperator
return $attribute == $original;
if (is_scalar($attribute) || is_scalar($original)) {
return false;
}

if ($this->hasCast($key, static::$primitiveCastTypes)) {
return $this->castAttribute($key, $attribute) ===
$this->castAttribute($key, $original);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should casts be applied before we convert to BSON?

// Convert DateTime instances to UTCDateTime for comparison.
// As done in Grammar::prepareFieldsForQuery()
if ($attribute instanceof DateTimeInterface) {
$attribute = new UTCDateTime($attribute);
}

if ($original instanceof DateTimeInterface) {
$original = new UTCDateTime($original);
}

Check failure on line 430 in src/Eloquent/DocumentModel.php

View workflow job for this annotation

GitHub Actions / phpcs

Whitespace found at end of line
if ($this->isClassComparable($key)) {
return $this->compareClassCastableAttribute($key, $original, $attribute);
}

if ($this->isClassCastable($key)) {
return ! is_object($attribute) ? $attribute === $original : $attribute == $original;

Check failure on line 436 in src/Eloquent/DocumentModel.php

View workflow job for this annotation

GitHub Actions / phpcs

Operator == is disallowed, use === instead.

Check failure on line 436 in src/Eloquent/DocumentModel.php

View workflow job for this annotation

GitHub Actions / phpcs

Function is_object() should not be referenced via a fallback global name, but via a use statement.
}

return is_numeric($attribute) && is_numeric($original)
&& strcmp((string) $attribute, (string) $original) === 0;
// phpcs:disable SlevomatCodingStandard.Operators.DisallowEqualOperators.DisallowedEqualOperator
return Document::fromPHP(['v' => $attribute]) == Document::fromPHP(['v' => $original]);
Comment thread
GromNaN marked this conversation as resolved.
// phpcs:enable SlevomatCodingStandard.Operators.DisallowEqualOperators.DisallowedEqualOperator
}

/** @inheritdoc */
Expand Down
197 changes: 197 additions & 0 deletions tests/ModelGetDirtyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
<?php

declare(strict_types=1);

namespace MongoDB\Laravel\Tests;

use Carbon\Carbon;
use DateTime;
use MongoDB\Laravel\Tests\Models\Casting;
use MongoDB\Laravel\Tests\Models\MemberStatus;
use MongoDB\Laravel\Tests\Models\Options;
use MongoDB\Laravel\Tests\Models\User;

class ModelGetDirtyTest extends TestCase
{
protected function tearDown(): void
{
User::truncate();
Casting::truncate();

parent::tearDown();
}

public function testGetDirtyDates(): void
{
$user = new User();
$user->name = 'John Doe';
$user->birthday = new DateTime('19 august 1989');
$user->syncOriginal();

// Same date: not dirty
$this->assertEmpty($user->getDirty());

$user->birthday = new DateTime('19 august 1989');
// Same date set again: not dirty
$this->assertEmpty($user->getDirty());
}

public function testGetDirtyObjects(): void
{
$user = new User();
$user->options = new Options();
// New unsaved model: dirty
$this->assertNotEmpty($user->getDirty());

$user->save();
// After save: not dirty
$this->assertEmpty($user->getDirty());

// Different object value: dirty
$user->options = (new Options())->setOption1('Value1');
$this->assertNotEmpty($user->getDirty());

$user->save();
// After save: not dirty
$this->assertEmpty($user->getDirty());
}

public function testGetDirtyScalarTypeChange(): void
{
// Changing a scalar value from one type to another must be considered dirty
// because MongoDB stores types as-is (int 1 and string '1' are different).
$user = new User();
$user->name = 'John Doe';
$user->age = 25;
$user->syncOriginal();

$this->assertEmpty($user->getDirty());

// Same value, same type: not dirty
$user->age = 25;
$this->assertEmpty($user->getDirty());

// Same numeric value, different type: dirty
$user->age = '25';
$this->assertTrue($user->isDirty('age'));
}

public function testGetDirtyEmbeddedDocument(): void
{
$user = User::create(['name' => 'John Doe', 'address' => ['city' => 'Paris', 'country' => 'France']]);

$user = User::find($user->id);
$this->assertFalse($user->isDirty());

// Setting the same array value: not dirty
$user->address = ['city' => 'Paris', 'country' => 'France'];
$this->assertFalse($user->isDirty());

// Changing a nested value: dirty
$user->address = ['city' => 'Lyon', 'country' => 'France'];
$this->assertTrue($user->isDirty('address'));
}

public function testGetDirtyDatetimeCast(): void
{
$user = User::create(['name' => 'John Doe', 'birthday' => new DateTime('1989-08-19 12:00:00')]);
$user = User::find($user->id);
$this->assertFalse($user->isDirty());

// Same date via Carbon: not dirty
$user->birthday = Carbon::parse('1989-08-19 12:00:00');
$this->assertFalse($user->isDirty('birthday'));

// Same date via DateTime: not dirty
$user->birthday = new DateTime('1989-08-19 12:00:00');
$this->assertFalse($user->isDirty('birthday'));

// Different date: dirty
$user->birthday = new DateTime('1990-01-01 00:00:00');
$this->assertTrue($user->isDirty('birthday'));

// Null vs date: dirty
$user->save();
$user->birthday = null;
$this->assertTrue($user->isDirty('birthday'));
}

public function testGetDirtyEnumCast(): void
{
$user = User::create(['name' => 'John Doe', 'member_status' => MemberStatus::Member]);
$user = User::find($user->id);
$this->assertFalse($user->isDirty());

// Same enum value: not dirty
$user->member_status = MemberStatus::Member;
$this->assertFalse($user->isDirty('member_status'));

// Setting null: dirty
$user->member_status = null;
$this->assertTrue($user->isDirty('member_status'));
}

public function testGetDirtyWithPrimitiveCast(): void
{
$casting = Casting::create(['intNumber' => 1, 'floatNumber' => 1.5, 'stringContent' => 'hello', 'booleanValue' => true]);
$casting = Casting::find($casting->id);
$this->assertFalse($casting->isDirty());

// Same value, different PHP type: cast normalizes to the same value, so not dirty
$casting->intNumber = '1';
$this->assertFalse($casting->isDirty('intNumber'));

$casting->booleanValue = 1;
$this->assertFalse($casting->isDirty('booleanValue'));

// Different effective value: dirty
$casting->intNumber = 2;
$this->assertTrue($casting->isDirty('intNumber'));

$casting->floatNumber = 1.6;
$this->assertTrue($casting->isDirty('floatNumber'));
}

public function testGetDirtyWithObjectAndArrayCast(): void
{
$casting = Casting::create(['objectValue' => (object) ['x' => 1], 'arrayValue' => [1, 2, 3]]);
$casting = Casting::find($casting->id);
$this->assertFalse($casting->isDirty());

// Same content via different PHP type (array vs stdClass): BSON encoding makes them equivalent
$casting->objectValue = (object) ['x' => 1];
$this->assertFalse($casting->isDirty('objectValue'));

$casting->arrayValue = [1, 2, 3];
$this->assertFalse($casting->isDirty('arrayValue'));

// Different content: dirty
$casting->objectValue = (object) ['x' => 2];
$this->assertTrue($casting->isDirty('objectValue'));

$casting->arrayValue = [1, 2, 4];
$this->assertTrue($casting->isDirty('arrayValue'));
}

public function testGetDirtyDateWithoutCast(): void
{
// A date field stored as UTCDateTime in MongoDB without an explicit cast.
// When reloaded, $original contains a UTCDateTime. Assigning a Carbon/DateTime
// triggers the DateTimeInterface -> UTCDateTime conversion before Document::fromPHP ==.
$user = User::create(['name' => 'John Doe', 'registered_at' => new DateTime('2024-01-15 12:00:00')]);
$user = User::find($user->id);
$this->assertFalse($user->isDirty());

// Same date via Carbon: not dirty
$user->registered_at = Carbon::parse('2024-01-15 12:00:00');
$this->assertFalse($user->isDirty('registered_at'));

// Same date via DateTime: not dirty
$user->registered_at = new DateTime('2024-01-15 12:00:00');
$this->assertFalse($user->isDirty('registered_at'));

// Different date: dirty
$user->registered_at = new DateTime('2024-01-16 00:00:00');
$this->assertTrue($user->isDirty('registered_at'));
}
}
27 changes: 0 additions & 27 deletions tests/ModelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
use MongoDB\Laravel\Tests\Models\Item;
use MongoDB\Laravel\Tests\Models\MemberStatus;
use MongoDB\Laravel\Tests\Models\NonIncrementing;
use MongoDB\Laravel\Tests\Models\Options;
use MongoDB\Laravel\Tests\Models\Soft;
use MongoDB\Laravel\Tests\Models\SqlUser;
use MongoDB\Laravel\Tests\Models\User;
Expand Down Expand Up @@ -1056,32 +1055,6 @@ public function testMultipleLevelDotNotation(): void
$this->assertEquals('The first chapter', $book['chapters.one.title']);
}

public function testGetDirtyDates(): void
{
$user = new User();
$user->setRawAttributes(['name' => 'John Doe', 'birthday' => new DateTime('19 august 1989')], true);
$this->assertEmpty($user->getDirty());

$user->birthday = new DateTime('19 august 1989');
$this->assertEmpty($user->getDirty());
}

public function testGetDirtyObjects(): void
{
$user = new User();
$user->options = new Options();
$this->assertNotEmpty($user->getDirty());

$user->save();
$this->assertEmpty($user->getDirty());

$user->options = (new Options())->setOption1('Value1');
$this->assertNotEmpty($user->getDirty());

$user->save();
$this->assertEmpty($user->getDirty());
}

public function testChunkById(): void
{
User::create(['name' => 'fork', 'tags' => ['sharp', 'pointy']]);
Expand Down
Loading