diff --git a/docs/traits/factory.md b/docs/traits/factory.md index 7bc08ab52..54d6acdea 100644 --- a/docs/traits/factory.md +++ b/docs/traits/factory.md @@ -7,51 +7,25 @@ This is required for [Using Dependency Injection With Active Record](../using-di The following method is provided by the `FactoryTrait`: -- `withFactory()` clones the current instance of Active Record then sets the factory for the new instance and returns - the new instance. +- `instantiate()` creates a new instance of the model with initialized dependencies using [yiisoft/factory](https://github.com/yiisoft/factory). ## Usage ```php use Yiisoft\ActiveRecord\ActiveRecord; +use Yiisoft\ActiveRecord\ActiveRecordFactory; use Yiisoft\ActiveRecord\Trait\FactoryTrait; -use Yiisoft\Factory\Factory; final class User extends ActiveRecord { use FactoryTrait; - public function __construct(Factory $factory, private MyService $myService) - { - $this->factory = $factory; - } + public function __construct(private MyService $myService) {} } -$user = $factory->create(User::class); // returns a new User instance with an initialized `Factory` and `MyService` instances. +$user = User::instantiate(); // returns a new User instance with an initialized `MyService` instance. ``` -If the `$factory` property is initialized, then the defined relations will be created using this factory. - -## Limitations - -When using `FactoryTrait`, you should not use the static `ActiveRecord::query()` method. It will not work correctly. -Instead, create a new instance of the model using the factory and create a new query object by calling the -`createQuery()` method on the model instance. - -```php -$user = $factory->create(User::class); -/** @var Yiisoft\ActiveRecord\ActiveQueryInterface $query */ -$query = $user->createQuery(); -``` - -Then you can use the active query object as usual, for example: - -```php -$users = $query->where(['is_active' => true])->all(); -``` - -Also, you cannot use `RepositoryTrait` with `FactoryTrait`, because it uses static `ActiveRecord::query()` method. - ## See also - [Using Dependency Injection With Active Record](../using-di.md); diff --git a/docs/using-di.md b/docs/using-di.md index 4e2b06c52..0114238ce 100644 --- a/docs/using-di.md +++ b/docs/using-di.md @@ -1,49 +1,60 @@ # Using Dependency Injection With Active Record Using [dependency injection](https://github.com/yiisoft/di) in the Active Record model allows injecting dependencies -into the model and use them in the model methods. +into the model and using them in the model methods. To create an Active Record model with dependency injection, you need to use a [factory](https://github.com/yiisoft/factory) that will create an instance of the model and inject the dependencies into it. -## Define The Active Record Model +## Define the Factory for Active Record -Yii Active Record provides a [FactoryTrait](traits/factory.md) trait that allows to use the factory with the Active Record class. +To use dependency injection with Active Record, you need to define the factory in `ActiveRecordFactory` class using one +of the following ways: -```php -use Yiisoft\ActiveRecord\ActiveQueryInterface; -use Yiisoft\ActiveRecord\ActiveRecord; -use Yiisoft\ActiveRecord\Trait\FactoryTrait; +### Using the bootstrap configuration -#[\AllowDynamicProperties] -final class User extends ActiveRecord -{ - use FactoryTrait; - - public function __construct(private MyService $myService) - { +Add the following code to the configuration file, for example, in `config/common/bootstrap.php`: + +```php +use Psr\Container\ContainerInterface; +use Yiisoft\ActiveRecord\ActiveRecordFactory; +use Yiisoft\Factory\Factory; + +return [ + static function (ContainerInterface $container): void { + ActiveRecordFactory::set(new Factory($container)); + // or `ActiveRecordFactory::set($container->get(Factory::class))` if the factory is defined in the container. } -} +]; ``` -When you use dependency injection in the Active Record model, you need to create the Active Record instance using -the factory. +> [!NOTE] +> The factory can be represented as `Factory` or `StrictFactory` class instance. -```php -/** @var \Yiisoft\Factory\Factory $factory */ -$user = $factory->create(User::class); -``` +### Using DI container autowiring -To create `ActiveQuery` instance you also need to use the factory to create the Active Record model. +You can set the factory for Active Record using the DI container autowiring. ```php -$userQuery = new ActiveQuery($factory->create(User::class)->withFactory($factory)); +use Psr\Http\Message\ResponseInterface; +use Yiisoft\ActiveRecord\ActiveRecordFactory; +use Yiisoft\Factory\Factory; + +final class SomeController +{ + public function someAction(Factory $factory): ResponseInterface + { + ActiveRecordFactory::set($factory); + + // ... + } +} ``` -## Factory Parameter In The Constructor +## Define The Active Record Model -Optionally, you can define the factory parameter in the constructor of the Active Record class. +Yii Active Record provides a [FactoryTrait](traits/factory.md) trait that allows using the factory with the Active Record class. ```php use Yiisoft\ActiveRecord\ActiveQueryInterface; @@ -55,17 +66,15 @@ final class User extends ActiveRecord { use FactoryTrait; - public function __construct(Factory $factory, private MyService $myService) - { - $this->factory = $factory; - } + public function __construct(private MyService $myService) {} } ``` -This will allow creating the `ActiveQuery` instance without calling `ActiveRecord::withFactory()` method. +Now you can create the Active Record instance or `ActiveQuery` instance using the factory. ```php -$userQuery = new ActiveQuery($factory->create(User::class)); +$user = User::instantiate(); +$userQuery = User::query(); ``` Back to [Create Active Record Model](create-model.md) diff --git a/src/ActiveRecordFactory.php b/src/ActiveRecordFactory.php new file mode 100644 index 000000000..3c884a18b --- /dev/null +++ b/src/ActiveRecordFactory.php @@ -0,0 +1,75 @@ + $className The class name of the active record to be created. + */ + public static function create(string $className): ActiveRecordInterface + { + /** @var ActiveRecordInterface */ + return self::get($className)->create($className); + } + + /** + * Checks if a factory for the given class name exists. + * + * @param ?class-string $className The class name of the active record to be checked + * or `null` to check default factory. + */ + public static function has(?string $className = null): bool + { + return isset(self::$factories[$className ?? self::DEFAULT]); + } + + /** + * Sets a factory by name. + * + * @param Factory|StrictFactory $factory The factory to be set. + * @param ?class-string $className The class name of the active record to be set + * or `null` to set default factory. + */ + public static function set(Factory|StrictFactory $factory, ?string $className = null): void + { + self::$factories[$className ?? self::DEFAULT] = $factory; + } + + /** + * Returns the factory for the given class name or the default factory if none is found. + * + * @param class-string $className The class name of the active record to be checked. + */ + private static function get(string $className): Factory|StrictFactory + { + return self::$factories[$className] + ?? self::$factories[self::DEFAULT] + ?? throw new InvalidArgumentException("Factory for class '$className' not found"); + } +} diff --git a/src/Trait/FactoryTrait.php b/src/Trait/FactoryTrait.php index f14845ddd..d70221212 100644 --- a/src/Trait/FactoryTrait.php +++ b/src/Trait/FactoryTrait.php @@ -4,46 +4,21 @@ namespace Yiisoft\ActiveRecord\Trait; -use Yiisoft\ActiveRecord\ActiveQueryInterface; -use Yiisoft\ActiveRecord\ActiveRecordInterface; -use Yiisoft\Factory\Factory; - -use function is_string; -use function method_exists; +use Yiisoft\ActiveRecord\ActiveRecordFactory; /** - * Trait to add factory support to ActiveRecord. + * Trait to add factory support to an active record model by using {@see ActiveRecordFactory} class. * - * @see AbstractActiveRecord::createQuery() + * @see AbstractActiveRecord::instantiate() */ trait FactoryTrait { - private Factory $factory; - /** - * Set the factory to use for creating new instances. + * Creates a new instance of the active record class. */ - public function withFactory(Factory $factory): static - { - $new = clone $this; - $new->factory = $factory; - return $new; - } - - public function createQuery(ActiveRecordInterface|string|null $modelClass = null): ActiveQueryInterface + public static function instantiate(): static { - if (!isset($this->factory)) { - return parent::createQuery($modelClass); - } - - $model = is_string($modelClass) - ? $this->factory->create($modelClass) - : $modelClass ?? $this; - - if (method_exists($model, 'withFactory')) { - return parent::createQuery($model->withFactory($this->factory)); - } - - return parent::createQuery($model); + /** @var static */ + return ActiveRecordFactory::create(static::class); } } diff --git a/tests/ActiveRecordFactoryTest.php b/tests/ActiveRecordFactoryTest.php new file mode 100644 index 000000000..599044153 --- /dev/null +++ b/tests/ActiveRecordFactoryTest.php @@ -0,0 +1,140 @@ +assertFalse(ActiveRecordFactory::has()); + + ActiveRecordFactory::set(new Factory()); + + $this->assertTrue(ActiveRecordFactory::has()); + } + + public function testSetWithClassName(): void + { + $className = Order::class; + + $this->assertFalse(ActiveRecordFactory::has($className)); + + ActiveRecordFactory::set(new Factory(), $className); + + $this->assertTrue(ActiveRecordFactory::has($className)); + } + + public function testHas(): void + { + $this->assertFalse(ActiveRecordFactory::has()); + + ActiveRecordFactory::set(new Factory()); + + $this->assertTrue(ActiveRecordFactory::has()); + + $className = Order::class; + + $this->assertFalse(ActiveRecordFactory::has($className)); + + ActiveRecordFactory::set(new Factory(), $className); + + $this->assertTrue(ActiveRecordFactory::has($className)); + } + + + public function testClear(): void + { + $className = Order::class; + + ActiveRecordFactory::set(new Factory()); + ActiveRecordFactory::set(new Factory(), $className); + + ActiveRecordFactory::clear(); + + $this->assertFalse(ActiveRecordFactory::has()); + $this->assertFalse(ActiveRecordFactory::has($className)); + } + + + public function testCreate(): void + { + $className = OrderWithFactory::class; + + $factory = new Factory(null, [ + MyService::class => [ + '__construct()' => ['custom'], + ], + ]); + + ActiveRecordFactory::set($factory); + + $order = ActiveRecordFactory::create($className); + $this->assertInstanceOf($className, $order); + $this->assertSame('custom', $order->service->name); + } + + public function testCreateWithClassNameThrowsExceptionWhenNotFound(): void + { + $className = Order::class; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Factory for class '$className' not found"); + + ActiveRecordFactory::create(Order::class); + } + + public function testSetWithStrictFactory(): void + { + $factory = new StrictFactory([]); + ActiveRecordFactory::set($factory); + + $this->assertTrue(ActiveRecordFactory::has()); + } + + public function testCreateWithStrictFactory(): void + { + $className = OrderWithFactory::class; + + $factory = new StrictFactory([ + $className => [], + MyService::class => [ + '__construct()' => ['strict'], + ], + ]); + ActiveRecordFactory::set($factory); + + $order = ActiveRecordFactory::create($className); + $this->assertInstanceOf($className, $order); + $this->assertSame('strict', $order->service->name); + } + + public function testCreateWithStrictFactoryThrowsExceptionWhenNotFound(): void + { + $className = OrderWithFactory::class; + $factory = new StrictFactory([]); + ActiveRecordFactory::set($factory); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage("No definition or class found or resolvable for $className."); + + ActiveRecordFactory::create($className); + } +} diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index bc4e9aab6..d8905d7ad 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -9,8 +9,10 @@ use InvalidArgumentException; use LogicException; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Depends; use PHPUnit\Framework\Attributes\TestWith; use Yiisoft\ActiveRecord\ActiveQuery; +use Yiisoft\ActiveRecord\ActiveRecordFactory; use Yiisoft\ActiveRecord\Event\AfterDelete; use Yiisoft\ActiveRecord\Event\EventDispatcherProvider; use Yiisoft\ActiveRecord\Internal\ArArrayHelper; @@ -45,6 +47,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\UuidPromotion; use Yiisoft\ActiveRecord\Tests\Support\DbHelper; use Yiisoft\ActiveRecord\Tests\Support\ModelFactory; +use Yiisoft\ActiveRecord\Tests\Support\MyService; use Yiisoft\ActiveRecord\UnknownPropertyException; use Yiisoft\Db\Connection\ConnectionProvider; use Yiisoft\Db\Exception\Exception; @@ -1004,64 +1007,97 @@ public function testWithCustomConnection(): void ConnectionProvider::remove('custom'); } - public function testWithFactory(): void + public function testWithNotInitializedFactory(): void { - $factory = $this->createFactory(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Factory for class '" . OrderWithFactory::class . "' not found"); - $orderQuery = new ActiveQuery($factory->create(OrderWithFactory::class)->withFactory($factory)); - $order = $orderQuery->with('customerWithFactory')->findByPk(2); + OrderWithFactory::query()->with('customerWithFactory')->findByPk(2); + } + + #[Depends('testWithNotInitializedFactory')] + public function testWithFactoryInstantiate(): void + { + $this->initFactory(); + + $order = OrderWithFactory::instantiate(); $this->assertInstanceOf(OrderWithFactory::class, $order); - $this->assertTrue($order->isRelationPopulated('customerWithFactory')); - $this->assertInstanceOf(CustomerWithFactory::class, $order->getCustomerWithFactory()); + $this->assertInstanceOf(MyService::class, $order->service); } - public function testWithFactoryInstanceRelation(): void + #[Depends('testWithNotInitializedFactory')] + public function testWithFactoryQuery(): void { - $factory = $this->createFactory(); + $this->initFactory(); - $orderQuery = new ActiveQuery($factory->create(OrderWithFactory::class)->withFactory($factory)); - $order = $orderQuery->findByPk(2); + $order = OrderWithFactory::query()->with('customerWithFactory')->findByPk(2); $this->assertInstanceOf(OrderWithFactory::class, $order); - $this->assertInstanceOf(CustomerWithFactory::class, $order->getCustomerWithFactoryInstance()); + $this->assertInstanceOf(MyService::class, $order->service); + $this->assertTrue($order->isRelationPopulated('customerWithFactory')); + $this->assertInstanceOf(CustomerWithFactory::class, $order->getCustomerWithFactory()); } + #[Depends('testWithNotInitializedFactory')] public function testWithFactoryRelationWithoutFactory(): void { - $factory = $this->createFactory(); + $this->initFactory(); - $orderQuery = new ActiveQuery($factory->create(OrderWithFactory::class)->withFactory($factory)); - $order = $orderQuery->findByPk(2); + $order = OrderWithFactory::query()->findByPk(2); $this->assertInstanceOf(OrderWithFactory::class, $order); $this->assertInstanceOf(Customer::class, $order->getCustomer()); } + #[Depends('testWithNotInitializedFactory')] public function testWithFactoryLazyRelation(): void { - $factory = $this->createFactory(); + $this->initFactory(); - $orderQuery = new ActiveQuery($factory->create(OrderWithFactory::class)->withFactory($factory)); - $order = $orderQuery->findByPk(2); + $order = OrderWithFactory::query()->findByPk(2); $this->assertInstanceOf(OrderWithFactory::class, $order); $this->assertFalse($order->isRelationPopulated('customerWithFactory')); $this->assertInstanceOf(CustomerWithFactory::class, $order->getCustomerWithFactory()); } - public function testWithFactoryWithConstructor(): void + #[Depends('testWithNotInitializedFactory')] + public function testWithFactoryRelationWithConstructor(): void { - $factory = $this->createFactory(); + $this->initFactory(); - $customerQuery = new ActiveQuery($factory->create(CustomerWithFactory::class)); - $customer = $customerQuery->findByPk(2); + $customer = CustomerWithFactory::query()->findByPk(2); $this->assertInstanceOf(CustomerWithFactory::class, $customer); $this->assertFalse($customer->isRelationPopulated('ordersWithFactory')); $this->assertInstanceOf(OrderWithFactory::class, $customer->getOrdersWithFactory()[0]); } + #[Depends('testWithNotInitializedFactory')] + public function testWithConcreteFactory(): void + { + $this->initFactory(); + + $newFactory = $this->createFactory()->withDefinitions([ + MyService::class => [ + '__construct()' => ['name' => 'new factory'], + ], + ]); + + ActiveRecordFactory::set($newFactory, CustomerWithFactory::class); + + $order = OrderWithFactory::instantiate(); + + $this->assertInstanceOf(MyService::class, $order->service); + $this->assertSame('default', $order->service->name); + + $customer = CustomerWithFactory::instantiate(); + + $this->assertInstanceOf(MyService::class, $customer->service); + $this->assertSame('new factory', $customer->service->name); + } + public function testWithFactoryNonInitiated(): void { $orderQuery = OrderWithFactory::query(); @@ -1986,5 +2022,13 @@ public function testWithConstructorNewInstance(): void $this->assertNotNull($newOrder->getDeletedAt()); } + protected function initFactory(): void + { + $factory = $this->createFactory(); + + ActiveRecordFactory::clear(); + ActiveRecordFactory::set($factory); + } + abstract protected function createFactory(): Factory; } diff --git a/tests/Driver/Mssql/ActiveRecordFactoryTest.php b/tests/Driver/Mssql/ActiveRecordFactoryTest.php new file mode 100644 index 000000000..978120f0a --- /dev/null +++ b/tests/Driver/Mssql/ActiveRecordFactoryTest.php @@ -0,0 +1,9 @@ +factory = $factory; - } + public function __construct( + public readonly MyService $service, + ) {} public function relationQuery(string $name): ActiveQueryInterface { diff --git a/tests/Stubs/ActiveRecord/OrderWithFactory.php b/tests/Stubs/ActiveRecord/OrderWithFactory.php index 40224aa09..23b9f5add 100644 --- a/tests/Stubs/ActiveRecord/OrderWithFactory.php +++ b/tests/Stubs/ActiveRecord/OrderWithFactory.php @@ -5,20 +5,21 @@ namespace Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord; use Yiisoft\ActiveRecord\ActiveQueryInterface; +use Yiisoft\ActiveRecord\Tests\Support\MyService; use Yiisoft\ActiveRecord\Trait\FactoryTrait; final class OrderWithFactory extends Order { use FactoryTrait; + public function __construct( + public readonly MyService $service, + ) {} + public function relationQuery(string $name): ActiveQueryInterface { return match ($name) { 'customerWithFactory' => $this->hasOne(CustomerWithFactory::class, ['id' => 'customer_id']), - 'customerWithFactoryInstance' => $this->hasOne( - $this->factory->create(CustomerWithFactory::class), - ['id' => 'customer_id'], - ), default => parent::relationQuery($name), }; } @@ -27,9 +28,4 @@ public function getCustomerWithFactory(): ?CustomerWithFactory { return $this->relation('customerWithFactory'); } - - public function getCustomerWithFactoryInstance(): ?CustomerWithFactory - { - return $this->relation('customerWithFactoryInstance'); - } } diff --git a/tests/Support/ConnectionHelper.php b/tests/Support/ConnectionHelper.php index 0307de6a1..2ba0cda01 100644 --- a/tests/Support/ConnectionHelper.php +++ b/tests/Support/ConnectionHelper.php @@ -16,7 +16,7 @@ abstract class ConnectionHelper public function createFactory(ConnectionInterface $db): Factory { $container = new Container(ContainerConfig::create()->withDefinitions([ConnectionInterface::class => $db])); - return new Factory($container, [ConnectionInterface::class => $db]); + return new Factory($container); } protected function createSchemaCache(): SchemaCache diff --git a/tests/Support/MyService.php b/tests/Support/MyService.php new file mode 100644 index 000000000..cb9a0be8d --- /dev/null +++ b/tests/Support/MyService.php @@ -0,0 +1,10 @@ +