CodeIgniterについてアウトプットしてみた～Entity編～

## はじめに

今回はCodeIgniterで用意されているEntityクラスについて説明します。
Entityクラスは、テーブルの1レコードの各カラムの`ビジネスロジック`を定義するクラスです。
DDDのドメインモデルに近い？のかもしれません。

:::note info
Entityは、CodeIgniterのversion4から導入されました。
:::

ビジネスロジックをEntityクラスに集約することで、コードの保守性が高まり、品質が高くなりそうです。

## Entityクラスを使わない場合の課題

例えば、ユーザテーブルが以下のようなテーブル構成になっているとします。

| 物理名 | 論理名 |
|:-:|:-:|
| id  | ID  |
| last_name  | 姓  |
| first_name  | 名  |
| login_id  |  ログインID |
| password  | パスワード  |
| is_active | 有効フラグ  |
| created_at | 登録日時  |
| updated_at | 更新日時  |
| deleted_at | 削除日時  |

ここで、以下のような処理をしたいとします。
- パスワード登録/更新時には、入力されたパスワードを`ハッシュ化して保存`したい
- ログイン時には、`入力されたパスワードが一致するか`確認したい
- full_nameというキーでユーザ情報にアクセスしたら、`「姓 + 名]の形式`で取りたい
- 数値は数値型、真偽値はbool型など`カラムの型にあった形式`でデータを取得したい

このような場合、どこにこういった処理を書くでしょうか？
・パスワード登録に関しては、ユーザ登録APIやユーザ更新APIやパスワード再設定API
・名前に関してはユーザ一覧API、ユーザ詳細API、それ以外のユーザ情報を表示するAPI
などいろいろなところで使うことが想定されます。

このままだと、各ControllerやModelの複数のメソッドに同じ処理を書くことになりそうです :thinking: 
そうすると、いざ修正したいとなった時に対応漏れが発生しやすくなります。
共通クラスを作るなどやり方はあるとは思いますが、出来るならそういったのは自分たちで作らず、楽をしたいですよね。

そこでEntityクラスの登場です。

## Entityクラスを使ってみる
### EntityクラスとModelクラスの作成

ます、Entityクラスを作ります。
以下のようにテーブルの単数名でEntityクラスを作ります。
```php:app/Entities/User.php

<?php

namespace App\Entities;

use CodeIgniter\Entity\Entity;

class User extends Entity
{
    // castするカラムと型を指定
    protected $casts = [
        'id' => 'integer',
        'is_active' => 'boolean',
    ];
    // 入力されたパスワードをハッシュ化
    public function setPassword(string $pass)
    {
        $this->attributes['password'] = password_hash($pass, PASSWORD_BCRYPT);

        return $this;
    }
    // 入力されたパスワードが登録されているパスワードと一致するか検証
    public function passwordVerify($inputPassword): bool
    {
        return password_verify($inputPassword, $this->attributes['password']);
    }
    // フルネーム(姓+名)を取得
    public function getFullName(): string
    {
        return $this->attributes['last_name'] . ' ' . $this->attributes['first_name'];
    }
}
```

:::note info
publicメソッドにしていますが、他のクラス(Controllerなど)からアクセスしないのであれば、メソッドはprotectedでもよさそうです
:::

次に、Modelクラスを作りましょう。
以下のようにテーブルの単数名でModelクラスを作ります。
```php:app/Models/UserModel.php

<?php

namespace App\Models;

use CodeIgniter\Model;
use App\Entities\User;

class UserModel extends Model
{
    protected $table = 'users';
    protected $primaryKey = 'id';
    protected $useAutoIncrement = true;

    //protected $returnType = 'array';
    protected $returnType = User::class;

    /**
     * @var array<string>
     */
    protected $allowedFields = [
        'login_id',
        'password',
        'last_name',
        'first_name',
        'is_active',
        'created_at',
        'updated_at',
        'deleted_at',
    ];
    protected $protectFields = true;

    // date property
    protected $useTimestamps = true;
    protected $dateFormat = 'datetime';
    protected $createdField = 'created_at';
    protected $updatedField = 'updated_at';
    protected $deletedField = 'deleted_at';
    protected $useSoftDeletes = true;
```

Modelクラスを以下のように変更しています。
`protected $returnType = 'array';`
↓
`protected $returnType = User::class`
これで、Modelを介した操作はEntityの形式で返されるようになります。

