From ded355d17e6b86d4e377de0d71b350a63a55c37d Mon Sep 17 00:00:00 2001 From: Sergei Tigrov Date: Sun, 25 Aug 2024 09:57:19 +0700 Subject: [PATCH] Update docs (#379) --- docs/create-model.md | 17 +- docs/define-connection.md | 4 +- docs/define-relations.md | 465 ++++++++++++++++++++++++++++++++++ docs/using-di.md | 27 +- src/ActiveQuery.php | 2 +- src/ActiveQueryInterface.php | 4 +- src/ActiveQueryTrait.php | 2 +- src/ActiveRecord.php | 2 +- src/ActiveRecordInterface.php | 4 +- src/ActiveRelationTrait.php | 4 +- 10 files changed, 482 insertions(+), 49 deletions(-) create mode 100644 docs/define-relations.md diff --git a/docs/create-model.md b/docs/create-model.md index 4bb5d704e..76f9ffd68 100644 --- a/docs/create-model.md +++ b/docs/create-model.md @@ -243,18 +243,9 @@ use Yiisoft\ActiveRecord\ActiveRecord; /** * Entity User. **/ +#[\AllowDynamicProperties] final class User extends ActiveRecord { - public int $id; - public string $username; - public string $email; - public int $profile_id; - - public function getTableName(): string - { - return '{{%user}}'; - } - public function relationQuery(string $name): ActiveQueryInterface { return match ($name) { @@ -282,7 +273,7 @@ Now you can use `$user->getProfile()` and `$user->getOrders()` to access the rel ```php use Yiisoft\ActiveRecord\ActiveQuery; -$userQuery = new ActiveQuery(User::class, $db); +$userQuery = new ActiveQuery(User::class); $user = $userQuery->where(['id' => 1])->one(); @@ -290,6 +281,8 @@ $profile = $user->getProfile(); $orders = $user->getOrders(); ``` -Also see [Using Dependency Injection With Active Record Model](docs/using-di.md). +For more information on defining relations, see [Define Relations](define-relations.md). + +Also see [Using Dependency Injection With Active Record Model](using-di.md). Back to [README](../README.md) diff --git a/docs/define-connection.md b/docs/define-connection.md index 49548a8da..60a8243d2 100644 --- a/docs/define-connection.md +++ b/docs/define-connection.md @@ -57,9 +57,9 @@ use Psr\Http\Message\ResponseInterface; use Yiisoft\ActiveRecord\ConnectionProvider; use Yiisoft\Db\Connection\ConnectionInterface; -final class Register +final class SomeController { - public function register( + public function someAction( ConnectionInterface $db, ): ResponseInterface { ConnectionProvider::set($db); diff --git a/docs/define-relations.md b/docs/define-relations.md new file mode 100644 index 000000000..9c57f3031 --- /dev/null +++ b/docs/define-relations.md @@ -0,0 +1,465 @@ +# Active Record Relations + +```mermaid +erDiagram + USER ||--|| PROFILE : has + USER ||--o{ ORDER : places + USER }o--o{ GROUP : belongs +``` + +## Define Relations + +### Overriding `relationQuery()` + +To define the relations in the Active Record model, you need to override `relationQuery()` method. This method should +return an instance of `ActiveQueryInterface` interface for the relation name. + +```php +use Yiisoft\ActiveRecord\ActiveRecord; +use Yiisoft\ActiveRecord\ActiveQueryInterface; + +final class User extends ActiveRecord +{ + 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), + }; + } +} +``` + +Also, you can use `relationQuery()` method to get a relation query by name. + +```php +$user = new User(); + +$profileQuery = $user->relationQuery('profile'); +$ordersQuery = $user->relationQuery('orders'); +``` + +### Using `MagicRelationsTrait` + +Alternatively, you can use `MagicRelationsTrait` trait to define relations in the Active Record model. This trait allows +you to define relation methods directly in the model without overriding `relationQuery()` method. The relation +methods should have a specific naming convention to be recognized by the trait. The method names should have prefix +`get` and suffix `Query` and returns an object implementing `ActiveQueryInterface` interface. + +```php +use Yiisoft\ActiveRecord\ActiveRecord; +use Yiisoft\ActiveRecord\ActiveQueryInterface; +use Yiisoft\ActiveRecord\MagicRelationsTrait; + +final class User extends ActiveRecord +{ + use MagicRelationsTrait; + + public function getProfileQuery(): ActiveQueryInterface + { + return $this->hasOne(Profile::class, ['id' => 'profile_id']); + } + + public function getOrdersQuery(): ActiveQueryInterface + { + return $this->hasMany(Order::class, ['user_id' => 'id']); + } +} +``` + +## Relation Methods + +`ActiveRecord` class has two methods to define different relation types: `hasOne()` and `hasMany()`. Both methods have +the same signature: + +```php +public function hasOne(string|ActiveRecordInterface|Closure $class, array $link): ActiveQueryInterface; + +public function hasMany(string|ActiveRecordInterface|Closure $class, array $link): ActiveQueryInterface; +``` + +- `$class` parameter is the class name of the related record, or an instance of the related record, or a Closure to + create an `ActiveRecordInterface` object. For example: `Profile::class`, `new Profile()`, or `fn() => new Profile()`. +- `$link` parameter is an array that defines the foreign key constraint. The keys of the array refer to the attributes + of the record associated with `$class` model, while the values of the array refer to the corresponding attributes + in the current Active Record class. For example: `['id' => 'profile_id']`, where `id` attribute of the related record + is linked to `profile_id` attribute of the current record. + +## Relation Types + +### One-to-one + +```mermaid +erDiagram + USER ||--|| PROFILE : has + USER { + int id + int profile_id + } + PROFILE { + int id + } +``` + +To define a **one-to-one** relation, use `hasOne()` method. + +```php +$this->hasOne(Profile::class, ['id' => 'profile_id']); +``` + +### One-to-many + +```mermaid +erDiagram + USER ||--o{ ORDER : places + USER { + int id + } + ORDER { + int id + int user_id + } +``` + +To define a **one-to-many** relation, use `hasMany()` method. + +```php +$this->hasMany(Order::class, ['user_id' => 'id']); +``` + +### Many-to-one + +```mermaid +erDiagram + ORDER }o--|| USER : belongs + USER { + int id + } + ORDER { + int id + int user_id + } +``` + +This type of relation is the same as the **one-to-one** relation, but the related record has many records associated +with it. Use the `hasOne()` method to define this relation. + +```php +$this->hasOne(User::class, ['id' => 'user_id']); +``` + +### Many-to-many + +The relationships are used when you need to link multiple records from one table to multiple records from another table. +This is common in scenarios where entities have a bidirectional relationship, such as users belonging to multiple groups +and groups having multiple users. + +```mermaid +erDiagram + USER }o--o{ GROUP : belongs +``` + +This is a complex relation type that сan be implemented in several ways. To define this relation use `hasMany()` method. + +#### Junction Table + +```mermaid +erDiagram + USER ||--o{ USER-GROUP : belongs + GROUP ||--o{ USER-GROUP : belongs + USER { + int id + } + GROUP { + int id + } + USER-GROUP { + int user_id + int group_id + } +``` + +This is the most common way to implement a **many-to-many** relation. You need to create a junction table that contains +foreign keys that reference the primary keys of the related tables. + +```php +$this->hasMany(Group::class, ['id' => 'group_id'])->viaTable('user_group', ['user_id' => 'id']); +``` + +In the example, `user_group` table contains two foreign keys: +- `user_id` attribute references the `id` attribute of the current record of `User` model; +- `group_id` attribute references the `id` attribute of the related record of `Group` model. + +```php +use Yiisoft\ActiveRecord\ActiveRecord; +use Yiisoft\ActiveRecord\ActiveQueryInterface; + +final class User extends ActiveRecord +{ + public int $id; + + public function relationQuery(string $name): ActiveQueryInterface + { + return match ($name) { + 'groups' => $this->hasMany(Group::class, ['id' => 'group_id'])->viaTable('user_group', ['user_id' => 'id']), + default => parent::relationQuery($name), + }; + } +} + +final class Group extends ActiveRecord +{ + public int $id; + + public function relationQuery(string $name): ActiveQueryInterface + { + return match ($name) { + 'users' => $this->hasMany(User::class, ['id' => 'user_id'])->viaTable('user_group', ['group_id' => 'id']), + default => parent::relationQuery($name), + }; + } +} +``` + +Use this method when you don't need to store additional information in the junction table. + +#### Junction Model + +This is the most flexible way to implement a **many-to-many** relation. You need to create a junction model that +represents the junction table. + +```php +$this->hasMany(Group::class, ['id' => 'group_id'])->via('userGroup'); +``` + +In the example, `userGroup` is a relation name associated with the junction model. You need to define this relation +in the Active Record model. + +```php +use Yiisoft\ActiveRecord\ActiveRecord; +use Yiisoft\ActiveRecord\ActiveQueryInterface; + +final class User extends ActiveRecord +{ + public int $id; + + public function relationQuery(string $name): ActiveQueryInterface + { + return match ($name) { + 'groups' => $this->hasMany(Group::class, ['id' => 'group_id'])->via('userGroup'), + 'userGroup' => $this->hasMany(UserGroup::class, ['user_id' => 'id']), + default => parent::relationQuery($name), + }; + } +} + +final class Group extends ActiveRecord +{ + public int $id; + + public function relationQuery(string $name): ActiveQueryInterface + { + return match ($name) { + 'users' => $this->hasMany(User::class, ['id' => 'user_id'])->via('userGroup'), + 'userGroup' => $this->hasMany(UserGroup::class, ['group_id' => 'id']), + default => parent::relationQuery($name), + }; + } +} + +final class UserGroup extends ActiveRecord +{ + public int $user_id; + public int $group_id; + + public function relationQuery(string $name): ActiveQueryInterface + { + return match ($name) { + 'user' => $this->hasOne(User::class, ['id' => 'user_id']), + 'group' => $this->hasOne(Group::class, ['id' => 'group_id']), + default => parent::relationQuery($name), + }; + } +} +``` + +Use this method when you need to store additional information in the junction table. + +#### Array of Related Keys + +```mermaid +erDiagram + USER }o--o{ GROUP : belongs + USER { + int id + int[] group_ids + } + GROUP { + int id + } +``` + +This is the simplest way to implement a **many-to-many** relation. You don't need to create a junction table or a +junction model to represent the junction table. Instead, you can use an array of related keys. + +```php +$this->hasMany(Group::class, ['id' => 'group_ids']); +``` + +In the example, `group_ids` attribute of the current record is an array of related keys that reference `id` attribute +of the related record. The array attribute can be represented in the database as an `array` type (currently supported +by `PgSQL` driver only) or as a `JSON` type (currently supported by `MySQL`, `PgSql`, and `SQLite` drivers). + +```php +use Yiisoft\ActiveRecord\ActiveRecord; +use Yiisoft\ActiveRecord\ActiveQueryInterface; + +final class User extends ActiveRecord +{ + public int $id; + public array $group_ids = []; + + public function relationQuery(string $name): ActiveQueryInterface + { + return match ($name) { + 'groups' => $this->hasMany(Group::class, ['id' => 'group_ids']), + default => parent::relationQuery($name), + }; + } +} + +final class Group extends ActiveRecord +{ + public int $id; + + public function relationQuery(string $name): ActiveQueryInterface + { + return match ($name) { + 'users' => $this->hasMany(User::class, ['group_ids' => 'id']), + default => parent::relationQuery($name), + }; + } +} +``` + +Use this method when you don't need to store additional information in the junction table and the database supports +`array` or `JSON` types. + +## Inverse Relations + +An inverse relation is a relation that is defined in the related record to link back to the current record. It is used +to associate the related record(s) with the current record in a more efficient way by avoiding additional queries. + +To define an inverse relation, use the `ActiveQueryInterface::inverseOf()` method. + +```php +$this->hasMany(Order::class, ['user_id' => 'id'])->inverseOf('user'); +``` + +In the example, `user` is the inverse relation name associated with the `Order` model. + +```php +use Yiisoft\ActiveRecord\ActiveRecord; +use Yiisoft\ActiveRecord\ActiveQueryInterface; + +final class User extends ActiveRecord +{ + public int $id; + + public function relationQuery(string $name): ActiveQueryInterface + { + return match ($name) { + 'orders' => $this->hasMany(Order::class, ['user_id' => 'id'])->inverseOf('user'), + default => parent::relationQuery($name), + }; + } +} + +final class Order extends ActiveRecord +{ + public int $id; + public int $user_id; + + public function relationQuery(string $name): ActiveQueryInterface + { + return match ($name) { + 'user' => $this->hasMany(User::class, ['id' => 'user_id'])->inverseOf('orders'), + default => parent::relationQuery($name), + }; + } +} +``` + +## Eager Loading + +**Relations are loaded lazily**, meaning that the related record(s) are not loaded until you access them. This allows +you to load only the data you need and avoid unnecessary queries. + +However, there are cases when you need to load the related record(s) in advance to avoid the **N+1 query problem**. +To do this, use the `ActiveQueryInterface::with()` method. + +```php +use Yiisoft\ActiveRecord\ActiveQuery; + +$userQuery = new ActiveQuery(User::class); + +$users = $userQuery->with('profile', 'orders')->all(); +``` + +In the example, `profile` and `orders` are the relation names that you want to load in advance. + +## Accessing Relations + +To get the related record, use `ActiveRecordInterface::relation()` method. This method returns the related record(s) +or `null` (empty array for `AbstractActiveRecord::hasMany()` relation type) if the record(s) not found. + +You can define getter methods to access the relations. + +```php +use Yiisoft\ActiveRecord\ActiveRecord; +use Yiisoft\ActiveRecord\ActiveQueryInterface; + +final class User extends ActiveRecord +{ + 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'); + } +} +``` + +## Usage + +Now you can use `$user->getProfile()` and `$user->getOrders()` to access the relations. + +```php +use Yiisoft\ActiveRecord\ActiveQuery; + +$userQuery = (new ActiveQuery(User::class))->where(['id' => 1]); + +$user = $userQuery->one(); + +$profile = $user->getProfile(); +$orders = $user->getOrders(); +``` + +Back to + +- [Create Active Record Model](create-model.md). +- [README](../README.md) diff --git a/docs/using-di.md b/docs/using-di.md index 8837c9447..d5b257de2 100644 --- a/docs/using-di.md +++ b/docs/using-di.md @@ -24,31 +24,6 @@ final class User extends ActiveRecord 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'); - } } ``` @@ -93,4 +68,4 @@ This will allow to create the `ActiveQuery` instance without calling `ActiveReco $userQuery = new ActiveQuery($factory->create(User::class)); ``` -Back to [Create Active Record Model](docs/create-model.md) +Back to [Create Active Record Model](create-model.md) diff --git a/src/ActiveQuery.php b/src/ActiveQuery.php index 059957689..453a857f2 100644 --- a/src/ActiveQuery.php +++ b/src/ActiveQuery.php @@ -79,7 +79,7 @@ * These options can be configured using methods of the same name. For example: * * ```php - * $customerQuery = new ActiveQuery(Customer::class, $db); + * $customerQuery = new ActiveQuery(Customer::class); * $query = $customerQuery->with('orders')->asArray()->all(); * ``` * diff --git a/src/ActiveQueryInterface.php b/src/ActiveQueryInterface.php index 6e226cf93..55ba0cff2 100644 --- a/src/ActiveQueryInterface.php +++ b/src/ActiveQueryInterface.php @@ -63,7 +63,7 @@ public function asArray(bool|null $value = true): self; * * ```php * // Create active query - * CustomerQuery = new ActiveQuery(Customer::class, $db); + * CustomerQuery = new ActiveQuery(Customer::class); * // find customers together with their orders and country * CustomerQuery->with('orders', 'country')->all(); * // find customers together with their orders and the orders' shipping address @@ -152,7 +152,7 @@ public function buildJoinWith(): void; * * ```php * // Find all orders that contain books, and eager loading "books". - * $orderQuery = new ActiveQuery(Order::class, $db); + * $orderQuery = new ActiveQuery(Order::class); * $orderQuery->joinWith('books', true, 'INNER JOIN')->all(); * * // find all orders, eager loading "books", and sort the orders and books by the book names. diff --git a/src/ActiveQueryTrait.php b/src/ActiveQueryTrait.php index eed6682a0..5fcc76f89 100644 --- a/src/ActiveQueryTrait.php +++ b/src/ActiveQueryTrait.php @@ -50,7 +50,7 @@ public function asArray(bool|null $value = true): static * * ```php * // Create active query - * CustomerQuery = new ActiveQuery(Customer::class, $db); + * CustomerQuery = new ActiveQuery(Customer::class); * // find customers together with their orders and country * CustomerQuery->with('orders', 'country')->all(); * // find customers together with their orders and the orders' shipping address diff --git a/src/ActiveRecord.php b/src/ActiveRecord.php index 09ce60b61..ec271a67e 100644 --- a/src/ActiveRecord.php +++ b/src/ActiveRecord.php @@ -68,7 +68,7 @@ * $user->save(); // a new row is inserted into user table * * // the following will retrieve the user 'CeBe' from the database - * $userQuery = new ActiveQuery(User::class, $db); + * $userQuery = new ActiveQuery(User::class); * $user = $userQuery->where(['name' => 'CeBe'])->one(); * * // this will get related records from orders table when relation is defined diff --git a/src/ActiveRecordInterface.php b/src/ActiveRecordInterface.php index 6ae6d0bfc..8b5a810ae 100644 --- a/src/ActiveRecordInterface.php +++ b/src/ActiveRecordInterface.php @@ -63,7 +63,7 @@ public function delete(): int; * > Warning: If you don't specify any condition, this method will delete **all** rows in the table. * * ```php - * $customerQuery = new ActiveQuery(Customer::class, $db); + * $customerQuery = new ActiveQuery(Customer::class); * $aqClasses = $customerQuery->where('status = 3')->all(); * foreach ($aqClasses as $aqClass) { * $aqClass->delete(); @@ -445,7 +445,7 @@ public function update(array $attributeNames = null): int; * > Warning: If you don't specify any condition, this method will update **all** rows in the table. * * ```php - * $customerQuery = new ActiveQuery(Customer::class, $db); + * $customerQuery = new ActiveQuery(Customer::class); * $customers = $customerQuery->where('status = 2')->all(); * foreach ($customers as $customer) { * $customer->status = 1; diff --git a/src/ActiveRelationTrait.php b/src/ActiveRelationTrait.php index 7a49988ed..c46fdd054 100644 --- a/src/ActiveRelationTrait.php +++ b/src/ActiveRelationTrait.php @@ -144,7 +144,7 @@ public function via(string $relationName, callable $callable = null): static * Let's suppose customer has several orders. If only one order was loaded: * * ```php - * $orderQuery = new ActiveQuery(Order::class, $db); + * $orderQuery = new ActiveQuery(Order::class); * $orders = $orderQuery->where(['id' => 1])->all(); * $customerOrders = $orders[0]->customer->orders; * ``` @@ -152,7 +152,7 @@ public function via(string $relationName, callable $callable = null): static * variable `$customerOrders` will contain only one order. If orders was loaded like this: * * ```php - * $orderQuery = new ActiveQuery(Order::class, $db); + * $orderQuery = new ActiveQuery(Order::class); * $orders = $orderQuery->with('customer')->where(['customer_id' => 1])->all(); * $customerOrders = $orders[0]->customer->orders; * ```