Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

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

問題設定

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

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

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というモデルを作ってアクセスしようとしています。

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オブジェクト)として取得できます(詳細)。

コントローラ

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

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]);
    }

    /* 以下略 */
}

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

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

ビュー

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も試したのですが、結果は同じ。

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の設定がちゃんと効きます。

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]);
    }

    /* 以下略 */
}

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

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

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

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

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

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_atend_atも同じ時刻なのに、あるレコードはNow On Airで、他のレコードはそうではない、という事態を避けたかったので。

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

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も書かず。

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()フリーになれました。
めでたしめでたし。

everylittle
PythonやWebプログラミングなどのTipsをメモ代わりに投稿しています。たまに機械学習の話題もあります。
https://www.every-little.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away