From 69a3d040de8f5dcbf8090f39e43d395ba5b3f6ee Mon Sep 17 00:00:00 2001 From: enlivenapp Date: Thu, 23 Apr 2026 20:24:39 -0400 Subject: [PATCH 1/2] Fix isHydrated() returning false with typed public properties When a subclass declares typed public properties (e.g. public int $id), PDO::FETCH_INTO sets them directly, bypassing __set(). This leaves the internal $data array empty, causing isHydrated() to always return false after find() and findAll(). Added syncDeclaredProperties() which detects initialized declared properties and copies them into $data after fetch operations. enlivenapp --- src/ActiveRecord.php | 40 +++++++++++++++++++++++++++++++++++++ tests/TypedPropertyTest.php | 39 +++++++++++++++++++++++++++++++----- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/ActiveRecord.php b/src/ActiveRecord.php index d095a51..04ac9cb 100644 --- a/src/ActiveRecord.php +++ b/src/ActiveRecord.php @@ -445,6 +445,43 @@ protected function syncDirtyFromProperties(bool $onlyChanged = false): void } } + /** + * Syncs declared public properties into the internal $data array. + * + * PDO::FETCH_INTO sets typed public properties directly, bypassing __set(), + * so $data stays empty. This method detects initialized declared properties + * and copies them into $data so that isHydrated() and getData() work + * correctly with subclasses that use typed properties. + */ + protected function syncDeclaredProperties(): void + { + $ref = new \ReflectionClass($this); + $parentRef = new \ReflectionClass(self::class); + + // If the primary key isn't initialized, no row was fetched — nothing to sync + $pkProp = $ref->hasProperty($this->primaryKey) ? $ref->getProperty($this->primaryKey) : null; + if ($pkProp !== null && !$pkProp->isInitialized($this)) { + return; + } + + foreach ($ref->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) { + if ($prop->isStatic()) { + continue; + } + + $name = $prop->getName(); + + // Skip properties that exist on ActiveRecord (even if redeclared by subclass) + if ($parentRef->hasProperty($name)) { + continue; + } + + if ($prop->isInitialized($this) && !array_key_exists($name, $this->data)) { + $this->data[$name] = $this->{$name}; + } + } + } + /** * get the database connection. * @return DatabaseInterface @@ -676,6 +713,8 @@ public function query(string $sql, array $param = [], ?ActiveRecord $obj = null, if ($single === true) { // fetch results into the object $sth->fetch($obj); + // Sync typed public properties that PDO::FETCH_INTO set directly + $obj->syncDeclaredProperties(); // clear any dirty data $obj->dirty(); $obj->isHydrated = count($obj->getData()) > 0; @@ -683,6 +722,7 @@ public function query(string $sql, array $param = [], ?ActiveRecord $obj = null, } $result = []; while ($obj = $sth->fetch($obj)) { + $obj->syncDeclaredProperties(); $new_obj = clone $obj->dirty(); $new_obj->isHydrated = count($new_obj->getData()) > 0; $result[] = $new_obj; diff --git a/tests/TypedPropertyTest.php b/tests/TypedPropertyTest.php index aa12839..81cc052 100644 --- a/tests/TypedPropertyTest.php +++ b/tests/TypedPropertyTest.php @@ -6,8 +6,8 @@ use PDO; /** - * Tests that insert() and update() correctly handle typed public properties - * when values are assigned directly (bypassing __set / dirty tracking). + * Tests that ActiveRecord works correctly with subclasses that declare + * typed public properties (e.g. public int $id, public string $name). */ class TypedPropertyTest extends \PHPUnit\Framework\TestCase { @@ -59,10 +59,7 @@ public function testUpdateWithTypedProperties(): void { $this->pdo->exec("INSERT INTO user (name, password) VALUES ('eve', 'hash5')"); - // Use the untyped User to find (avoids isHydrated dependency) - // Then update via typed property assignment $user = new TypedUser($this->pdo); - $user->dirty(['name' => 'eve']); // simulate populating data $user->eq('name', 'eve')->find(); // Direct property assignment should be detected by syncDirtyFromProperties @@ -86,4 +83,36 @@ public function testUpdateDoesNotTouchUnchangedFields(): void $this->assertSame('frank_updated', $row['name']); $this->assertSame('hash6', $row['password'], 'unchanged field should not be modified'); } + + public function testFindIsHydrated(): void + { + $this->pdo->exec("INSERT INTO user (name, password) VALUES ('alice', 'hash1')"); + + $user = new TypedUser($this->pdo); + $user->eq('id', 1)->find(); + + $this->assertTrue($user->isHydrated(), 'isHydrated() should return true after find()'); + $this->assertSame(1, $user->id); + $this->assertSame('alice', $user->name); + } + + public function testFindNoResultIsNotHydrated(): void + { + $user = new TypedUser($this->pdo); + $user->eq('id', 999)->find(); + + $this->assertFalse($user->isHydrated(), 'isHydrated() should return false when no row is found'); + } + + public function testFindAllIsHydrated(): void + { + $this->pdo->exec("INSERT INTO user (name, password) VALUES ('alice', 'hash1')"); + $this->pdo->exec("INSERT INTO user (name, password) VALUES ('bob', 'hash2')"); + + $users = (new TypedUser($this->pdo))->findAll(); + + $this->assertCount(2, $users); + $this->assertTrue($users[0]->isHydrated(), 'findAll() results should be hydrated'); + $this->assertTrue($users[1]->isHydrated()); + } } From ea2b0eb305d9ccf98744a717a055ba018872e90f Mon Sep 17 00:00:00 2001 From: enlivenapp Date: Thu, 23 Apr 2026 20:25:08 -0400 Subject: [PATCH 2/2] Fix insert() TypeError when primary key is typed as int lastInsertId() returns a string, which throws a TypeError when assigned to a typed public int property under strict_types. Cast the value to int when the primary key property has an int type declaration. enlivenapp --- src/ActiveRecord.php | 14 +++++++++++++- tests/TypedPropertyTest.php | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/ActiveRecord.php b/src/ActiveRecord.php index 04ac9cb..e6df82b 100644 --- a/src/ActiveRecord.php +++ b/src/ActiveRecord.php @@ -620,7 +620,19 @@ public function insert(): ActiveRecord $this->execute($this->buildSql(['insert', 'values']), $this->params); - $this->{$this->primaryKey} = $intentionallyAssignedPrimaryKey ?: $this->databaseConnection->lastInsertId(); + $lastInsertId = $intentionallyAssignedPrimaryKey ?: $this->databaseConnection->lastInsertId(); + + // Cast to int if the primary key property is typed as int, + // since lastInsertId() returns a string which fails under strict_types. + $classRef = new \ReflectionClass($this); + if ($classRef->hasProperty($this->primaryKey)) { + $type = $classRef->getProperty($this->primaryKey)->getType(); + if ($type instanceof \ReflectionNamedType && $type->getName() === 'int') { + $lastInsertId = (int) $lastInsertId; + } + } + + $this->{$this->primaryKey} = $lastInsertId; $this->processEvent(['afterInsert', 'afterSave'], [$this]); diff --git a/tests/TypedPropertyTest.php b/tests/TypedPropertyTest.php index 81cc052..d2b3c73 100644 --- a/tests/TypedPropertyTest.php +++ b/tests/TypedPropertyTest.php @@ -48,13 +48,24 @@ public function testInsertWithTypedProperties(): void $user->password = 'hash3'; $user->insert(); - // Verify persisted via raw query (avoids isHydrated/lastInsertId dependencies) + // Verify persisted via raw query $row = $this->pdo->query("SELECT * FROM user WHERE name = 'charlie'")->fetch(PDO::FETCH_ASSOC); $this->assertNotFalse($row, 'insert should persist when properties are set directly'); $this->assertSame('charlie', $row['name']); $this->assertSame('hash3', $row['password']); } + public function testInsertSetsTypedIntId(): void + { + $user = new TypedUser($this->pdo); + $user->name = 'charlie'; + $user->password = 'hash3'; + $user->insert(); + + $this->assertIsInt($user->id, 'id should be int after insert, not string'); + $this->assertGreaterThan(0, $user->id); + } + public function testUpdateWithTypedProperties(): void { $this->pdo->exec("INSERT INTO user (name, password) VALUES ('eve', 'hash5')"); @@ -62,7 +73,6 @@ public function testUpdateWithTypedProperties(): void $user = new TypedUser($this->pdo); $user->eq('name', 'eve')->find(); - // Direct property assignment should be detected by syncDirtyFromProperties $user->name = 'eve_updated'; $user->save();