From e9708de5de115288c0d248f664875aa469ab021e Mon Sep 17 00:00:00 2001 From: Sergei Tigrov <rrr-r@ya.ru> Date: Thu, 4 Jul 2024 09:42:51 +0700 Subject: [PATCH] Using Dependency Injection With Active Record (#370) --- README.md | 3 +- composer-require-checker.json | 3 +- composer.json | 2 + docs/create-model.md | 4 + docs/using-di.md | 96 ++++++++++++++++ src/Trait/FactoryTrait.php | 59 ++++++++++ tests/ActiveRecordTest.php | 104 ++++++++++++++++++ tests/Driver/Mssql/ActiveRecordTest.php | 6 + tests/Driver/Mysql/ActiveRecordTest.php | 6 + tests/Driver/Oracle/ActiveRecordTest.php | 6 + tests/Driver/Pgsql/ActiveRecordTest.php | 6 + tests/Driver/Sqlite/ActiveRecordTest.php | 6 + .../ActiveRecord/CustomerWithFactory.php | 34 ++++++ tests/Stubs/ActiveRecord/OrderWithFactory.php | 44 ++++++++ tests/Support/ConnectionHelper.php | 10 +- 15 files changed, 378 insertions(+), 11 deletions(-) create mode 100644 docs/using-di.md create mode 100644 src/Trait/FactoryTrait.php create mode 100644 tests/Stubs/ActiveRecord/CustomerWithFactory.php create mode 100644 tests/Stubs/ActiveRecord/OrderWithFactory.php diff --git a/README.md b/README.md index 208ac78de..ffcea217c 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,8 @@ return [ ]; ``` -_For more information about how to configure middleware, follow [Middleware Documentation](https://github.com/yiisoft/docs/blob/master/guide/en/structure/middleware.md)_ +_For more information about how to configure middleware, follow +[Middleware Documentation](https://github.com/yiisoft/docs/blob/master/guide/en/structure/middleware.md)_ Now you can use the Active Record in the action: diff --git a/composer-require-checker.json b/composer-require-checker.json index c17694e98..97c5b755f 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -3,6 +3,7 @@ "Psr\\Http\\Message\\ResponseInterface", "Psr\\Http\\Message\\ServerRequestInterface", "Psr\\Http\\Server\\MiddlewareInterface", - "Psr\\Http\\Server\\RequestHandlerInterface" + "Psr\\Http\\Server\\RequestHandlerInterface", + "Yiisoft\\Factory\\Factory" ] } diff --git a/composer.json b/composer.json index dfd088270..8d8629f41 100644 --- a/composer.json +++ b/composer.json @@ -45,6 +45,7 @@ "yiisoft/cache": "^3.0", "yiisoft/db-sqlite": "dev-master", "yiisoft/di": "^1.0", + "yiisoft/factory": "^1.2", "yiisoft/json": "^1.0", "yiisoft/middleware-dispatcher": "^5.2" }, @@ -54,6 +55,7 @@ "yiisoft/db-pgsql": "For PostgreSQL database support", "yiisoft/db-mssql": "For MSSQL database support", "yiisoft/db-oracle": "For Oracle database support", + "yiisoft/factory": "For factory support", "yiisoft/middleware-dispatcher": "For middleware support" }, "autoload": { diff --git a/docs/create-model.md b/docs/create-model.md index 791bea951..1c6158377 100644 --- a/docs/create-model.md +++ b/docs/create-model.md @@ -289,3 +289,7 @@ $user = $userQuery->where(['id' => 1])->onePopulate(); $profile = $user->getProfile(); $orders = $user->getOrders(); ``` + +Also see [Using Dependency Injection With Active Record Model](docs/using-di.md). + +Back to [README](../README.md) diff --git a/docs/using-di.md b/docs/using-di.md new file mode 100644 index 000000000..8837c9447 --- /dev/null +++ b/docs/using-di.md @@ -0,0 +1,96 @@ +# Using Dependency Injection With Active Record + +Using [dependency injection](https://github.com/yiisoft/di) in the Active Record model allows to inject dependencies +into the model and use 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 + +Yii Active Record provides a `FactoryTrait` trait that allows to use the factory with the Active Record class. + +```php +use Yiisoft\ActiveRecord\ActiveQueryInterface; +use Yiisoft\ActiveRecord\ActiveRecord; +use Yiisoft\ActiveRecord\Trait\FactoryTrait; + +#[\AllowDynamicProperties] +final class User extends ActiveRecord +{ + use FactoryTrait; + + public function __construct(private MyService $myService) + { + } + + public function getTableName(): string + { + return '{{%user}}'; + } + + public function relationQuery(string $name): ActiveQueryInterface + { + return match ($name) { + 'profile' => $this->hasOne(Profile::class, ['id' => 'profile_id']), + 'orders' => $this->hasMany(Order::class, ['user_id' => 'id']), + default => parent::relationQuery($name), + }; + } + + public function getProfile(): Profile|null + { + return $this->relation('profile'); + } + + /** @return Order[] */ + public function getOrders(): array + { + return $this->relation('orders'); + } +} +``` + +When you use dependency injection in the Active Record model, you need to create the Active Record instance using +the factory. + +```php +/** @var \Yiisoft\Factory\Factory $factory */ +$user = $factory->create(User::class); +``` + +To create `ActiveQuery` instance you also need to use the factory to create the Active Record model. + +```php +$userQuery = new ActiveQuery($factory->create(User::class)->withFactory($factory)); +``` + +## Factory Parameter In The Constructor + +Optionally, you can define the factory parameter in the constructor of the Active Record class. + +```php +use Yiisoft\ActiveRecord\ActiveQueryInterface; +use Yiisoft\ActiveRecord\ActiveRecord; +use Yiisoft\ActiveRecord\Trait\FactoryTrait; + +#[\AllowDynamicProperties] +final class User extends ActiveRecord +{ + use FactoryTrait; + + public function __construct(Factory $factory, private MyService $myService) + { + $this->factory = $factory; + } +} +``` + +This will allow to create the `ActiveQuery` instance without calling `ActiveRecord::withFactory()` method. + +```php +$userQuery = new ActiveQuery($factory->create(User::class)); +``` + +Back to [Create Active Record Model](docs/create-model.md) diff --git a/src/Trait/FactoryTrait.php b/src/Trait/FactoryTrait.php new file mode 100644 index 000000000..ec2e736e4 --- /dev/null +++ b/src/Trait/FactoryTrait.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +namespace Yiisoft\ActiveRecord\Trait; + +use Closure; +use Yiisoft\ActiveRecord\ActiveQuery; +use Yiisoft\ActiveRecord\ActiveQueryInterface; +use Yiisoft\ActiveRecord\ActiveRecordInterface; +use Yiisoft\Factory\Factory; + +use function is_string; +use function method_exists; + +/** + * Trait to add factory support to ActiveRecord. + * + * @see AbstractActiveRecord::instantiateQuery() + */ +trait FactoryTrait +{ + private Factory $factory; + + /** + * Set the factory to use for creating new instances. + */ + public function withFactory(Factory $factory): static + { + $new = clone $this; + $new->factory = $factory; + return $new; + } + + public function instantiateQuery(string|ActiveRecordInterface|Closure $arClass): ActiveQueryInterface + { + if (!isset($this->factory)) { + return new ActiveQuery($arClass); + } + + if (is_string($arClass)) { + if (method_exists($arClass, 'withFactory')) { + return new ActiveQuery( + fn (): ActiveRecordInterface => $this->factory->create($arClass)->withFactory($this->factory) + ); + } + + return new ActiveQuery(fn (): ActiveRecordInterface => $this->factory->create($arClass)); + } + + if ($arClass instanceof ActiveRecordInterface && method_exists($arClass, 'withFactory')) { + return new ActiveQuery( + $arClass->withFactory($this->factory) + ); + } + + return new ActiveQuery($arClass); + } +} diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 54126b82a..ca9e246df 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -4,6 +4,7 @@ namespace Yiisoft\ActiveRecord\Tests; +use ArgumentCountError; use DivisionByZeroError; use ReflectionException; use Yiisoft\ActiveRecord\ActiveQuery; @@ -14,6 +15,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerClosureField; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerForArrayable; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerWithAlias; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerWithFactory; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerWithCustomConnection; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Dog; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Item; @@ -22,6 +24,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\OrderWithFactory; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Type; use Yiisoft\ActiveRecord\Tests\Support\Assert; use Yiisoft\Db\Exception\Exception; @@ -30,9 +33,12 @@ use Yiisoft\Db\Exception\InvalidConfigException; use Yiisoft\Db\Exception\UnknownPropertyException; use Yiisoft\Db\Query\Query; +use Yiisoft\Factory\Factory; abstract class ActiveRecordTest extends TestCase { + abstract protected function createFactory(): Factory; + public function testStoreNull(): void { $this->checkFixture($this->db(), 'null_values', true); @@ -985,4 +991,102 @@ public function testWithCustomConnection(): void ConnectionProvider::remove('custom'); } + + public function testWithFactory(): void + { + $this->checkFixture($this->db(), 'order'); + + $factory = $this->createFactory(); + + $orderQuery = new ActiveQuery($factory->create(OrderWithFactory::class)->withFactory($factory)); + $order = $orderQuery->with('customerWithFactory')->findOne(2); + + $this->assertInstanceOf(OrderWithFactory::class, $order); + $this->assertTrue($order->isRelationPopulated('customerWithFactory')); + $this->assertInstanceOf(CustomerWithFactory::class, $order->getCustomerWithFactory()); + } + + public function testWithFactoryClosureRelation(): void + { + $this->checkFixture($this->db(), 'order'); + + $factory = $this->createFactory(); + + $orderQuery = new ActiveQuery($factory->create(OrderWithFactory::class)->withFactory($factory)); + $order = $orderQuery->findOne(2); + + $this->assertInstanceOf(OrderWithFactory::class, $order); + $this->assertInstanceOf(CustomerWithFactory::class, $order->getCustomerWithFactoryClosure()); + } + + public function testWithFactoryInstanceRelation(): void + { + $this->checkFixture($this->db(), 'order'); + + $factory = $this->createFactory(); + + $orderQuery = new ActiveQuery($factory->create(OrderWithFactory::class)->withFactory($factory)); + $order = $orderQuery->findOne(2); + + $this->assertInstanceOf(OrderWithFactory::class, $order); + $this->assertInstanceOf(CustomerWithFactory::class, $order->getCustomerWithFactoryInstance()); + } + + public function testWithFactoryRelationWithoutFactory(): void + { + $this->checkFixture($this->db(), 'order'); + + $factory = $this->createFactory(); + + $orderQuery = new ActiveQuery($factory->create(OrderWithFactory::class)->withFactory($factory)); + $order = $orderQuery->findOne(2); + + $this->assertInstanceOf(OrderWithFactory::class, $order); + $this->assertInstanceOf(Customer::class, $order->getCustomer()); + } + + public function testWithFactoryLazyRelation(): void + { + $this->checkFixture($this->db(), 'order'); + + $factory = $this->createFactory(); + + $orderQuery = new ActiveQuery($factory->create(OrderWithFactory::class)->withFactory($factory)); + $order = $orderQuery->findOne(2); + + $this->assertInstanceOf(OrderWithFactory::class, $order); + $this->assertFalse($order->isRelationPopulated('customerWithFactory')); + $this->assertInstanceOf(CustomerWithFactory::class, $order->getCustomerWithFactory()); + } + + public function testWithFactoryWithConstructor(): void + { + $this->checkFixture($this->db(), 'order'); + + $factory = $this->createFactory(); + + $customerQuery = new ActiveQuery($factory->create(CustomerWithFactory::class)); + $customer = $customerQuery->findOne(2); + + $this->assertInstanceOf(CustomerWithFactory::class, $customer); + $this->assertFalse($customer->isRelationPopulated('ordersWithFactory')); + $this->assertInstanceOf(OrderWithFactory::class, $customer->getOrdersWithFactory()[0]); + } + + public function testWithFactoryNonInitiated(): void + { + $this->checkFixture($this->db(), 'order'); + + $orderQuery = new ActiveQuery(OrderWithFactory::class); + $order = $orderQuery->findOne(2); + + $customer = $order->getCustomer(); + + $this->assertInstanceOf(Customer::class, $customer); + + $this->expectException(ArgumentCountError::class); + $this->expectExceptionMessage('Too few arguments to function'); + + $customer = $order->getCustomerWithFactory(); + } } diff --git a/tests/Driver/Mssql/ActiveRecordTest.php b/tests/Driver/Mssql/ActiveRecordTest.php index 39ad1fa00..a3d738ca8 100644 --- a/tests/Driver/Mssql/ActiveRecordTest.php +++ b/tests/Driver/Mssql/ActiveRecordTest.php @@ -9,6 +9,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\TestTriggerAlert; use Yiisoft\ActiveRecord\Tests\Support\MssqlHelper; use Yiisoft\Db\Connection\ConnectionInterface; +use Yiisoft\Factory\Factory; final class ActiveRecordTest extends \Yiisoft\ActiveRecord\Tests\ActiveRecordTest { @@ -17,6 +18,11 @@ protected function createConnection(): ConnectionInterface return (new MssqlHelper())->createConnection(); } + protected function createFactory(): Factory + { + return (new MssqlHelper())->createFactory($this->db()); + } + public function testSaveWithTrigger(): void { $this->checkFixture($this->db(), 'test_trigger'); diff --git a/tests/Driver/Mysql/ActiveRecordTest.php b/tests/Driver/Mysql/ActiveRecordTest.php index c4ecfdd21..308e6ea6a 100644 --- a/tests/Driver/Mysql/ActiveRecordTest.php +++ b/tests/Driver/Mysql/ActiveRecordTest.php @@ -10,6 +10,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Customer; use Yiisoft\ActiveRecord\Tests\Support\MysqlHelper; use Yiisoft\Db\Connection\ConnectionInterface; +use Yiisoft\Factory\Factory; final class ActiveRecordTest extends \Yiisoft\ActiveRecord\Tests\ActiveRecordTest { @@ -18,6 +19,11 @@ protected function createConnection(): ConnectionInterface return (new MysqlHelper())->createConnection(); } + protected function createFactory(): Factory + { + return (new MysqlHelper())->createFactory($this->db()); + } + public function testCastValues(): void { $this->checkFixture($this->db(), 'type'); diff --git a/tests/Driver/Oracle/ActiveRecordTest.php b/tests/Driver/Oracle/ActiveRecordTest.php index 4f82fc7ef..e8595a342 100644 --- a/tests/Driver/Oracle/ActiveRecordTest.php +++ b/tests/Driver/Oracle/ActiveRecordTest.php @@ -10,6 +10,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Type; use Yiisoft\ActiveRecord\Tests\Support\OracleHelper; use Yiisoft\Db\Connection\ConnectionInterface; +use Yiisoft\Factory\Factory; final class ActiveRecordTest extends \Yiisoft\ActiveRecord\Tests\ActiveRecordTest { @@ -18,6 +19,11 @@ protected function createConnection(): ConnectionInterface return (new OracleHelper())->createConnection(); } + protected function createFactory(): Factory + { + return (new OracleHelper())->createFactory($this->db()); + } + public function testCastValues(): void { $this->markTestSkipped('Cant bind floats without support from a custom PDO driver.'); diff --git a/tests/Driver/Pgsql/ActiveRecordTest.php b/tests/Driver/Pgsql/ActiveRecordTest.php index df91df2c9..c6f1f37da 100644 --- a/tests/Driver/Pgsql/ActiveRecordTest.php +++ b/tests/Driver/Pgsql/ActiveRecordTest.php @@ -21,6 +21,7 @@ use Yiisoft\Db\Expression\Expression; use Yiisoft\Db\Expression\JsonExpression; use Yiisoft\Db\Pgsql\Schema as SchemaPgsql; +use Yiisoft\Factory\Factory; final class ActiveRecordTest extends \Yiisoft\ActiveRecord\Tests\ActiveRecordTest { @@ -29,6 +30,11 @@ protected function createConnection(): ConnectionInterface return (new PgsqlHelper())->createConnection(); } + protected function createFactory(): Factory + { + return (new PgsqlHelper())->createFactory($this->db()); + } + public function testDefaultValues(): void { $this->checkFixture($this->db(), 'type'); diff --git a/tests/Driver/Sqlite/ActiveRecordTest.php b/tests/Driver/Sqlite/ActiveRecordTest.php index f9da98db3..d47fc396a 100644 --- a/tests/Driver/Sqlite/ActiveRecordTest.php +++ b/tests/Driver/Sqlite/ActiveRecordTest.php @@ -9,6 +9,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Customer; use Yiisoft\ActiveRecord\Tests\Support\SqliteHelper; use Yiisoft\Db\Connection\ConnectionInterface; +use Yiisoft\Factory\Factory; final class ActiveRecordTest extends \Yiisoft\ActiveRecord\Tests\ActiveRecordTest { @@ -17,6 +18,11 @@ protected function createConnection(): ConnectionInterface return (new SqliteHelper())->createConnection(); } + protected function createFactory(): Factory + { + return (new SqliteHelper())->createFactory($this->db()); + } + public function testExplicitPkOnAutoIncrement(): void { $this->checkFixture($this->db(), 'customer', true); diff --git a/tests/Stubs/ActiveRecord/CustomerWithFactory.php b/tests/Stubs/ActiveRecord/CustomerWithFactory.php new file mode 100644 index 000000000..1c2b46c86 --- /dev/null +++ b/tests/Stubs/ActiveRecord/CustomerWithFactory.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +namespace Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord; + +use Yiisoft\ActiveRecord\ActiveQueryInterface; +use Yiisoft\ActiveRecord\Trait\FactoryTrait; +use Yiisoft\Aliases\Aliases; +use Yiisoft\Factory\Factory; + +final class CustomerWithFactory extends Customer +{ + use FactoryTrait; + + public function __construct(Factory $factory, private Aliases $aliases) + { + $this->factory = $factory; + } + + public function relationQuery(string $name): ActiveQueryInterface + { + return match ($name) { + 'ordersWithFactory' => $this->hasMany(OrderWithFactory::class, ['customer_id' => 'id']), + default => parent::relationQuery($name), + }; + } + + /** @return OrderWithFactory[] */ + public function getOrdersWithFactory(): array + { + return $this->relation('ordersWithFactory'); + } +} diff --git a/tests/Stubs/ActiveRecord/OrderWithFactory.php b/tests/Stubs/ActiveRecord/OrderWithFactory.php new file mode 100644 index 000000000..d0a8b9282 --- /dev/null +++ b/tests/Stubs/ActiveRecord/OrderWithFactory.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +namespace Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord; + +use Yiisoft\ActiveRecord\ActiveQueryInterface; +use Yiisoft\ActiveRecord\Trait\FactoryTrait; + +final class OrderWithFactory extends Order +{ + use FactoryTrait; + + public function relationQuery(string $name): ActiveQueryInterface + { + return match ($name) { + 'customerWithFactory' => $this->hasOne(CustomerWithFactory::class, ['id' => 'customer_id']), + 'customerWithFactoryClosure' => $this->hasOne( + fn () => $this->factory->create(CustomerWithFactory::class), + ['id' => 'customer_id'] + ), + 'customerWithFactoryInstance' => $this->hasOne( + $this->factory->create(CustomerWithFactory::class), + ['id' => 'customer_id'] + ), + default => parent::relationQuery($name), + }; + } + + public function getCustomerWithFactory(): CustomerWithFactory|null + { + return $this->relation('customerWithFactory'); + } + + public function getCustomerWithFactoryClosure(): CustomerWithFactory|null + { + return $this->relation('customerWithFactoryClosure'); + } + + public function getCustomerWithFactoryInstance(): CustomerWithFactory|null + { + return $this->relation('customerWithFactoryInstance'); + } +} diff --git a/tests/Support/ConnectionHelper.php b/tests/Support/ConnectionHelper.php index 2b9a07dd5..14bde41a5 100644 --- a/tests/Support/ConnectionHelper.php +++ b/tests/Support/ConnectionHelper.php @@ -4,7 +4,6 @@ namespace Yiisoft\ActiveRecord\Tests\Support; -use Yiisoft\ActiveRecord\ActiveRecordFactory; use Yiisoft\Cache\ArrayCache; use Yiisoft\Db\Cache\SchemaCache; use Yiisoft\Db\Connection\ConnectionInterface; @@ -14,19 +13,12 @@ abstract class ConnectionHelper { - protected Factory $factory; - - public function createARFactory(ConnectionInterface $db): ActiveRecordFactory - { - return new ActiveRecordFactory($this->createFactory($db)); - } - protected function createSchemaCache(): SchemaCache { return new SchemaCache(new ArrayCache()); } - private function createFactory(ConnectionInterface $db): Factory + public function createFactory(ConnectionInterface $db): Factory { $container = new Container(ContainerConfig::create()->withDefinitions([ConnectionInterface::class => $db])); return new Factory($container, [ConnectionInterface::class => $db]);