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
54 changes: 53 additions & 1 deletion src/ActiveRecord.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -583,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]);

Expand Down Expand Up @@ -676,13 +725,16 @@ 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;
return $obj;
}
$result = [];
while ($obj = $sth->fetch($obj)) {
$obj->syncDeclaredProperties();
$new_obj = clone $obj->dirty();
$new_obj->isHydrated = count($new_obj->getData()) > 0;
$result[] = $new_obj;
Expand Down
53 changes: 46 additions & 7 deletions tests/TypedPropertyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -48,24 +48,31 @@ 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')");

// 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
$user->name = 'eve_updated';
$user->save();

Expand All @@ -86,4 +93,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());
}
}