Skip to content
24 changes: 15 additions & 9 deletions src/AbstractActiveRecord.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Yiisoft\ActiveRecord;

use Closure;
use DateTimeInterface;
use InvalidArgumentException;
use LogicException;
use ReflectionClass;
Expand Down Expand Up @@ -81,7 +82,7 @@

public function equals(ActiveRecordInterface $record): bool
{
if ($this->isNew() || $record->isNew()) {

Check warning on line 85 in src/AbstractActiveRecord.php

View workflow job for this annotation

GitHub Actions / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "LogicalOr": @@ @@ public function equals(ActiveRecordInterface $record): bool { - if ($this->isNew() || $record->isNew()) { + if ($this->isNew() && $record->isNew()) { return false; }
return false;
}

Expand Down Expand Up @@ -116,21 +117,26 @@

public function newValues(?array $propertyNames = null): array
{
$values = $this->propertyValues($propertyNames);

if ($this->oldValues === null) {
return $values;
$currentValues = $this->propertyValues($propertyNames);
if (($oldValues = $this->oldValues()) === []) {
return $currentValues;

Check warning on line 122 in src/AbstractActiveRecord.php

View workflow job for this annotation

GitHub Actions / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "ReturnRemoval": @@ @@ { $currentValues = $this->propertyValues($propertyNames); if (($oldValues = $this->oldValues()) === []) { - return $currentValues; + } $newValues = array_diff_key($currentValues, $oldValues);
}

$result = array_diff_key($values, $this->oldValues);
$newValues = array_diff_key($currentValues, $oldValues);

foreach (array_diff_key($values, $result) as $name => $value) {
if ($value !== $this->oldValues[$name]) {
$result[$name] = $value;
foreach (array_diff_key($currentValues, $newValues) as $name => $newValue) {
if ($newValue instanceof DateTimeInterface) {
if ($oldValues[$name] === null
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

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

When the new value is a DateTimeInterface, the code checks if the old value is null but doesn't verify that the old value also implements DateTimeInterface before calling format() on it at line 130. If the old value is a non-null value that doesn't implement DateTimeInterface (e.g., a string), this will cause a fatal error. Consider adding an additional check: || !($oldValues[$name] instanceof DateTimeInterface) to the condition at line 129.

Suggested change
if ($oldValues[$name] === null
if ($oldValues[$name] === null
|| !($oldValues[$name] instanceof DateTimeInterface)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Also see what Copilot suggests

|| $this->column($name)->dbTypecast($newValue) !=
$this->column($name)->dbTypecast($oldValues[$name])) {
$newValues[$name] = $newValue;
}
} elseif ($newValue !== $oldValues[$name]) {
$newValues[$name] = $newValue;
}
}

return $result;
return $newValues;
}

public function oldValues(): array
Expand All @@ -148,7 +154,7 @@
return match (count($keys)) {
1 => $this->oldValues[$keys[0]] ?? null,
0 => throw new LogicException(
static::class . ' does not have a primary key. You should either define a primary key for '

Check warning on line 157 in src/AbstractActiveRecord.php

View workflow job for this annotation

GitHub Actions / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "ConcatOperandRemoval": @@ @@ 1 => $this->oldValues[$keys[0]] ?? null, 0 => throw new LogicException( static::class . ' does not have a primary key. You should either define a primary key for ' - . $this->tableName() . ' table or override the primaryKey() method.', + . $this->tableName(), ), default => throw new LogicException( static::class . ' has multiple primary keys. Use primaryKeyOldValues() method instead.',

Check warning on line 157 in src/AbstractActiveRecord.php

View workflow job for this annotation

GitHub Actions / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "Concat": @@ @@ return match (count($keys)) { 1 => $this->oldValues[$keys[0]] ?? null, 0 => throw new LogicException( - static::class . ' does not have a primary key. You should either define a primary key for ' - . $this->tableName() . ' table or override the primaryKey() method.', + static::class . ' does not have a primary key. You should either define a primary key for ' . ' table or override the primaryKey() method.' . $this->tableName(), ), default => throw new LogicException( static::class . ' has multiple primary keys. Use primaryKeyOldValues() method instead.',

Check warning on line 157 in src/AbstractActiveRecord.php

View workflow job for this annotation

GitHub Actions / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "ConcatOperandRemoval": @@ @@ return match (count($keys)) { 1 => $this->oldValues[$keys[0]] ?? null, 0 => throw new LogicException( - static::class . ' does not have a primary key. You should either define a primary key for ' - . $this->tableName() . ' table or override the primaryKey() method.', + static::class . ' does not have a primary key. You should either define a primary key for ' . ' table or override the primaryKey() method.', ), default => throw new LogicException( static::class . ' has multiple primary keys. Use primaryKeyOldValues() method instead.',
. $this->tableName() . ' table or override the primaryKey() method.',
),
default => throw new LogicException(
Expand All @@ -163,7 +169,7 @@

if (empty($keys)) {
throw new LogicException(
static::class . ' does not have a primary key. You should either define a primary key for '

Check warning on line 172 in src/AbstractActiveRecord.php

View workflow job for this annotation

GitHub Actions / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "ConcatOperandRemoval": @@ @@ if (empty($keys)) { throw new LogicException( static::class . ' does not have a primary key. You should either define a primary key for ' - . $this->tableName() . ' table or override the primaryKey() method.', + . $this->tableName(), ); }

Check warning on line 172 in src/AbstractActiveRecord.php

View workflow job for this annotation

GitHub Actions / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "Concat": @@ @@ if (empty($keys)) { throw new LogicException( - static::class . ' does not have a primary key. You should either define a primary key for ' - . $this->tableName() . ' table or override the primaryKey() method.', + static::class . ' does not have a primary key. You should either define a primary key for ' . ' table or override the primaryKey() method.' . $this->tableName(), ); }

Check warning on line 172 in src/AbstractActiveRecord.php

View workflow job for this annotation

GitHub Actions / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "ConcatOperandRemoval": @@ @@ if (empty($keys)) { throw new LogicException( - static::class . ' does not have a primary key. You should either define a primary key for ' - . $this->tableName() . ' table or override the primaryKey() method.', + static::class . ' does not have a primary key. You should either define a primary key for ' . ' table or override the primaryKey() method.', ); }
. $this->tableName() . ' table or override the primaryKey() method.',
);
}
Expand All @@ -188,7 +194,7 @@
return match (count($keys)) {
1 => $this->get($keys[0]),
0 => throw new LogicException(
static::class . ' does not have a primary key. You should either define a primary key for '

Check warning on line 197 in src/AbstractActiveRecord.php

View workflow job for this annotation

GitHub Actions / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "Concat": @@ @@ return match (count($keys)) { 1 => $this->get($keys[0]), 0 => throw new LogicException( - static::class . ' does not have a primary key. You should either define a primary key for ' - . $this->tableName() . ' table or override the primaryKey() method.', + static::class . ' does not have a primary key. You should either define a primary key for ' . ' table or override the primaryKey() method.' . $this->tableName(), ), default => throw new LogicException( static::class . ' has multiple primary keys. Use primaryKeyValues() method instead.',

Check warning on line 197 in src/AbstractActiveRecord.php

View workflow job for this annotation

GitHub Actions / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "ConcatOperandRemoval": @@ @@ return match (count($keys)) { 1 => $this->get($keys[0]), 0 => throw new LogicException( - static::class . ' does not have a primary key. You should either define a primary key for ' - . $this->tableName() . ' table or override the primaryKey() method.', + static::class . ' does not have a primary key. You should either define a primary key for ' . ' table or override the primaryKey() method.', ), default => throw new LogicException( static::class . ' has multiple primary keys. Use primaryKeyValues() method instead.',
. $this->tableName() . ' table or override the primaryKey() method.',
),
default => throw new LogicException(
Expand Down
3 changes: 2 additions & 1 deletion tests/ActiveQueryFindTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Yiisoft\ActiveRecord\Tests;

use DateTimeImmutable;
use Yiisoft\ActiveRecord\ActiveQueryInterface;
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Customer;
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerQuery;
Expand All @@ -12,7 +13,6 @@
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Type;
use InvalidArgumentException;
use Yiisoft\Db\Exception\InvalidConfigException;
use Yiisoft\Db\QueryBuilder\Condition\In;

use function ksort;

Expand Down Expand Up @@ -177,6 +177,7 @@ public function testFindAsArray(): void
'address' => 'address2',
'status' => 1,
'bool_status' => true,
'registered_at' => new DateTimeImmutable('2022-02-02 02:02:02.222222 UTC'),
'profile_id' => null,
], $customer);

Expand Down
45 changes: 42 additions & 3 deletions tests/ActiveQueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
namespace Yiisoft\ActiveRecord\Tests;

use Closure;
use DateInterval;
use DateTimeImmutable;
use DateTimeInterface;
use InvalidArgumentException;
use LogicException;
use PHPUnit\Framework\Attributes\DataProvider;
use Yiisoft\ActiveRecord\ActiveQuery;
use Yiisoft\ActiveRecord\ActiveQueryInterface;
use Yiisoft\ActiveRecord\Internal\ArArrayHelper;
use Yiisoft\ActiveRecord\JoinWith;
use Yiisoft\ActiveRecord\OptimisticLockException;
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\BitValues;
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Category;
Expand All @@ -31,7 +33,6 @@
use Yiisoft\ActiveRecord\Tests\Support\DbHelper;
use Yiisoft\ActiveRecord\UnknownPropertyException;
use Yiisoft\Db\Command\AbstractCommand;
use Yiisoft\Db\Exception\Exception;
use Yiisoft\Db\Exception\InvalidCallException;
use Yiisoft\Db\Exception\InvalidConfigException;
use Yiisoft\Db\Expression\Expression;
Expand Down Expand Up @@ -2094,6 +2095,7 @@ public function testPropertyValues(): void
'address' => 'address1',
'status' => 1,
'bool_status' => true,
'registered_at' => new DateTimeImmutable('2011-01-01 01:01:01.111111 UTC'),
'profile_id' => 1,
];

Expand All @@ -2117,7 +2119,7 @@ public function testPropertyValuesExcept(): void
{
$customer = Customer::query();

$values = $customer->findByPk(1)->propertyValues(null, ['status', 'bool_status', 'profile_id']);
$values = $customer->findByPk(1)->propertyValues(null, ['status', 'bool_status', 'registered_at', 'profile_id']);

$this->assertEquals(
['id' => 1, 'email' => 'user1@example.com', 'name' => 'user1', 'address' => 'address1'],
Expand Down Expand Up @@ -2148,6 +2150,7 @@ public function testGetOldValues(): void
'address' => 'address1',
'status' => 1,
'bool_status' => true,
'registered_at' => new DateTimeImmutable('2011-01-01 01:01:01.111111 UTC'),
'profile_id' => 1,
];

Expand Down Expand Up @@ -2392,6 +2395,41 @@ public function testUnlinkAllAndConditionDelete(): void
$this->assertCount(0, $customer->getExpensiveOrders());
}

public function testNewValuesStaysEmptyOnSameMomentInTime()
{
$this->reloadFixtureAfterTest();

$customerQuery = Customer::query();
$customer = $customerQuery->findByPk(2);
$this->assertEmpty($customer->newValues());

/** @var DateTimeInterface $customerRegisteredAt */
$customerRegisteredAt = $customer->get('registered_at');
$equalRegisteredAt = $customerRegisteredAt->add(new DateInterval('PT0S'));
$this->assertEquals($equalRegisteredAt->format(($format = 'Y-m-d\TH:i:s.uP')), $customerRegisteredAt->format($format));

$customer->set('registered_at', $equalRegisteredAt);
$this->assertEmpty($customer->newValues());
}

public function testNewValuesStaysNotEmptyOnDifferentMomentInTime()
{
$this->reloadFixtureAfterTest();

$customerQuery = Customer::query();
$customer = $customerQuery->findByPk(2);
$this->assertEmpty($customer->newValues());

/** @var DateTimeInterface $customerRegisteredAt */
$customerRegisteredAt = $customer->get('registered_at');
$differentRegisteredAt = $customerRegisteredAt->add(new DateInterval('PT1S'));
$this->assertNotEquals($differentRegisteredAt->format(($format = 'Y-m-d\TH:i:s.uP')), $customerRegisteredAt->format($format));

$customer->set('registered_at', $differentRegisteredAt);
$this->assertSame($customer->oldValues()['registered_at'], $customerRegisteredAt);
$this->assertSame($customer->newValues()['registered_at'], $differentRegisteredAt);
}

public function testUpdate(): void
{
$this->reloadFixtureAfterTest();
Expand All @@ -2400,6 +2438,7 @@ public function testUpdate(): void
$customer = $customerQuery->findByPk(2);
$this->assertInstanceOf(Customer::class, $customer);
$this->assertEquals('user2', $customer->get('name'));
$this->assertEquals(new DateTimeImmutable('2022-02-02 02:02:02.222222 UTC'), $customer->get('registered_at'));
$this->assertFalse($customer->isNew());
$this->assertEmpty($customer->newValues());

Expand Down
54 changes: 54 additions & 0 deletions tests/ActiveRecordTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Yiisoft\ActiveRecord\Tests;

use ArgumentCountError;
use DateTimeImmutable;
use DivisionByZeroError;
use InvalidArgumentException;
use LogicException;
Expand Down Expand Up @@ -896,6 +897,56 @@ public function testPrimaryKeyOldValuesWithoutPrimaryKey(): void
$orderItem->primaryKeyOldValues();
}

public static function providerForNewValues(): array
{
/*
* Defines the same moment in time but in different timezones.
*/
$registeredAt_1 = new DateTimeImmutable('2011-01-01T01:01:01.111111+00:00');
$registeredAt_1_1 = new DateTimeImmutable('2022-02-12T12:12:12.121212+00:00');

return [
'new record 1.0' => [
'id' => null,
'propertyValues' => ['name' => 'user1'],
'expect' => ['name' => 'user1'],
],
'new record 1.1' => [
'id' => null,
'propertyValues' => ['name' => 'user1', 'registered_at' => $registeredAt_1],
'expect' => ['name' => 'user1', 'registered_at' => $registeredAt_1],
],
/*
* This record already has the "$registeredAt_1" date set.
*/
'old record 1.0' => [
'id' => 1,
'propertyValues' => ['name' => 'user1'],
'expect' => [],
],
'old record 1.1' => [
'id' => 1,
'propertyValues' => ['name' => 'user1.1', 'registered_at' => $registeredAt_1],
// We set the same value, so we do not expect any change here.
'expect' => ['name' => 'user1.1'],
],
'old record 1.2' => [
'id' => 1,
'propertyValues' => ['name' => 'user1.2', 'registered_at' => $registeredAt_1_1],
// We set the same moment in time but with different timezone. It will be detected as a new value.
'expect' => ['name' => 'user1.2', 'registered_at' => $registeredAt_1_1],
],
];
}

#[DataProvider('providerForNewValues')]
public function testNewValues(?int $id, array $propertyValues, array $expect): void
{
$customer = $id === null ? new Customer() : Customer::findByPk($id);
array_walk($propertyValues, static fn($value, string $key) => $customer->set($key, $value));
$this->assertSame($expect, $customer->newValues(array_keys($propertyValues)));
}

public function testGetDirtyValuesOnNewRecord(): void
{
$this->reloadFixtureAfterTest();
Expand All @@ -908,6 +959,7 @@ public function testGetDirtyValuesOnNewRecord(): void
'address' => null,
'status' => 0,
'bool_status' => false,
'registered_at' => null,
'profile_id' => null,
],
$customer->newValues(),
Expand All @@ -916,6 +968,7 @@ public function testGetDirtyValuesOnNewRecord(): void
$customer->set('name', 'Adam');
$customer->set('email', 'adam@example.com');
$customer->set('address', null);
$customer->set('registered_at', ($registeredAt = new DateTimeImmutable('2026-01-01 12:12:12.123456 Europe/Berlin')));

$this->assertSame([], $customer->newValues([]));

Expand All @@ -926,6 +979,7 @@ public function testGetDirtyValuesOnNewRecord(): void
'address' => null,
'status' => 0,
'bool_status' => false,
'registered_at' => $registeredAt,
'profile_id' => null,
],
$customer->newValues(),
Expand Down
3 changes: 3 additions & 0 deletions tests/ArrayableTraitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public function testFields(): void
'address' => 'address',
'status' => 'status',
'bool_status' => 'bool_status',
'registered_at' => 'registered_at',
'profile_id' => 'profile_id',
'item' => 'item',
'items' => 'items',
Expand All @@ -46,6 +47,7 @@ public function testToArray(): void
'address' => 'address1',
'status' => 1,
'bool_status' => true,
'registered_at' => '2011-01-01T01:01:01.111111+00:00',
'profile_id' => 1,
],
$customer->toArray(),
Expand All @@ -65,6 +67,7 @@ public function testToArrayWithClosure(): void
'address' => 'address1',
'status' => 'active',
'bool_status' => true,
'registered_at' => '2011-01-01T01:01:01.111111+00:00',
'profile_id' => 1,
],
$customer->toArray(),
Expand Down
19 changes: 19 additions & 0 deletions tests/Stubs/ActiveRecord/Customer.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord;

use DateTimeImmutable;
use Yiisoft\ActiveRecord\ActiveQuery;
use Yiisoft\ActiveRecord\ActiveQueryInterface;
use Yiisoft\ActiveRecord\ActiveRecordInterface;
Expand All @@ -29,13 +30,21 @@ class Customer extends ArrayableActiveRecord
protected ?string $address = null;
protected ?int $status = 0;
protected bool|int|null $bool_status = false;
protected ?DateTimeImmutable $registered_at = null;
protected ?int $profile_id = null;

public function tableName(): string
{
return 'customer';
}

public function fields(): array
{
return array_merge(parent::fields(), [
'registered_at' => static fn(self $customer) => $customer->registered_at?->format('Y-m-d\TH:i:s.uP'),
]);
}

public function relationQuery(string $name): ActiveQueryInterface
{
return match ($name) {
Expand Down Expand Up @@ -88,6 +97,11 @@ public function getBoolStatus(): ?bool
return $this->bool_status;
}

public function getRegisteredAt(): ?DateTimeImmutable
{
return $this->registered_at;
}

public function getProfileId(): ?int
{
return $this->profile_id;
Expand Down Expand Up @@ -127,6 +141,11 @@ public function setBoolStatus(?bool $bool_status): void
$this->bool_status = $bool_status;
}

public function setRegisteredAt(?DateTimeImmutable $registered_at): void
{
$this->registered_at = $registered_at;
}

public function setProfileId(?int $profile_id): void
{
$this->set('profile_id', $profile_id);
Expand Down
14 changes: 9 additions & 5 deletions tests/Stubs/ActiveRecord/CustomerClosureField.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord;

use DateTimeImmutable;
use Yiisoft\ActiveRecord\Tests\Stubs\ArrayableActiveRecord;

/**
Expand All @@ -17,6 +18,7 @@ final class CustomerClosureField extends ArrayableActiveRecord
protected ?string $address = null;
protected ?int $status = 0;
protected bool|string|null $bool_status = false;
protected ?DateTimeImmutable $registered_at = null;
protected ?int $profile_id = null;

public function tableName(): string
Expand All @@ -26,10 +28,12 @@ public function tableName(): string

public function fields(): array
{
$fields = parent::fields();

$fields['status'] = static fn(self $customer) => $customer->status === 1 ? 'active' : 'inactive';

return $fields;
return array_merge(
parent::fields(),
[
'status' => static fn(self $customer) => $customer->status === 1 ? 'active' : 'inactive',
'registered_at' => static fn(self $customer) => $customer->registered_at?->format('Y-m-d\TH:i:s.uP'),
],
);
}
}
7 changes: 4 additions & 3 deletions tests/data/mssql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ CREATE TABLE [dbo].[customer] (
[address] [text],
[status] [int] DEFAULT 0,
[bool_status] [bit] DEFAULT 0,
[registered_at] DATETIME2(6) DEFAULT NULL,
[profile_id] [int],
CONSTRAINT [PK_customer] PRIMARY KEY CLUSTERED (
[id] ASC
Expand Down Expand Up @@ -243,9 +244,9 @@ INSERT INTO [dbo].[animal] (type) VALUES ('Yiisoft\ActiveRecord\Tests\Stubs\Acti
INSERT INTO [dbo].[profile] ([description]) VALUES ('profile customer 1');
INSERT INTO [dbo].[profile] ([description]) VALUES ('profile customer 3');

INSERT INTO [dbo].[customer] ([email], [name], [address], [status], [bool_status], [profile_id]) VALUES ('user1@example.com', 'user1', 'address1', 1, 1, 1);
INSERT INTO [dbo].[customer] ([email], [name], [address], [status], [bool_status]) VALUES ('user2@example.com', 'user2', 'address2', 1, 1);
INSERT INTO [dbo].[customer] ([email], [name], [address], [status], [bool_status], [profile_id]) VALUES ('user3@example.com', 'user3', 'address3', 2, 0, 2);
INSERT INTO [dbo].[customer] ([email], [name], [address], [status], [bool_status], [registered_at], [profile_id]) VALUES ('user1@example.com', 'user1', 'address1', 1, 1, '2011-01-01 01:01:01.111111+00:00', 1);
INSERT INTO [dbo].[customer] ([email], [name], [address], [status], [bool_status], [registered_at]) VALUES ('user2@example.com', 'user2', 'address2', 1, 1, '2022-02-02 02:02:02.222222+00:00');
INSERT INTO [dbo].[customer] ([email], [name], [address], [status], [bool_status], [registered_at], [profile_id]) VALUES ('user3@example.com', 'user3', 'address3', 2, 0, '2023-03-03 03:03:03.333333+00:00', 2);

INSERT INTO [dbo].[category] ([name]) VALUES ('Books');
INSERT INTO [dbo].[category] ([name]) VALUES ('Movies');
Expand Down
Loading
Loading