【Laravel】DBから取得した結果をtoArray()で変換したらハマったメモ

ハマってしまったので備忘録と皆さまへの注意喚起を兼ねて。

# 問題設定

Laravel 5.5.35 を使ってDBアクセス。
ここではSQLiteを使っていますが、MySQLとかでも同じだと思います。（多分）
以下のDB設定やテーブルの準備は全部完了しているとします。

``app/database.sqlite``にアクセスして```reservations```テーブルのスキーマを確認します。

```sql:app/database.sqlite
CREATE TABLE "reservations" (
"id" integer not null primary key autoincrement,
"title" varchar not null,
"start_at" datetime not null,
"end_at" datetime not null,
"created_at" datetime null,
"updated_at" datetime null);
```

（``datetime``という型が出てきていますが、SQLite3に日付型は存在しないので、内部的には文字列（TEXT）と同等に扱われているはずです）

でもって、データはこんな感じ。

```:terminal
$ sqlite3 database/database.sqlite "SELECT * FROM reservations;"
9|田村ゆかりの乙女心♡症候群|2018-03-08 21:30:00|2018-03-08 22:00:00|2018-03-01 21:00:00|2018-03-01 21:00:00
10|堀江由衣の天使のたまご|2018-03-10 02:00:00|2018-03-10 02:30:00|2018-03-01 21:50:00|2018-03-01 21:50:00
11|水瀬いのり MELODY FLAG|2018-03-11 22:00:00|2018-03-11 22:30:00|2018-03-01 21:55:00|2018-03-01 21:55:00
```

# 使ってみる

## モデル

上のテーブルに対して、以下のような``Reservation``というモデルを作ってアクセスしようとしています。

```php:app/Reservation.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Reservation extends Model
{
    protected $guarded = ['id'];
    protected $dates = ['start_at', 'end_at'];
}
```

