diff --git a/CHANGELOG.md b/CHANGELOG.md index d4c86d353..b673aecb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Bug #527: Fix PHPDoc tags `@see` (@mspirkov) - Enh #532, #533: Remove unnecessary files from Composer package (@mspirkov) +- Enh #538: It is now possible to instantiate AR model with constructor (@Tigrov, @olegbaturin) +- Bug #538: Remove `Closure` type from parameter `$modelClass` of `EventsTrait::query()` method (@Tigrov) ## 1.0.0 December 09, 2025 diff --git a/docs/create-model.md b/docs/create-model.md index c062f2a5c..87fe449d3 100644 --- a/docs/create-model.md +++ b/docs/create-model.md @@ -203,11 +203,6 @@ use Yiisoft\ActiveRecord\Trait\MagicPropertiesTrait; final class User extends ActiveRecord { use MagicPropertiesTrait; - - public function tableName(): string - { - return '{{%user}}'; - } } ``` @@ -233,37 +228,16 @@ use Yiisoft\ActiveRecord\ActiveRecord; **/ final class User extends ActiveRecord { + public ?int $id; + public function __construct( - public ?int $id = null, - public ?string $username = null, - public ?string $email = null, + public string $username, + public string $email, public string $status = 'active', ) {} } ``` -### Limitations - -When using the constructor, you should either specify default values or `null` for the arguments, or avoid using the static -`ActiveRecord::query()` method. It will not work correctly. Instead, create a new model instance and create a new query -object by calling the `createQuery()` method on the model instance. - -```php -// If the constructor arguments do not have default values -$user = new User(1, 'admin', 'admin@example.net', 'active'); -/** @var Yiisoft\ActiveRecord\ActiveQueryInterface $query */ -$query = $user->createQuery(); -``` - -Then you can use the active query object as usual, for example: - -```php -$users = $query->where(['status' => 'active'])->all(); -``` - -Also, if the constructor arguments do not have default values, you cannot use `RepositoryTrait`, because it uses static -`ActiveRecord::query()` method. - ## Relations To define relations, use the `ActiveRecordInterface::relationQuery()` method. This method should return an instance of diff --git a/rector.php b/rector.php index 21c42b7d6..3f8e69617 100644 --- a/rector.php +++ b/rector.php @@ -24,5 +24,6 @@ 'tests/Stubs/ActiveRecord/Category.php', 'tests/Stubs/ActiveRecord/Customer.php', 'tests/Stubs/ActiveRecord/Order.php', + 'tests/Stubs/ActiveRecord/OrderWithConstructor.php', ], ]); diff --git a/src/ActiveQuery.php b/src/ActiveQuery.php index b18b94507..6f875c0cb 100644 --- a/src/ActiveQuery.php +++ b/src/ActiveQuery.php @@ -6,6 +6,7 @@ use Closure; use InvalidArgumentException; +use ReflectionClass; use ReflectionException; use Throwable; use Yiisoft\ActiveRecord\Internal\ArArrayHelper; @@ -162,7 +163,7 @@ final public function __construct( ) { $this->model = $modelClass instanceof ActiveRecordInterface ? $modelClass - : new $modelClass(); + : (new ReflectionClass($modelClass))->newInstanceWithoutConstructor(); parent::__construct($this->model->db()); } diff --git a/src/Trait/EventsTrait.php b/src/Trait/EventsTrait.php index 3a9a99447..d46490289 100644 --- a/src/Trait/EventsTrait.php +++ b/src/Trait/EventsTrait.php @@ -4,7 +4,7 @@ namespace Yiisoft\ActiveRecord\Trait; -use Closure; +use ReflectionClass; use Yiisoft\ActiveRecord\ActiveQueryInterface; use Yiisoft\ActiveRecord\ActiveRecordInterface; use Yiisoft\ActiveRecord\Event\AfterCreateQuery; @@ -23,8 +23,6 @@ use Yiisoft\ActiveRecord\Event\BeforeUpsert; use Yiisoft\ActiveRecord\Event\EventDispatcherProvider; -use function is_string; - /** * Trait to implement event dispatching for ActiveRecord. * @@ -84,14 +82,11 @@ public function populateRecord(array|object $data): static return $this; } - public static function query(ActiveRecordInterface|Closure|string|null $modelClass = null): ActiveQueryInterface + public static function query(ActiveRecordInterface|string|null $modelClass = null): ActiveQueryInterface { - $model = match (true) { - $modelClass === null => new static(), - is_string($modelClass) => new $modelClass(), - $modelClass instanceof ActiveRecordInterface => $modelClass, - default => ($modelClass)(), - }; + $model = $modelClass instanceof ActiveRecordInterface + ? $modelClass + : (new ReflectionClass($modelClass ?? static::class))->newInstanceWithoutConstructor(); $eventDispatcher = EventDispatcherProvider::get($model::class); $eventDispatcher->dispatch($event = new BeforeCreateQuery($model)); diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 9aee4bb86..22cca4a86 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -4,7 +4,6 @@ namespace Yiisoft\ActiveRecord\Tests; -use ArgumentCountError; use DivisionByZeroError; use InvalidArgumentException; use LogicException; @@ -32,6 +31,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Order; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderItem; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderItemWithNullFK; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderWithConstructor; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderWithFactory; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Profile; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Promotion; @@ -1069,10 +1069,9 @@ public function testWithFactoryNonInitiated(): void $this->assertInstanceOf(Customer::class, $customer); - $this->expectException(ArgumentCountError::class); - $this->expectExceptionMessage('Too few arguments to function'); - $customer = $order->getCustomerWithFactory(); + + $this->assertInstanceOf(Customer::class, $customer); } public function testSerialization(): void @@ -1939,5 +1938,42 @@ public function testGetAllWithHasOneAndArrayValue(): void $this->assertNull($promotions[1]->relation('singleItem')); } + public function testWithConstructorQuery(): void + { + /** @var OrderWithConstructor[] $orders */ + $orders = OrderWithConstructor::query()->all(); + + $this->assertCount(3, $orders); + } + + public function testWithConstructorRelations(): void + { + $orderItems = OrderWithConstructor::query()->findByPk(1)->getOrderItems(); + $this->assertCount(2, $orderItems); + } + + public function testWithConstructorRepositoryTrait(): void + { + $this->assertCount(3, OrderWithConstructor::findAll()); + $this->assertInstanceOf(OrderWithConstructor::class, OrderWithConstructor::findByPk(1)); + } + + public function testWithConstructorNewInstance(): void + { + $this->reloadFixtureAfterTest(); + + $newOrder = new OrderWithConstructor(1); + + $this->assertTrue($newOrder->isNew()); + $newOrder->save(); + $this->assertFalse($newOrder->isNew()); + $this->assertSame(4, $newOrder->getId()); + $this->assertNotNull($newOrder->getCreatedAt()); + $this->assertNotNull($newOrder->getUpdatedAt()); + $this->assertNull($newOrder->getDeletedAt()); + $this->assertSame(1, $newOrder->delete()); + $this->assertNotNull($newOrder->getDeletedAt()); + } + abstract protected function createFactory(): Factory; } diff --git a/tests/EventsTraitTest.php b/tests/EventsTraitTest.php index 8d1dea73a..f3fbf309b 100644 --- a/tests/EventsTraitTest.php +++ b/tests/EventsTraitTest.php @@ -11,7 +11,6 @@ use Yiisoft\ActiveRecord\Event\BeforeUpdate; use Yiisoft\ActiveRecord\Event\BeforeUpsert; use Yiisoft\ActiveRecord\Event\EventDispatcherProvider; -use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Category; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CategoryEventsModel; use Yiisoft\Test\Support\EventDispatcher\SimpleEventDispatcher; @@ -110,13 +109,6 @@ static function (object $event) use ($customQuery): void { $this->assertSame($customQuery, $query); } - public function testQueryWithClosureModelClass(): void - { - $query = CategoryEventsModel::query(fn() => new Category()); - - $this->assertInstanceOf(Category::class, $query->getModel()); - } - public function testSaveWithEventPrevention(): void { EventDispatcherProvider::set( diff --git a/tests/Stubs/ActiveRecord/OrderItemWithConstructor.php b/tests/Stubs/ActiveRecord/OrderItemWithConstructor.php new file mode 100644 index 000000000..e0592db22 --- /dev/null +++ b/tests/Stubs/ActiveRecord/OrderItemWithConstructor.php @@ -0,0 +1,96 @@ +order_id; + } + + public function getItemId(): int + { + return $this->item_id; + } + + public function getQuantity(): int + { + return $this->quantity; + } + + public function getSubtotal(): float + { + return $this->subtotal; + } + + public function setOrderId(int $orderId): void + { + $this->set('order_id', $orderId); + } + + public function setItemId(int $itemId): void + { + $this->set('item_id', $itemId); + } + + public function setQuantity(int $quantity): void + { + $this->quantity = $quantity; + } + + public function setSubtotal(float $subtotal): void + { + $this->subtotal = $subtotal; + } + + public function relationQuery(string $name): ActiveQueryInterface + { + return match ($name) { + 'order' => $this->getOrderQuery(), + 'item' => $this->getItemQuery(), + default => parent::relationQuery($name), + }; + } + + public function getOrder(): ?OrderWithConstructor + { + return $this->relation('order'); + } + + public function getOrderQuery(): ActiveQuery + { + return $this->hasOne(OrderWithConstructor::class, ['id' => 'order_id']); + } + + public function getItem(): ?Item + { + return $this->relation('item'); + } + + public function getItemQuery(): ActiveQuery + { + return $this->hasOne(Item::class, ['id' => 'item_id']); + } +} diff --git a/tests/Stubs/ActiveRecord/OrderWithConstructor.php b/tests/Stubs/ActiveRecord/OrderWithConstructor.php new file mode 100644 index 000000000..4c01c462c --- /dev/null +++ b/tests/Stubs/ActiveRecord/OrderWithConstructor.php @@ -0,0 +1,127 @@ +id; + } + + public function getCustomerId(): int + { + return $this->customer_id; + } + + public function getCreatedAt(): int|DateTimeInterface + { + return $this->created_at; + } + + public function getTotal(): float + { + return $this->total; + } + + public function getUpdatedAt(): int|DateTimeInterface + { + return $this->updated_at; + } + + public function getDeletedAt(): int|DateTimeInterface|null + { + return $this->deleted_at ?? null; + } + + public function setId(?int $id): void + { + $this->set('id', $id); + } + + public function setCustomerId(int $customerId): void + { + $this->set('customer_id', $customerId); + } + + public function setCreatedAt(int|DateTimeInterface $createdAt): void + { + $this->created_at = $createdAt; + } + + public function setUpdatedAt(int|DateTimeInterface $updatedAt): void + { + $this->updated_at = $updatedAt; + } + + public function setTotal(float $total): void + { + $this->total = $total; + } + + public function relationQuery(string $name): ActiveQueryInterface + { + return match ($name) { + 'customer' => $this->getCustomerQuery(), + 'orderItems' => $this->getOrderItemsQuery(), + default => parent::relationQuery($name), + }; + } + + public function getCustomer(): ?Customer + { + return $this->relation('customer'); + } + + public function getCustomerQuery(): ActiveQuery + { + return $this->hasOne(Customer::class, ['id' => 'customer_id']); + } + + public function getOrderItems(): array + { + return $this->relation('orderItems'); + } + + public function getOrderItemsQuery(): ActiveQuery + { + return $this->hasMany(OrderItemWithConstructor::class, ['order_id' => 'id']); + } +}