diff --git a/.github/workflows/build-ci-atlas.yml b/.github/workflows/build-ci-atlas.yml index 24f25da31..fe2a3ed4f 100644 --- a/.github/workflows/build-ci-atlas.yml +++ b/.github/workflows/build-ci-atlas.yml @@ -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 @@ -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 diff --git a/src/Eloquent/DocumentModel.php b/src/Eloquent/DocumentModel.php index 1ce62c733..b7ff62b0d 100644 --- a/src/Eloquent/DocumentModel.php +++ b/src/Eloquent/DocumentModel.php @@ -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; @@ -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; @@ -57,6 +56,22 @@ trait DocumentModel 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 + */ + private static array $nonScalarCastTypes = [ + 'array', + 'json', + 'json:unicode', + 'object', + 'collection', + ]; + /** * The parent relation instance. */ @@ -386,20 +401,33 @@ public function originalIsEquivalent($key) 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); + // 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); + } + if ($this->isClassComparable($key)) { return $this->compareClassCastableAttribute($key, $original, $attribute); } @@ -408,8 +436,9 @@ public function originalIsEquivalent($key) return ! is_object($attribute) ? $attribute === $original : $attribute == $original; } - 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]); + // phpcs:enable SlevomatCodingStandard.Operators.DisallowEqualOperators.DisallowedEqualOperator } /** @inheritdoc */ diff --git a/tests/ModelGetDirtyTest.php b/tests/ModelGetDirtyTest.php new file mode 100644 index 000000000..bd4c9fc2c --- /dev/null +++ b/tests/ModelGetDirtyTest.php @@ -0,0 +1,197 @@ +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')); + } +} diff --git a/tests/ModelTest.php b/tests/ModelTest.php index 91c267e03..0218f21c1 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -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; @@ -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']]);