### Controllerの作成
では、次にEntityに実装した各メソッドをどのように呼ぶか説明します。
以下のように`ユーザ詳細`と`ユーザパスワード更新`と`ログイン`処理をするAPIがあるとします。
※説明に不要な処理は省いています
```php:app/Controllers/User.php
<?php

namespace App\Controllers;

use App\Models\UserModel;
use CodeIgniter\RESTful\ResourceController;

class User extends ResourceController
{
    /**
     * User model instance
     * @var UserModel
     */
    private UserModel $userModel;


    public function __construct()
    {
        $this->userModel = model(UserModel::class);
    }

    /**
     * ユーザ詳細
     *
     **/
    public function detailUser()
    {
        $user = $this->userModel->find(1);
        $response = [
            'full_name' => $user->full_name,
            'is_active' => $user->is_active,
        ];
        return $this->respond(['status' => 200, 'response' => $response]);
    }

    /**
     * ユーザ更新
     *
     **/
    public function updateUser()
    {
        $user = $this->userModel->find(1);
        $user->password = 'hoge';
        $this->userModel->save($user);

        return $this->respond(['status' => 200, 'response' => $user]);
    }
    /**
     * ログイン
     *
     **/
    public function login()
    {
        $requestBody = $this->request->getJSON(true);
        $user = $this->userModel->find(1);

        if (!$user->passwordVerify($requestBody['password'])) {
            return $this->respond(['status' => 401, 'message' => '認証エラー']);
        }

        return $this->respond(['status' => 200, 'token' => 'xxx']);
    }
}
```

### ユーザ詳細を試してみる
ユーザ詳細APIへリクエストするとレスポンスの
- `full_name`がgetFullName()の値
- `is_active`がboolean型へのキャスト処理が行われた後の値
になっています

```json:レスポンスボディ
{
    "status": 200,
    "response": {
        "full_name": "山田 太郎",
        "is_active": true
    }
}
```
getFullName()メソッドを明示的に呼んでないし、is_activeのキャスト処理も行っていないのに、どうしてだろう:thinking:と思うかもしれませんが、CodeIgniterのEntityでは、カラム読み込み時に
- `get${カラム名のパスカルケース}`というメソッドを作成すると、自動的にメソッドを呼び出し
- `$casts`にカラムと型を設定すると、自動的に型変換
してくれるのです！

ちなみに、$castsでis_activeをキャストしないとレスポンスは以下のように、`文字列の`1になります。
※Mysqlのtinyint(1)カラムを使っている場合

```json:レスポンスボディ
{
    "status": 200,
    "response": {
        "full_name": "山田 太郎",
        "is_active": "1"
    }
}
```

### ユーザ更新を試してみる
ユーザ更新APIをリクエストしたときに、setPassword()処理が行われています。

CodeIgniterのEntityでは、Entityのカラムに対して値をセットした時に
`set${カラム名のパスカルケース}`というメソッドを作成すると、自動的にメソッドを呼び出し
てくれます。

### ログインを試してみる
getterとsetterだけでも非常に便利になることがわかりました。
ただ、複数のカラムを使った判定処理や入力値を使った比較処理など、実際はもっといろんなことをしたいと思います。

その例として、ログインAPIで行うパスワード検証処理として、passwordVerify()を実装してみます。
このメソッドでは入力されたパスワードと、登録されているパスワードのハッシュ値を比較し、認証を行います。
こういったカスタムメソッドは、`$user->passwordVerify($requestBody['password'])`のように、自由にメソッド名や引数を定義し、任意のタイミングで呼び出すことが出来ます。

主な機能としては以上になりますが、他の機能も説明していきます。

## その他の機能

基本的な機能としては、getter、setter、$castsになると思っていますが、それ以外に出来ることについて解説していきます。

### $dates

日付のミューテータ定義をする設定です。
$datesに設定したプロパティは、`CodeIgniter\I18n\Time`インスタンスに変換されます。
なので、以下のように読み取り時に必要なフォーマットに変換できます。
```php
protected $dates = ['created_at', 'updated_at', 'deleted_at']; // デフォルトでは、この3つ
// ex)created_at=2024-02-07 00:00:00
var_dump($user->created_at); // インスタンス(CodeIgniter\I18n\Time)
var_dump($user->created_at->humanize()); // 2 months ago
var_dump($user->created_at->format('Y-m-d')); // 2024-02-07
```

### $datamap
カラムとアクセスするプロパティ名を変換する設定です。
例えば現在、ログインには
- 任意の文字列である、login_id
- 任意の文字列である、password
を使っているとします

しかし、途中の仕様変更でlogin_idから、emailに変更されました。

この場合、login_idを使用しているAPIの該当処理を全てemailを利用するように変更しないといけないと思います。
しかし、$datemapに以下のように記述することで、login_idへのアクセスはemailカラムへのアクセスに`マッピング`されます。

