まいける's Tech Blog

LAMP関係のメモなどを不定期に掲載します

既存の Laravel プロジェクトに Closure Table を導入する

最初からツリー構造のデータを扱うことがわかっていれば、 franzose/closure-table を導入して、こちらの記事(
Laravel|Closure Tableで階層の深さが動的なカテゴリ構造を扱う - わくわくBank )などを参考にしながら設定すればよいのですが、後からコメントを階層化したいみたいなオーダーが出てきたときにどうすればよいか、というのが今回の記事のお話です。

1. 必要なライブラリをインストール

$ composer require franzose/closure-table

2. マイグレーションファイルの作成

Closure Table 用のテーブルを作成するとともに、元々のモデル用のテーブルに必要なカラムを追加します
(以下では、 comments テーブルをツリー化することを想定しています)

Closure Table 用のテーブルを作成

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateCommentClosuresTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('comment_closures', function (Blueprint $table) {
            $table->increments('closure_id');
            $table->integer('ancestor', false, true);
            $table->integer('descendant', false, true);
            $table->integer('depth', false, true);

            $table->foreign('ancestor')
                ->references('id')
                ->on('comments')
                ->onDelete('cascade');

            $table->foreign('descendant')
                ->references('id')
                ->on('comments')
                ->onDelete('cascade');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('comment_closures');
    }
}

既存の comments テーブルにカラムを追加

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class AddClosureColumnsOnComments extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('comments', function (Blueprint $table) {
            $table->integer('parent_id')->unsigned()->nullable();
            $table->integer('position', false, true);
            $table->integer('real_depth', false, true);
            $table->foreign('parent_id')
                ->references('id')
                ->on('comments')
                ->onDelete('set null');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('comments', function (Blueprint $table) {
            $table->dropColumn('parent_id');
            $table->dropColumn('position');
            $table->dropColumn('real_depth');
        });
    }
}

3. モデルの作成

既存のモデル(Comment)を修正するほか、Closure Table 用のモデルを 3 つ追加します

Comment.php の修正

<?php
namespace App;

use Franzose\ClosureTable\Models\Entity;

class Comment extends Entity implements CommentInterface
{
    /**
     * The table associated with the model.
     *
     * @var string
     */
    protected $table = 'comments';

    /**
     * ClosureTable model instance.
     *
     * @var CommentClosure
     */
    protected $closure = 'App\CommentClosure';

    //複数代入を許可する項目
    protected $fillable = ['name', 'content'];

    (以下略)
}

use を追加するのと、$closure を追加するのが主な変更ですが、1点注意しなければならないのが複数代入の設定です。
ご存知のように、モデルには fillable か guarded のどちらかの属性を設定する必要がありますが
Closure Table を利用するためには、fillable で設定する必要があります。
というのも、Closure Table で利用するカラムに値を追加できるように

$this->fillable(array_merge($this->getFillable(), [$position, $depth]));

という処理を行っているためです。新規作成の場合にはこのあたり気にする必要はありませんが、既存のモデルを使う場合、注意が必要です。

CommentClosure.php の作成

<?php

namespace App;

use Franzose\ClosureTable\Models\ClosureTable;

class CommentClosure extends ClosureTable implements CommentClosureInterface
{
    /**
     * The table associated with the model.
     *
     * @var string
     */
    protected $table = 'comment_closures';
}

CommentClosureInterface.php の作成

<?php

namespace App;

use Franzose\ClosureTable\Contracts\ClosureTableInterface;

interface CommentClosureInterface extends ClosureTableInterface
{
}

CommentInterface.php の作成

<?php
namespace App;

use Franzose\ClosureTable\Contracts\EntityInterface;

interface CommentInterface extends EntityInterface
{
}

4. 初期データの投入

新規作成の場合は必要ないのですが、既存データを利用する場合、comments.position に値を入れるとともに、comment_closures テーブルに初期データを投入する必要があります。
comments.position は同じ階層にあるデータの並び順を表すデータで、他のデータと重複しないように設定する必要があります。
また、comment_closure テーブルのほうには、いったんすべてのデータが最上位の階層に並んでいるようなデータを投入します。
手っ取り早いのは、プライマリキーに設定されている comments.id を使う方法で

update comments set position = id;
insert into comment_closures (`ancestor`, `descendant`, depth) select id, id, 0 from comments;

こんな感じで設定しておけば大丈夫です

これで Closure Table が使えるようになると思います。それ専用に作られているだけあって、データの操作や取得はかなり便利に行うことができます。お試しください!

