From 00f5fc21b256a54b7fd8a552e0012cf5dff0d120 Mon Sep 17 00:00:00 2001 From: Mike W <3036663+enlivenapp@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:40:00 -0400 Subject: [PATCH] null to falsy value detection in syncDirtyFromProperties --- src/ActiveRecord.php | 2 +- tests/TypedPropertyTest.php | 68 +++++++++++++++++++++++++++++++++++-- tests/classes/TypedUser.php | 6 ++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/ActiveRecord.php b/src/ActiveRecord.php index f991354..56acfb7 100644 --- a/src/ActiveRecord.php +++ b/src/ActiveRecord.php @@ -456,7 +456,7 @@ protected function syncDirtyFromProperties(bool $onlyChanged = false): void if ($onlyChanged) { $storedValue = $this->data[$name] ?? null; - if ($currentValue != $storedValue) { + if ($currentValue !== $storedValue) { $this->dirty[$name] = $currentValue; } } else { diff --git a/tests/TypedPropertyTest.php b/tests/TypedPropertyTest.php index b3c7e33..9d7217d 100644 --- a/tests/TypedPropertyTest.php +++ b/tests/TypedPropertyTest.php @@ -32,7 +32,8 @@ public function setUp(): void id INTEGER PRIMARY KEY, name TEXT, password TEXT, - created_dt TEXT + created_dt TEXT, + credits REAL )"); } @@ -80,6 +81,31 @@ public function testUpdateWithTypedProperties(): void $this->assertSame('eve_updated', $row['name'], 'update should persist changed typed property'); } + public function testSyncDoesNotDirtyUnchangedTypedProperties(): void + { + $this->pdo->exec("INSERT INTO user (name, password, credits) VALUES ('neo', 'hash10', NULL)"); + + $user = new TypedUser($this->pdo); + $user->eq('name', 'neo')->find(); + + // Re-assign the exact values the typed properties already hold + $user->name = 'neo'; + $user->credits = null; + + $sync = new \ReflectionMethod(\flight\ActiveRecord::class, 'syncDirtyFromProperties'); + $sync->setAccessible(true); + $sync->invoke($user, true); + + $dirtyProp = new \ReflectionProperty(\flight\ActiveRecord::class, 'dirty'); + $dirtyProp->setAccessible(true); + + $this->assertSame( + [], + $dirtyProp->getValue($user), + 're-assigning identical values to typed properties must not mark them dirty (no spurious UPDATE)' + ); + } + public function testUpdateDoesNotTouchUnchangedFields(): void { $this->pdo->exec("INSERT INTO user (name, password) VALUES ('frank', 'hash6')"); @@ -146,7 +172,45 @@ public function testFindAllRowsDoNotInheritPriorRowData(): void ); } - public function testSyncDirtySkipsPropertiesAlreadyInDirty(): void + public function testUpdatePersistsZeroFloatOverNull(): void + { + $this->pdo->exec("INSERT INTO user (name, password) VALUES ('kara', 'hash8')"); + + $user = new TypedUser($this->pdo); + $user->eq('name', 'kara')->find(); + + $this->assertNull($user->credits, 'fixture: credits is NULL in the DB'); + + $user->credits = 0.0; + $user->save(); + + $row = $this->pdo->query("SELECT credits FROM user WHERE id = " . (int) $user->id)->fetch(PDO::FETCH_ASSOC); + $this->assertNotNull($row['credits'], '0.0 must be persisted over a NULL stored value'); + $this->assertEquals(0.0, (float) $row['credits']); + } + + public function testSyncDetectsFloatZeroChangeFromNull(): void + { + $this->pdo->exec("INSERT INTO user (name, password) VALUES ('lena', 'hash9')"); + + $user = new TypedUser($this->pdo); + $user->eq('name', 'lena')->find(); + + $user->credits = 0.0; + + $sync = new \ReflectionMethod(\flight\ActiveRecord::class, 'syncDirtyFromProperties'); + $sync->setAccessible(true); + $sync->invoke($user, true); + + $dirtyProp = new \ReflectionProperty(\flight\ActiveRecord::class, 'dirty'); + $dirtyProp->setAccessible(true); + $dirty = $dirtyProp->getValue($user); + + $this->assertArrayHasKey('credits', $dirty, '0.0 must be marked dirty when the stored value is NULL'); + $this->assertSame(0.0, $dirty['credits']); + } + + public function testSyncSkipsPropertiesAlreadyInDirty(): void { $user = new TypedUser($this->pdo); $user->name = 'prefilled'; diff --git a/tests/classes/TypedUser.php b/tests/classes/TypedUser.php index e5a528c..bdb87d2 100644 --- a/tests/classes/TypedUser.php +++ b/tests/classes/TypedUser.php @@ -16,6 +16,12 @@ class TypedUser extends ActiveRecord public string $password; public ?string $created_dt = null; + /** + * Nullable float column so tests can prove strict comparison detection + * when a falsy value (0.0) is written over a NULL stored value. + */ + public ?float $credits = null; + /** * Intentionally left unset in many tests so sync helpers skip uninitialized props. * @var string