これによって、修正範囲をEntityだけ減らすことが出来ます。

:::note warn
便利ではありますが、開発者が意図しない挙動をする可能性もはらんでいるので、利用はよく検討する必要があるかなとは思います。
:::

```php:app/Entities/User.php
protected $datamap = [
  'login_id' => 'email',
];
```

```php
$user = $this->userModel->where('id', 1)->first();
echo $user->login_id; // emailカラムの値が表示される
```

### $casts

既に$castsについては紹介はしていますが、boolean以外にどんなキャストがあるのか紹介します。

:::note info
castはgetter/setter両方で変換する型と、getterのみで変換する型があります。
※下記表で、×になっているものに関しては、setter時にはcast処理は行われません。
:::

| 名前 | getterの処理 | setterの処理 |
|:-:|:-:|:-:|
| int or integer  | `(int)`でキャスト | × |
| float or double  | `(float)`でキャスト  | × |
| string  | `(string)`でキャスト  | × |
| bool or boolean  | `(bool)`でキャスト | × |
| object  | `(object)`でキャスト | × |
| array  | `(array)`でキャスト | `serialize($value)`でシリアライズ |
| int-bool  | `(bool)`でキャスト | `(int)`でキャスト |
| datetime  | `CodeIgniter\I18n\Time`インスタンスを生成 | × |
| timestamp  | `strtotime`でUnixタイムスタンプに変換 | × |
| uri  | `CodeIgniter\HTTP\URI`インスタンスを生成 | × |
| json  | `json_decode($value, false)`でjsonをデコード | `json_encode($value)`でエンコード |
| json-array  | `json_decode($value, true)`でjsonをデコード | `json_encode($value)`でエンコード |
| csv  | `explode(',', $value)`で配列形式に変換 | `implode(',', $value)`で文字列に変換 |

なお、NULL可能なカラムに関しては、`?string`のように定義します。


上記がCodeIgniterで用意されているものになりますが、これだけでは対応できないケースがあるかと思います。その場合は`独自のキャスト`を定義することが可能です。

以下はbase64キャストの例です。
- getterではbase64デコード
- setterではbase64エンコード
しています。

```php:App/Entities/Cast/CastBase64.php
namespace App\Entities\Cast;

use CodeIgniter\Entity\Cast\BaseCast;

class CastBase64 extends BaseCast
{
    public static function get($value, array $params = [])
    {
        return base64_decode($value, true);
    }

    public static function set($value, array $params = [])
    {
        return base64_encode($value);
    }
}
```

作成したキャストは、以下のように$castsに定義するだけで利用可能です。
```php:app/Entities/User.php

<?php

namespace App\Entities;

use CodeIgniter\Entity\Entity;

class User extends Entity
{
    protected $casts = [
        'file' => 'base64',
    ];
}
```

### Special getter/setter

Entityを利用していると一部処理は共通化したいと思ってくると思います。
その場合はEntityクラスを継承した`親Entity`を作って、親Entityを継承した`子Entity`を実装することで共通化することが出来ます。

しかし、例えばUserEntityだけgetter/setterの挙動を変えたい場合など出てきた場合は、どうすればいいのでしょうか？

そこで、Special getter/setterの出番です。
まず、親となるParentEntityを作成します。
```php:app/Entities/ParentEntity.php
<?php

namespace App\Entities;

use CodeIgniter\Entity\Entity;

class ParentEntity extends Entity
{
    protected function getFullname()
    {
        return $this->attributes['last_name'] . ' ' . $this->attributes['middle_name'] . ' ' . $this->attributes['first_name'];
    }
    protected function setPassword()
    {
      $this->attributes['password'] = password_hash($pass, PASSWORD_BCRYPT);
        return $this;
    }
}
```

次に、子となるUserEntityと、ChildEntityを作成します。
メソッド名の先頭に[_]を付けることで、子Entityの処理が実行されるようになります。
```php:app/Entities/UserEntity.php
class User extends ParentEntity
{
    protected function _getPassword()
    {
        return $this->attributes['last_name'] . ' ' . $this->attributes['first_name'];
    }
    protected function _setPassword()
    {
      $this->attributes['password'] = password_hash($pass, PASSWORD_ARGON2I);
        return $this;
    }
}
```
```php:app/Entities/ChildEntity.php
class Child extends ParentEntity
{
}
```

これで、getPasswordの呼び出し時に
- UserEntityでは、定義した`_getPassword`
- ChildEntityでは、`getPassword`
が呼ばれるようになります。