Laravel のセッションを非 Laravel のプロジェクトと共有する

基本的な手順は kojirockさんのこちらの記事 を参考にさせていただきました。ありがとうございます。

1. 必要なライブラリをインストール

$ composer require illuminate/encryption

2. Laravel用sessionテーブル作成

3. Laravel側session設定

Laravel側の設定なので、このあたりを参照してください

4. Laravel側session設定を共有したいプロジェクトで定数として設定

例えばこんな感じ

<?php
define("LARAVEL_APP_KEY", "base64:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
define("SESSION_NAME", "laravel_session");
define("SESSION_TIME", time() + (120 * 60));
define("SESSION_PATH", "/");
define("SESSION_DOMAIN", null);
define("SESSION_SECURE", false);
define("SESSION_HTTP_ONLY", true);
define("LARAVEL_CIPHER", "AES-256-CBC");

define("DBNAME", "dbname");
define("DBHOST", "127.0.0.1");
define("DBUSER", "hoge");
define("DBPASSWORD", "fuga");

5. SessionHandlerを作成

kojirock さんの記事を参考にしつつ、session_regenerate_id に対応できるように create_sid メソッドを追加しています

<?php
namespace MyApp\Session;

use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Str;

class AppSessionHandler implements \SessionHandlerInterface
{
    /**
     * @var string
     */
    protected $decryptSessionId;

    /**
     * @var \PDO
     */
    protected $pdo;

    /**
     * AppSessionHandler constructor.
     * @param string $decryptSessionId
     * @param \PDO $pdo
     */
    public function __construct($decryptSessionId, \PDO $pdo)
    {
        $this->decryptSessionId = $decryptSessionId;
        $this->pdo = $pdo;
    }

    /**
     * @param string $savePath
     * @param string $name
     * @return bool
     */
    public function open($savePath, $name)
    {
        return true;
    }

    /**
     * @param string $sessionId
     * @return string
     */
    public function read($sessionId)
    {
        $stmt = $this->pdo->prepare("SELECT payload FROM sessions WHERE id = :id");
        $stmt->bindValue(":id", $this->decryptSessionId);
        $stmt->execute();
        $results = $stmt->fetchColumn();
        $stmt->closeCursor();
        return is_null($results) ? '' : base64_decode($results);
    }

    /**
     * @param string $sessionId
     * @param string $sessionData
     * @return bool
     */
    public function write($sessionId, $sessionData)
    {
        $sql = <<<SQL
INSERT INTO sessions (id, ip_address, user_agent, payload, last_activity)
VALUES(:id, :ip_address, :user_agent, :payload, :last_activity)
ON DUPLICATE KEY UPDATE last_activity = :last_activity, payload = :payload
SQL;

        if ($this->isEncryptSessionId($sessionId)) {
            $sessionId = $this->decryptSessionId($sessionId);
        }

        $payload = $this->getSavePayload($sessionId, $sessionData);
        $stmt = $this->pdo->prepare($sql);
        $stmt->bindValue(":id", $sessionId);
        $stmt->bindValue(":ip_address", $_SERVER['REMOTE_ADDR']);
        $stmt->bindValue(":user_agent", $_SERVER['HTTP_USER_AGENT']);
        $stmt->bindValue(":payload", base64_encode(serialize($payload)));
        $stmt->bindValue(":last_activity", time());
        $stmt->execute();
        $results = $stmt->rowCount();
        $stmt->closeCursor();
        return ($results) ? true : false;
    }

    /**
     * @param string $sessionId
     * @return bool
     */
    public function destroy($sessionId)
    {
        $stmt = $this->pdo->prepare("DELETE FROM sessions WHERE id = :id");
        $stmt->bindValue(":id", $this->decryptSessionId);
        $stmt->execute();
        $stmt->closeCursor();
        return true;
    }

    /**
     * @return bool
     */
    public function close()
    {
        return true;
    }

    /**
     * @param int $maxlifetime
     * @return bool
     */
    public function gc($maxlifetime)
    {
        $stmt = $this->pdo->prepare("DELETE FROM sessions WHERE last_activity < :lastActivity");
        $stmt->bindValue(":lastActivity", $maxlifetime);
        $stmt->execute();
        $stmt->closeCursor();
        return true;
    }

    /**
     * 新しい sessionIDの作成
     * @return string
     */
    public function create_sid()
    {
        $sessionId = Str::random(40);
        return $sessionId;
    }

    /**
     * @param string $sessionId
     * @param string $sessionData
     * @return array
     */
    protected function getSavePayload($sessionId, $sessionData)
    {
        // DB から保存されているデータを取得
        $registeredPayload = $this->read($sessionId);
        if (strlen($registeredPayload) >= 1) {
            $registeredPayload = unserialize($registeredPayload);
        } else {
            // 保存されているデータがなければ初期化
            $registeredPayload = $this->initPayload();
        }

        if (strlen($sessionData) >= 1) {
            // セッションとして登録したいデータがあれば上書き
            $payload = unserialize($sessionData);
        } else {
            // セッションとして登録したいデータがなければデータを保持
            $payload = $registeredPayload;
        }
        $payload['_previous'] = ['url' => $_SERVER['HTTP_REFERER']];

        return $payload;
    }

    /**
     * payloadの初期化
     * @return array
     */
    protected function initPayload()
    {
        return $payload = [
            '_token' => Str::random(40),
            '_flash' => ['old' => [], 'new' => []]
        ];
    }

    /**
     * 暗号化された sessionID かどうかの判別
     * @param $sessionId
     * @return bool
     */
    protected function isEncryptSessionId($sessionId)
    {
        if (is_string($sessionId) && ctype_alnum($sessionId) && strlen($sessionId) === 40) {
            return false;
        } else {
            return true;
        }
    }

    /**
     * 暗号化された sessionID の復号
     * @param $sessionId
     * @return string
     */
    protected function decryptSessionId($sessionId)
    {
        $encryptValue = base64_decode(substr(LARAVEL_APP_KEY, 7));
        $encrypter = new Encrypter($encryptValue, LARAVEL_CIPHER);

        $decryptSessionId = $encrypter->decrypt($sessionId);
        return $decryptSessionId;
    }

}

6. カスタムセッションハンドラの設定

4. で設定した定数を /var/www/config.php に、5. で設定したSessionHandlerを /var/www/class_session_handler.php に置いていたとして

<?php
require_once '/var/www/config.php';
require_once '/var/www/class_session_handler.php';

use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Str;
use MyApp\Session;

ini_set('session.serialize_handler', 'php_serialize');

$encryptValue = base64_decode(substr(LARAVEL_APP_KEY, 7));
$encrypter = new Encrypter($encryptValue, LARAVEL_CIPHER);

if (!isset($_COOKIE[SESSION_NAME])) {
    $sessionId = Str::random(40);
    $encryptSessionId = $encrypter->encrypt($sessionId);
    setcookie(SESSION_NAME, $encryptSessionId, SESSION_TIME, SESSION_PATH, SESSION_DOMAIN, SESSION_SECURE,
        SESSION_HTTP_ONLY);
} else {
    if (is_string($_COOKIE[SESSION_NAME]) && ctype_alnum($_COOKIE[SESSION_NAME]) && strlen($_COOKIE[SESSION_NAME]) === 40) {
        // regenerate された session_id のときは session_id を暗号化して再格納
        $encryptSessionId = $encrypter->encrypt($_COOKIE[SESSION_NAME]);
        setcookie(SESSION_NAME, $encryptSessionId, SESSION_TIME, SESSION_PATH, SESSION_DOMAIN, SESSION_SECURE,
            SESSION_HTTP_ONLY);
        $decryptSessionId = $_COOKIE[SESSION_NAME];
    } else {
        $decryptSessionId = $encrypter->decrypt($_COOKIE[SESSION_NAME]);
    }
    $dsn = 'mysql:host=' . DBHOST . ';dbname=' . DBNAME;

    $pdo = new PDO($dsn, DBUSER, DBPASSWORD);
    $sessionHandler = new Session\AppSessionHandler($decryptSessionId, $pdo);
    session_name(SESSION_NAME);
    session_set_save_handler($sessionHandler, true);
}

このあとに session_start() を呼び出せば、Laravel と非 Laravel の双方で読み書きできるセッションが作れると思います

Laravel クエリビルダの whereRaw メソッドでプレースホルダを使う

Laravel 5.5 のドキュメントには記載があるのに(日本語版英語版)、それ以前のドキュメントには、なぜか記載がないのですが、クエリビルダの whereRaw メソッドでプレースホルダを使うときは、名前付きプレースホルダではなく、疑問符プレースホルダを使う必要があります。

$orders = DB::table('orders')
    ->whereRaw('price > IF(state = "TX", ?, 100)', [200])
    ->get();

名前付きプレースホルダを使うと、以下のエラーが出ます

Uncaught exception 'PDOException' with message 'SQLSTATE[HY093]: Invalid parameter number: mixed named and positional parameters'