ここで ``$dates`` の配列にカラム名を指定しておくと、そのカラムの値が日付型（正確にはPHPのDateTimeを拡張したCarbonオブジェクト）として取得できます（[詳細](https://laravel.com/docs/5.5/eloquent-mutators#date-mutators)）。

## コントローラ

コントローラはこんな感じ。
今回の問題に関係ない部分は省略。

```php:app/Http/Controllers/ReservationController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Reservation;

class ReservationController extends Controller
{
    public function index()
    {
        $reservations = Reservation::all()->toArray();
        return view('test', ['reservations' => $reservations]);
    }

    /* 以下略 */
}
```

対応するルーティングは、例えば以下のように書かれているとします。

```php:routes/web.php
Route::resource('reservation', 'ReservationController');
```

## ビュー

```html:resources/views/test.blade.php
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>test</title>
  </head>
  <body>
    <ul>
    @foreach ($reservations as $res)
      <li>
        {{ $res['title'] }} : {{ $res['start_at']->format('m/d H:i') }}-{{ $res['end_at']->format('m/d H:i') }}
      </li>
    @endforeach
    </ul>
  </body>
</html>
```

# 実行したらハマった

ビューのforeach内で ``Call to a member function format() on string`` と怒られます。
``$res['start_at']``や``$res['end_at']``はCarbonオブジェクトに変換されるものと思って、formatメソッドで日時のフォーマット変換を試みたのですが、``on string``とエラーに出ているところを見ると、Carbonオブジェクトへの変換が効いていない？

テツandトモばりに「なんでだろう～」と唱えながら、同じように型変換を行ってくれる``$casts``も試したのですが、結果は同じ。

```php:app/Reservation.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Reservation extends Model
{
    protected $guarded = ['id'];
    protected $casts = [
        'start_at' => 'datetime',
        'end_at' => 'datetime'
    ];
}
```

# 原因

``toArray()``が元凶でした。
直接``all()``の戻り値を処理すれば、``$dates``や``$casts``の設定がちゃんと効きます。

```php:app/Http/Controllers/ReservationController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Reservation;

class ReservationController extends Controller
{
    public function index()
    {
        $reservations = Reservation::all(); // <= ココ！
        return view('test', ['reservations' => $reservations]);
    }

    /* 以下略 */
}
```

そうすれば以下のように出力できます。

```html:result
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>test</title>
  </head>
  <body>
    <ul>
          <li>
        田村ゆかりの乙女心♡症候群 : 03/08 21:30-03/08 22:00
      </li>
          <li>
        堀江由衣の天使のたまご : 03/10 02:00-03/10 02:30
      </li>
          <li>
        水瀬いのり MELODY FLAG : 03/11 22:00-03/11 22:30
      </li>
        </ul>
  </body>
</html>
```

仕様なのかバグなのか分かりませんが…。

# そもそもなんでtoArray()したのよお前

ここからは本題からちょっと逸れますが、じゃなんでお前はわざわざ``toArray()``なんて使っていたのかという疑問が起こると思います。

例えば、``start_at``と``end_at``の値を見て、現在時刻が範囲内に入っていれば「Now On Air」と表示する処理を付け加えようとしました。
ビューにその判定ロジックを入れたくなかったので、ビューに渡す前にコントローラ側で``now_on_air``という属性を付け加えようとしたのですが、``all()``の結果はコレクション（foreachできるけどarrayではない）ので、属性を付け加えたかったらarrayにしないといけません。

つまり、コントローラをこんな感じに作りたかったのです。

```php:app/Http/Controllers/ReservationController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Reservation;
use Carbon\Carbon; // <= 1行追加

class ReservationController extends Controller
{
    public function index()
    {
        $reservations = Reservation::all()->toArray(); // toArray()を外すとnow_on_airに想定した結果が入らない
        $now = Carbon::now();
        foreach ($reservations as &$res) {
            $res['now_on_air'] = ($res['start_at'] <= $now && $now <= $res['end_at']);
        }
        unset($res);
        return view('test', ['reservations' => $reservations]);
    }

    /* 以下略 */
}
```

つまり

- ``toArray()``を使わないと、``now_on_air``の値がうまく設定できない
- ``toArray()``を使うと、``start_at``, ``end_at``が日付型に変換されない

というジレンマに陥ってしまいました。
``toArray()``を使わずに結果を取得して、foreach内で全属性を列挙してarrayを作り直すこともできますが、正直やりたくない。

# モデル側でDBにない属性を追加する

なんとモデル側に記述を追加すると、DBにない属性をDBから取得したカラムの値から生成できるようです。
モデル側で ``$appends`` と、対応する各accessorを定義します。
追加した属性は、他の属性と一緒に ``all()`` などで取得できるみたいです。

https://laravel.com/docs/5.5/eloquent-serialization#appending-values-to-json

つまり、モデルをこんな感じで書きます。

```php:app/Reservation.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon; // 追加

class Reservation extends Model
{
    protected $guarded = ['id'];
    protected $dates = ['start_at', 'end_at'];

    /* これ以下を追加 */
    protected $appends = ['now_on_air'];
    private $now;

    public function __construct(array $attributes = array())
    {
        parent::__construct($attributes);
        $this->now = Carbon::now();
    }

    public function getNowOnAirAttribute()
    {
        return $this->start_at <= $this->now && $this->now <= $this->end_at;
    }
}
```

コンストラクタを定義していますが、これは``Carbon::now()``の呼び出しを最初の1回だけで済ませたかったためです。
``start_at``も``end_at``も同じ時刻なのに、あるレコードはNow On Airで、他のレコードはそうではない、という事態を避けたかったので。

ちなみに、``Reservation::all()``はスタティックメソッドの呼び出しなのに、なんでコンストラクタが出てくるのかという話ですが、``all()``の中で呼び出し元クラスのインスタンスが作成されているので、この時にコンストラクタが呼ばれることになりますね。

```php:vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php
    public static function all($columns = ['*'])
    {
        return (new static)->newQuery()->get(
            is_array($columns) ? $columns : func_get_args()
        );
    }
```

https://qiita.com/armorik83/items/9fc7cfd59abc05bab49a

これにより、コントローラ側で属性を追加するための記述は一切必要なくなります。
以下のように、``toArray()``も使わず、``Carbon``も書かず。

```php:app/Http/Controllers/ReservationController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Reservation;

class ReservationController extends Controller
{
    public function index()
    {
        $reservations = Reservation::all();
        return view('test', ['reservations' => $reservations]);
    }

    /* 以下略 */
```

というわけで、（私の場合は）これで晴れて``toArray()``フリーになれました。
めでたしめでたし。