ちなみに、フレームワークの以下のファイルでどちらを処理するか判定しています。
```php:codeigniter4/framework/system/Entity/Entity.php
   public function __get(string $key)
    {
        $dbColumn = $this->mapProperty($key);

        $result = null;

        // Convert to CamelCase for the method
        $method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $dbColumn)));

        // if a getter method exists for this key,
        // use that method to insert this value.
        if (method_exists($this, '_' . $method)) { // 👈子Entityのgetterがあれば、こちらを処理
            // If a "`_get` + $key" method exists, it is a getter.
            $result = $this->{'_' . $method}();
        } elseif (method_exists($this, $method)) {
            // If a "`get` + $key" method exists, it is also a getter.
            $result = $this->{$method}();


            // Otherwise return the protected property
            // if it exists.
        } elseif (array_key_exists($dbColumn, $this->attributes)) {
            $result = $this->attributes[$dbColumn];
        }

        // Do we need to mutate this into a date?
        if (in_array($dbColumn, $this->dates, true)) {
            $result = $this->mutateDate($result);
        }
        // Or cast it as something?
        elseif ($this->_cast) {
            $result = $this->castAs($result, $dbColumn);
        }

        return $result;
    }
```


### toArray()、toRawArray()

`userModel->find(1);`の返り値はEntity形式になるのですが、配列形式で取得したいこともあると思います。その場合は、 toArray()かtoRawArray()を使います。違いとしては、
- toArray()：getterメソッドを通す
- toRawArray()：getterメソッドを通さない

という違いがあります。


### クエリビルダでEntityを利用

クエリビルダーで取得した場合にはResult形式で返されますが、ModelのようにEntity形式で返したいこともあるかもしれません。その場合は、以下のようにすればEntity形式で取得できます。

```php
var_dump($this->builder()->getWhere($where)); // CodeIgniter\Database\MySQLi\Result
var_dump($this->builder()->getWhere($where)->getResult(User:class)); // App\Entities\User
```


### Entitiyの値が変更されたかチェック
Entityのあるプロパティの値が変更されたかチェックしたい場合は、`hasChanged()`を使います。

```php
$user = new \App\Entities\User();
// 特定の属性の変更チェック
echo $user->hasChanged('name'); // false
$user->name = 'Hoge';
echo $user->hasChanged('name'); // true

// Entitiy全体の変更チェック
echo $user->hasChanged(); // true
```

## おまけ
### 各CRUDにおけるEntityを使ったバージョンでの実装

ModelクラスでCRUDを行う場合は、以下のようになります。
```php:app/Models/UserModel.php

<?php

namespace App\Models;

use CodeIgniter\Model;
use App\Entities\User;

class UserModel extends Model
{
    protected $table = 'users';
    protected $primaryKey = 'id';
    protected $useAutoIncrement = true;

    protected $returnType = User::class;

    /**
     * @var array<string>
     */
    protected $allowedFields = [
        'login_id',
        'password',
        'last_name',
        'first_name',
        'created_at',
        'updated_at',
        'deleted_at',
    ];
    protected $protectFields = true;

    // date property
    protected $useTimestamps = true;
    protected $dateFormat = 'datetime';
    protected $createdField = 'created_at';
    protected $updatedField = 'updated_at';
    protected $deletedField = 'deleted_at';
    protected $useSoftDeletes = true;

    /**
     * ユーザ登録
     *
     * @param array $request
     * @return bool
     */
    public function register($request): bool
    {
      $user = new User($request); // 各setterメソッドが呼ばれます
      // 以下のように1つずつ設定することも可能です
      // $user = new User();
      // $user->password = $request['password'];
      // $user->last_name = $request['last_name'];
      // $user->first_name = $request['first_name'];
      $this->insert($user);
    }

    /**
     * ユーザ更新
     *
     * @param int $userId
     * @param array $request
     * @return bool
     */
    public function update($userId, $request): bool
    {
      $user = $this->find($userId);
      $user->fill($request); // 各setterメソッドが呼ばれます
      // fill()で設定せずに、以下のように1つずつ設定することも可能です
      // $user->password = $request['password'];
      // $user->last_name = $request['last_name'];
      // $user->first_name = $request['first_name'];
      $this->update($userId, $user);
    }

    /**
     * ユーザ削除
     *
     * @param int $userId
     * @return bool
     */
    public function delete($userId): bool
    {
      $this->delete(userId);
    }

    /**
     * ユーザ取得
     *
     * @param int $userId
     * @return User|null
     */
    public function detail($userId): ?User
    {
      $this->find($userId);
    }
    
```

