ハマってしまったので備忘録と皆さまへの注意喚起を兼ねて。
問題設定
Laravel 5.5.35 を使ってDBアクセス。
ここではSQLiteを使っていますが、MySQLとかでも同じだと思います。(多分)
以下のDB設定やテーブルの準備は全部完了しているとします。
app/database.sqlite
にアクセスしてreservations
テーブルのスキーマを確認します。
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)と同等に扱われているはずです)
でもって、データはこんな感じ。
$ 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
namespace App;
use Illuminate\Database\Eloquent\Model;
class Reservation extends Model
{
protected $guarded = ['id'];
protected $dates = ['start_at', 'end_at'];
}
ここで $dates
の配列にカラム名を指定しておくと、そのカラムの値が日付型(正確にはPHPのDateTimeを拡張したCarbonオブジェクト)として取得できます(詳細)。
コントローラ
コントローラはこんな感じ。
今回の問題に関係ない部分は省略。
<?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]);
}
/* 以下略 */
}
対応するルーティングは、例えば以下のように書かれているとします。
Route::resource('reservation', 'ReservationController');
ビュー
<!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
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
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]);
}
/* 以下略 */
}
そうすれば以下のように出力できます。
<!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
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()
などで取得できるみたいです。
つまり、モデルをこんな感じで書きます。
<?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()
の中で呼び出し元クラスのインスタンスが作成されているので、この時にコンストラクタが呼ばれることになりますね。
public static function all($columns = ['*'])
{
return (new static)->newQuery()->get(
is_array($columns) ? $columns : func_get_args()
);
}
これにより、コントローラ側で属性を追加するための記述は一切必要なくなります。
以下のように、toArray()
も使わず、Carbon
も書かず。
<?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()
フリーになれました。
めでたしめでたし。