:::note info
fill()メソッドでは、Modelの`$allowedFields`に存在するカラムかをチェックしています。
存在しない場合は、登録/更新対象から除外されます。
new User($request)のようにEntityインスタンス作成時にも、fill()メソッドは実行されます。
:::

### $casts、$dates、setter/getterの優先度

$casts、$dates、setter/getterでいろいろな変換処理を行えることがわかりましたが、これらを全て設定している場合、どのように挙動になるのでしょうか？
Entityファイルを見てみましょう。

```php:codeigniter4/framework/system/Entity/Entity.php
    /**
     * Magic method to all protected/private class properties to be
     * easily set, either through a direct access or a
     * `setCamelCasedProperty()` method.
     *
     * Examples:
     *  $this->my_property = $p;
     *  $this->setMyProperty() = $p;
     *
     * @param array|bool|float|int|object|string|null $value
     *
     * @return void
     *
     * @throws Exception
     */
    public function __set(string $key, $value = null)
    {
        $dbColumn = $this->mapProperty($key);

        // Check if the field should be mutated into a date
        if (in_array($dbColumn, $this->dates, true)) {
            $value = $this->mutateDate($value);
        }

        $value = $this->castAs($value, $dbColumn, 'set');

        // if a setter method exists for this key, use that method to
        // insert this value. should be outside $isNullable check,
        // so maybe wants to do sth with null value automatically
        $method = 'set' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $dbColumn)));

        // If a "`_set` + $key" method exists, it is a setter.
        if (method_exists($this, '_' . $method)) {
            $this->{'_' . $method}($value);

            return;
        }

        // If a "`set` + $key" method exists, it is also a setter.
        if (method_exists($this, $method) && $method !== 'setAttributes') {
            $this->{$method}($value);

            return;
        }

        // Otherwise, just the value. This allows for creation of new
        // class properties that are undefined, though they cannot be
        // saved. Useful for grabbing values through joins, assigning
        // relationships, etc.
        $this->attributes[$dbColumn] = $value;
    }

    /**
     * Magic method to allow retrieval of protected and private class properties
     * either by their name, or through a `getCamelCasedProperty()` method.
     *
     * Examples:
     *  $p = $this->my_property
     *  $p = $this->getMyProperty()
     *
     * @return array|bool|float|int|object|string|null
     *
     * @throws Exception
     *
     * @params string $key class property
     */
    public function __get(string $key)
    {
        $dbColumn = $this->mapProperty($key);

        $result = null;

        // Convert to CamelCase for the method
        $method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $dbColumn)));

        // if a getter method exists for this key,
        // use that method to insert this value.
        if (method_exists($this, '_' . $method)) {
            // If a "`_get` + $key" method exists, it is a getter.
            $result = $this->{'_' . $method}();
        } elseif (method_exists($this, $method)) {
            // If a "`get` + $key" method exists, it is also a getter.
            $result = $this->{$method}();


            // Otherwise return the protected property
            // if it exists.
        } elseif (array_key_exists($dbColumn, $this->attributes)) {
            $result = $this->attributes[$dbColumn];
        }

        // Do we need to mutate this into a date?
        if (in_array($dbColumn, $this->dates, true)) {
            $result = $this->mutateDate($result);
        }
        // Or cast it as something?
        elseif ($this->_cast) {
            $result = $this->castAs($result, $dbColumn);
        }

        return $result;
    }
```
setterとgetterで挙動が違うようです。まとめると以下のような順番で処理されます。
- setter
1. $datesに設定されているカラムに対して、`CodeIgniter\I18n\Time`インスタンスへの変換処理を実施
2. $castsに設定されているカラムに対して、変換処理を実施
3. setterメソッド処理
    3-1. Special setterメソッドがあれば、該当のメソッドを実施してreturn
    3-2. setterメソッドがあれば、該当のメソッドを実施してreturn
    3-3. setterメソッドがなければ、何もしない

- getter
1. getterメソッド処理
    1-1. Special getterメソッドがあれば、該当のメソッドを実施
    1-2. getterメソッドがあれば、該当のメソッドを実施
    1-3. getterメソッドがなければ、何もしない
2. $datesに設定されているカラムに対して、`CodeIgniter\I18n\Time`インスタンスへの変換処理を実施
3. $castsに設定されているカラムに対して、変換処理を実施
4. 1～3で変換した値をreturn

## おわりに

CodeIgniterのEntityについて、説明しました。
アウトプットしながら、自身の理解も深められて良かったです！
次は、CodeIgniterのModelクラスに絞って説明していこうと思います:smile: