HHeLiBeXの日記 正道編

日々の記憶の記録とメモ‥

カスタムダイアログを表示する

はじめに

ちょっとお仕事で必要が生じて、window.confirm()ではまかない切れないカスタムダイアログを出さなければならなくなったので、その検証メモ。

今回は、Laravelを使って組んでいく。

なお、実行環境は以下だが、構築の詳細は割愛。

下準備

まずはLaravelのプロジェクトを作る。

cd /var/www/html
composer create-project laravel/laravel:11.* sample10
chmod -R a+w  sample10/storage/
chmod a+w  sample10/bootstrap/cache

お仕事がLaravel 11なので、バージョンとしてLaravel 11系を指定しているが、最新のLaravel 12系でも問題なく動くと思う。

ファイルの作成・編集

今回は、ラジオボタン2つでの選択肢があり、OK、キャンセルの2つのボタンがあるダイアログを作る。

sample10/resources/views/sample/index.blade.php

<!DOCTYPE html>
<html lang="ja">

<head>
    <base href="/sample10/public/" />
    <meta charset="UTF-8" />
    <title>カスタムダイアログ</title>
    <link rel="stylesheet" type="text/css" href="sample.css" />
    <script type="text/javascript" src="sample.js"></script>
</head>

<body>
    <button id="openDialogButton">確認ダイアログを開く</button>

    <dialog id="customDialog">
        <h2>削除しますか?</h2>
        <p>選択したアイテムを、このカテゴリからのみ削除しますか?</p>
        <p>または、データベースから完全に削除しますか?</p>
        <ul>
            <li>
                <label>
                    <input id="removeFromCategory"
                        type="radio" name="deleteMode" value="1" />
                    このカテゴリから削除
                </label>
            </li>
            <li>
                <label>
                    <input id="deleteFromDatabase"
                        type="radio" name="deleteMode" value="1" />
                    データベースから完全に削除
                </label>
            </li>
        </ul>
        <div class="dialog-buttons">
            <button id="yesButton">はい</button>
            <button id="cancelButton">キャンセル</button>
        </div>
    </dialog>

    <script type="text/javascript">
        initDialog();
    </script>
</body>

</html>

sample10/public/sample.css

dialog {
    padding: 2em;
    border: solid 1px #ccc;
    border-radius: 8px;
    max-width: 400px;
}

dialog::backdrop {
    background-color: rgba(0, 0, 0, 0.5);
}

.dialog-buttons {
    margin-top: 1.5em;
    display: flex;
    justify-content: flex-end;
    gap: 1em;
}

sample10/public/sample.js

function initDialog() {
    const openDialogButton = document.getElementById("openDialogButton");
    const customDialog = document.getElementById("customDialog");
    const yesButton = document.getElementById("yesButton");
    const cancelButton = document.getElementById("cancelButton");
    const radioRemoveFromCategory =
        document.getElementById("removeFromCategory");
    const radioDeleteFromDatabase =
        document.getElementById("deleteFromDatabase");

    openDialogButton.addEventListener("click", () => {
        // 前回の選択結果が残るので、ラジオボタンの選択状態を毎回初期化
        radioRemoveFromCategory.checked = true;
        customDialog.showModal(); // モーダルダイアログとして表示
    });

    yesButton.addEventListener("click", () => {
        let modeText;
        if (radioRemoveFromCategory.checked) {
            modeText = "カテゴリからのみ削除";
        } else if (radioDeleteFromDatabase.checked) {
            modeText = "データベースから完全に削除";
        } else {
            modeText = "N/A";
        }
        // 「はい」が選択されたときの処理
        alert("「はい」が選択されました。(" + modeText + ")");
        customDialog.close();
    });

    cancelButton.addEventListener("click", () => {
        // 「キャンセル」が選択されたときの処理
        alert("「キャンセル」が選択されました。");
        customDialog.close();
    });
}

sample10/routes/web.php

<?php

use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/sample', function () {
    return view('sample.index');
});

動作確認

以下のURLにアクセスして動作確認。

http://<your-ip-address>/sample10/public/sample

  • ラジオボタン「このカテゴリから削除」を選んで「はい」ボタンを押すと、「「はい」が選択されました。(カテゴリからのみ削除)」が表示される
  • ラジオボタン「データベースから完全に削除」を選んで「はい」ボタンを押すと、「「はい」が選択されました。(データベースから完全に削除)」が表示される
  • ラジオボタンは任意の選択状態で「キャンセル」ボタンを押すと、「「キャンセル」が選択されました。」が表示される

辺りが確認できれば完成。

参考

Geminiを頼った。

share.google

tableの行をドラッグ&ドロップで入れ替える

はじめに

ちょっとお仕事で必要が生じて、HTMLテーブルの行をドラッグ&ドロップで入れ替える機能を実装することになったので、その検証メモ。 お仕事では、ドラッグ&ドロップによる行入れ替えのほかに、ドラッグ&ドロップでの行選択の機能も混じるので、動的にdraggable属性の値を切り替える必要があるので、簡単な行選択のロジックも組み込む。 また、入れ替えた行の順番をサーバー側で保存するためのロジックも記述していく。

今回は、Laravelを使って組んでいく。

なお、実行環境は以下だが、構築の詳細は割愛。

下準備

まずはLaravelのプロジェクトを作る。

cd /var/www/html
composer create-project laravel/laravel:11.* sample09
chmod -R a+w  sample09/storage/
chmod a+w  sample09/bootstrap/cache

お仕事がLaravel 11なので、バージョンとしてLaravel 11系を指定しているが、最新のLaravel 12系でも問題なく動くと思う。

ファイルの作成・編集

sample09/resources/views/sample/index.blade.php

<!DOCTYPE html>
<html lang="ja">

<head>
    <base href="/sample09/public/" />
    <meta charset="UTF-8" />
    <title>ドラッグ&ドロップによるテーブル行の順序入れ替え</title>
    <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
    <link rel="stylesheet" type="text/css" href="sample.css" />
    <script type="text/javascript" src="sample.js"></script>
</head>

<body>
    <table class="sorting-by-drag-table">
        <thead>
            <tr>
                <th>メールアドレス</th>
                <th></th>
                <th></th>
            </tr>
        </thead>
        <tbody>
            @foreach ($persons as $person)
            <tr data-id="{{$person->id}}">
                <td>{{$person->mailAddress}}</td>
                <th>{{$person->lastName}}</th>
                <th>{{$person->firstName}}</th>
            </tr>
            @endforeach
        </tbody>
    </table>

    <script type="text/javascript">
        initSortingByDrag('.sorting-by-drag-table');
    </script>
</body>

</html>

sample09/app/Http/Controllers/SampleController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Session;
use stdClass;

class SampleController extends Controller
{
    public function index()
    {
        $persons = Session::get('persons');
        if (!$persons) {
            $persons = [];
            for ($i = 1; $i <= 20; ++$i) {
                $person = new stdClass();
                $person->id = $i + 1000;
                $person->mailAddress = 'メールアドレス' . mb_convert_kana($i, 'N');
                $person->lastName = '姓' . mb_convert_kana($i, 'N');
                $person->firstName = '名' . mb_convert_kana($i, 'N');
                $person->showOrder = $i;
                $persons[$person->id] = $person;
            }

            Session::put('persons', $persons);
        }

        // showOrderでソート
        $viewPersons = [];
        foreach ($persons as $person) {
            $viewPersons[$person->showOrder] = $person;
        }
        ksort($viewPersons);

        return view(
            'sample.index',
            [
                'persons' => $viewPersons,
            ]
        );
    }
    public function insertBefore(Request $request)
    {
        $rowIds = $request->get('row_ids');
        $targetId = $request->get('target_id');

        $this->updateShowOrder($rowIds, $targetId, true);
    }
    public function insertAfter(Request $request)
    {
        $rowIds = $request->get('row_ids');
        $targetId = $request->get('target_id');

        $this->updateShowOrder($rowIds, $targetId, false);
    }

    private function updateShowOrder($rowIds, $targetId, $before)
    {
        /*
         * target のデータを取得する
         * target の showOrder を変数 targetShowOrder に格納する
         * selectedRows のデータをすべて取得する
         * selectedRows の showOrder の min, max を求めて変数に格納する
         * targetShowOrder と min/max の間のデータをすべて取得する
         * 取得したデータを selected と notSelected に分ける
         * after の場合は逆順に処理するので、順番を入れ替えておく
         * targetShowOrder を別の変数 currentShowOrder に格納する
         * selected のデータを順に処理して showOrder を currentShowOrder++/currentShowOrder-- に更新する
         * notSelected のデータを順に処理して showOrder を currentShowOrder++/currentShowOrder-- に更新する
         */
        $persons = Session::get('persons');
        // target のデータを取得する
        if (!isset($persons[$targetId])) {
            echo json_encode(['status', false, 'errorMessage' => 'target_id:指定されたIDのデータが見つかりません。']);
            return;
        }
        $target = $persons[$targetId];
        // target の showOrder を変数 targetShowOrder に格納する
        $targetShowOrder = $target->showOrder;
        // selectedRows のデータをすべて取得する
        $selectedRows = []; // SQLなら、IN句で一発で取得できる
        foreach ($rowIds as $rowId) {
            if (!isset($persons[$rowId])) {
                echo json_encode(['status', false, 'errorMessage' => 'row_id:指定されたIDのデータが見つかりません。']);
                return;
            }
            $selectedRows[] = $persons[$rowId];
        }
        // selectedRows の showOrder の min, max を求めて変数に格納する
        $min = 0x7fffffffffffffff;
        $max = -1;
        foreach ($selectedRows as $row) {
            $min = min($min, $row->showOrder);
            $max = max($max, $row->showOrder);
        }
        // targetShowOrder と min/max の間のデータをすべて取得する
        //   ※サンプルではループを回して全件チェックする必要があるが、実際にはDBにrangeクエリーを出せばよい
        $editRows = [];
        foreach ($persons as $person) {
            if ($before) {
                if ($targetShowOrder <= $person->showOrder && $person->showOrder <= $max) {
                    $editRows[] = $person;
                }
            } else {
                if ($min <= $person->showOrder && $person->showOrder <= $targetShowOrder) {
                    $editRows[] = $person;
                }
            }
        }
        // 取得したデータを selected と notSelected に分ける
        $selected = [];
        $notSelected = [];
        foreach ($editRows as $editRow) {
            if (in_array($editRow->id, $rowIds)) {
                $selected[] = $editRow;
            } else {
                $notSelected[] = $editRow;
            }
        }
        // after の場合は逆順に処理するので、順番を入れ替えておく
        if (!$before) {
            $selected = array_reverse($selected);
            $notSelected = array_reverse($notSelected);
        }
        // targetShowOrder を別の変数 currentShowOrder に格納する
        $currentShowOrder = $targetShowOrder;
        // selected のデータを順に処理して showOrder を currentShowOrder++/currentShowOrder-- に更新する
        foreach ($selected as $row) {
            if ($before) {
                $row->showOrder = $currentShowOrder++;
            } else {
                $row->showOrder = $currentShowOrder--;
            }
        }
        // notSelected のデータを順に処理して showOrder を currentShowOrder++/currentShowOrder-- に更新する
        foreach ($notSelected as $row) {
            if ($before) {
                $row->showOrder = $currentShowOrder++;
            } else {
                $row->showOrder = $currentShowOrder--;
            }
        }
        // オーバーフロー/アンダーフローチェック
        if ($before) {
            if ($currentShowOrder > $max + 1) {
                echo json_encode(['status' => false, 'errorMessage' => 'ShowOrderがオーバーフローしました。']);
                return;
            }
        } else {
            if ($currentShowOrder < $min - 1) {
                echo json_encode(['status' => false, 'errorMessage' => 'ShowOrderがアンダーフローしました。']);
                return;
            }
        }
        // showOrderで並べ替えてからセッションに書き込み
        //   ※DBを使っている場合はORDER BY句を指定して取得すればいいので不要な処理
        $tmpPersons = [];
        foreach ($persons as $person) {
            $tmpPersons[$person->showOrder] = $person;
        }
        ksort($tmpPersons);
        $persons = [];
        foreach ($tmpPersons as $tmpPerson) {
            $persons[$tmpPerson->id] = $tmpPerson;
        }
        Session::put('persons', $persons);

        echo json_encode(['status' => true]);
    }
}

sample09/public/sample.css

table {
    border-collapse: collapse;
    outline: none;
}
th,
td {
    border: 1px solid #ccc;
}

.dragging {
    opacity: 0.5; /* ドラッグ中の行の透明度を調整 */
}

.drop-placeholder {
    border-top: 2px dashed #000; /* ドロップ位置の目印 */
}

.selected {
    background-color: #d0d8ff; /* 選択時の背景色 */
    border-left: 3px solid #007bff; /* 選択時の左ボーダー */
}

sample09/public/sample.js

function initSortingByDrag(selector) {
    const table = document.querySelector(selector);
    const rows = table.querySelectorAll("tbody > tr");

    let draggingRows = null; // ドラッグ中の行を保持する
    let min;
    let max;

    // 各行にイベントリスナーを設定
    rows.forEach((row) => {
        row.addEventListener("dragstart", handleDragStart);
        row.addEventListener("dragenter", handleDragEnter);
        row.addEventListener("dragover", handleDragOver);
        row.addEventListener("dragleave", handleDragLeave);
        row.addEventListener("drop", handleDrop);
        row.addEventListener("dragend", handleDragEnd);

        row.addEventListener("click", handleClick);
    });

    // ドラッグ開始時の処理
    function handleDragStart(ev) {
        draggingRows = table.querySelectorAll(".selected");
        ev.dataTransfer.effectAllowed = "move";
        ev.dataTransfer.setData("text/html", this.innerHTML);

        // ドラッグ中の行の間にドロップ先の行が含まれないことを確認
        min = 9223372036854775807;
        max = -1;
        draggingRows.forEach((draggingRow) => {
            if (min > draggingRow.rowIndex) {
                min = draggingRow.rowIndex;
            }
            if (max < draggingRow.rowIndex) {
                max = draggingRow.rowIndex;
            }
        });

        // ドラッグ中の行にCSSクラスを追加してスタイルを適用
        setTimeout(() => {
            draggingRows.forEach((row) => {
                row.classList.add("dragging");
            });
        }, 0);
    }

    // ドラッグ中の要素が他の要素の上に侵入した時の処理
    function handleDragEnter(ev) {
        ev.preventDefault();
        this.classList.add("drop-placeholder");
    }

    // ドラッグ中の要素が他の要素の上にある時の処理
    function handleDragOver(ev) {
        // ドロップ先の行を取得
        let targetRow = this;

        if (targetRow.rowIndex < min || max < targetRow.rowIndex) {
            ev.preventDefault(); // デフォルトの動作をキャンセルして、ドロップを許可する
            ev.dataTransfer.dropEffect = "move";
        }
    }

    // ドラッグ中の要素が他の要素から離れた時の処理
    function handleDragLeave(ev) {
        this.classList.remove("drop-placeholder");
    }

    // ドロップ時の処理
    function handleDrop(ev) {
        ev.preventDefault();

        // ドロップ先の行を取得
        let targetRow = this;

        // ドラッグ中の行の間にドロップ先の行が含まれないことを確認
        if (targetRow.rowIndex < min || max < targetRow.rowIndex) {
            const parent = targetRow.parentNode;

            const rowIds = []; // ドラッグされている行のデータID
            const targetId = targetRow.dataset.id;

            // ドロップ位置を判定し、要素を入れ替える
            if (targetRow.rowIndex < min) {
                // ドラッグしている行よりも上の行にドロップした場合
                draggingRows.forEach((draggingRow) => {
                    parent.insertBefore(draggingRow, targetRow);
                    rowIds.push(draggingRow.dataset.id);
                });

                console.log(rowIds + " is/are inserted before " + targetId);
                save("sample/insert-before", rowIds, targetId);
            } else {
                // ドラッグしている行よりも下の行にドロップした場合
                draggingRows.forEach((draggingRow) => {
                    parent.insertBefore(draggingRow, targetRow.nextSibling);
                    // 順序を保持するために、挿入先を変える
                    targetRow = draggingRow;
                    rowIds.push(draggingRow.dataset.id);
                });

                console.log(rowIds + " is/are inserted after " + targetId);
                save("sample/insert-after", rowIds, targetId);
            }
        }
    }

    // ドラッグ終了時の処理
    function handleDragEnd(ev) {
        draggingRows.forEach((draggingRow) => {
            draggingRow.classList.remove("dragging");
        });
        table.querySelectorAll("tbody > tr").forEach((row) => {
            row.classList.remove("drop-placeholder");
        });
        draggingRows = null;
    }

    // 簡易的な複数行選択機能
    function handleClick(ev) {
        const row = ev.target.closest("tr");

        if (row.classList.contains("selected")) {
            row.classList.remove("selected");
            row.draggable = false;
        } else {
            row.classList.add("selected");
            row.draggable = true;
        }
    }

    // 順序をサーバー側に保存する
    function save(url, rowIds, targetId) {
        $.ajax({
            type: "get",
            url: url,
            dataType: "json",
            data: {
                row_ids: rowIds,
                target_id: targetId,
            },
        })
            .done((result) => {
                if (!result.status) {
                    alert(result.errorMessage);
                }
            })
            .fail((jqXHR, textStatus, errorThrown) => {
                alert("Ajax通信に失敗しました。");
                console.log("jqXHR          : " + jqXHR.status); // HTTPステータスを表示
                console.log("textStatus     : " + textStatus); // タイムアウト、パースエラーなどのエラー情報を表示
                console.log("errorThrown    : " + errorThrown.message); // 例外情報を表示
            });
    }
}

sample09/routes/web.php

<?php

use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/sample', [\App\Http\Controllers\SampleController::class, 'index']);
Route::get('/sample/insert-before', [\App\Http\Controllers\SampleController::class, 'insertBefore']);
Route::get('/sample/insert-after', [\App\Http\Controllers\SampleController::class, 'insertAfter']);

動作確認

以下のURLにアクセスして動作確認。

http://<your-ip-address>/sample09/public/sample

  • 行をクリックすると、選択状態がトグルされる
  • 選択されていない行をドラッグしても何も起きない(テキストが選択されるだけ)
  • 選択されている行をドラッグ&ドロップすると、ドラッグ元との位置関係によって、ドロップ先の行の上か下にドラッグした行が挿入される
  • ページをリロードしても順番が保持される

辺りが確認できれば完成。

参考

Geminiと会話して得られたコードを元にカスタマイズした。

share.google

Zend FrameworkからLaravelに移行する話(10)

はじめに

お仕事で、Zend Frameworkのバージョンアップをしなければならなくなった・・と思ったら、Zend Frameworkはもうなくて、Laminas Projectに移って新たなフレームワークとして公開されている。 しかし、Laminas MVCでは必要な要件を満たさないことが分かってとん挫していた。

そこで、次の候補としてLaravelを挙げて、必要な要件を満たせるかどうかを一歩ずつ調査していく。

要件(10)

10個目の要件は、「__callメソッドを使ったアクションのまとめ上げができること」。

一部のコントローラで__callメソッドを使っているので、念のため、フレームワーク側で変なことをしていないかを確認する。

結論から言うと、__callメソッドにはRequestオブジェクトを渡せないので、別のアプローチになる。

導入

こちらでセットアップした環境を(コピーして)使っていく。

hhelibex.hatenablog.jp

私は以下のようにコピーを作成。

cp -pr laravel-setup-9-request-parameters laravel-setup-10-call-method
cd laravel-setup-10-call-method

設定・実装

まず、例示のためのコントローラーを作っていく。

php artisan make:controller '\App\Http\Controllers\CallController'

このapp/Http/Controllers/CallController.phpを以下のように編集する。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class CallController extends Controller
{
    public function login(Request $request, $tenantAlias = null)
    {
        return view('call.login', ['action_name' => $tenantAlias]);
    }
}

次に、ビューテンプレートresources/views/call/login.tplを作成する。

Action Name:{$action_name|escape:"html"}

最後に、routes/web.phpに以下の内容を追記する。

Route::get('/call/{tenant_alias}', [\App\Http\Controllers\CallController::class, 'login'])->where('tenant_alias', '[a-zA-Z0-9-]*');

最後の正規表現の部分は、許容するURLの文字列パターンなのでご自由に。

動作確認

以下のURLにアクセスする。

http://<your-ip-address>/laravel-setup-10-call-method/public/call/test-hoge

routes/web.phpの設定にある{tenant_alias}の部分がtest-hogeとマッチするので、期待通りにアクションが呼び出されているため、以下のような画面が表示される。

Action Name:test-hoge 

まとめ

  • 結果的に、__callメソッドは使えなかったが、もっと分かりやすくすっきりした形で書ける

Zend FrameworkからLaravelに移行する話(9)

はじめに

お仕事で、Zend Frameworkのバージョンアップをしなければならなくなった・・と思ったら、Zend Frameworkはもうなくて、Laminas Projectに移って新たなフレームワークとして公開されている。 しかし、Laminas MVCでは必要な要件を満たさないことが分かってとん挫していた。

そこで、次の候補としてLaravelを挙げて、必要な要件を満たせるかどうかを一歩ずつ調査していく。

要件(9)

9つ目の要件は、「GETパラメータ、POSTパラメータを取得するラッパーメソッドがあること」。

$_GETや$_POSTなどを直接触りたくないので。

導入

こちらでセットアップした環境を(コピーして)使っていく。

hhelibex.hatenablog.jp

私は以下のようにコピーを作成。

cp -pr laravel-setup-8-user-auth laravel-setup-9-request-parameters
cd laravel-setup-9-request-parameters

設定・実装

まず、下準備として、SmartyのテンプレートでCSRFトークンを埋め込むためのプラグインを作成する。 ファイルパスはresources/smarty/plugins/function.csrf.php

<?php
function smarty_function_csrf($params, Smarty_Internal_Template $template)
{
    return '<input type="hidden" name="_token" value="' . csrf_token() . '" />';
}

次に、例示するためのコントローラーを以下のように作る。

php artisan make:controller '\App\Http\Controllers\RequestParameterController'

このapp/Http/Controllers/RequestParameterController.phpを以下のように編集する。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class RequestParameterController extends Controller
{
    public function index()
    {
        return view('request-parameter.index');
    }
    public function result(Request $request)
    {
        $getParams = $request->query();
        $postParams = $request->post();
        $files = $request->allFiles();
        return view('request-parameter.result',
                [
                    'get_params' => $getParams,
                    'post_params' => $postParams,
                    'files' => $files,
                ]);
    }
}

次にビューの作成。resources/views/request-parameter/index.tplresources/views/request-parameter/result.tplを作る。

  • resources/views/request-parameter/index.tpl
<form action="request-parameter/result?get_a=b&amp;get_c=d&amp;x[]=xx" method="post" enctype="multipart/form-data">
{csrf}
<ul>
<li><input type="text" name="post_a" value="B" /></li>
<li><input type="text" name="post_c" value="D" /></li>
<li><input type="text" name="x[]" value="XX" /></li>
<li><input type="file" name="img" /></li>
<li><input type="submit" value="Submit" /></li>
</ul>
</form>
  • resources/views/request-parameter/result.tpl
<table>
    <thead>
        <tr>
            <th>GET parameters</th>
            <th>POST parameters</th>
            <th>FILES</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>
                <pre>{$get_params|@var_dump|escape:"html"}</pre>
            </td>
            <td>
                <pre>{$post_params|@var_dump|escape:"html"}</pre>
            </td>
            <td>
                <pre>{$files|@var_dump|escape:"html"}</pre>
            </td>
        </tr>
    </tbody>
</table>

最後に、routes/web.phpに以下の内容を追記する。

Route::get('/request-parameter', [\App\Http\Controllers\RequestParameterController::class, 'index']);
Route::post('/request-parameter/result', [\App\Http\Controllers\RequestParameterController::class, 'result']);

動作確認

以下のURLにアクセスする。

http://<your-ip-address>/laravel-setup-9-request-parameters/public/request-parameter

以下のような画面になるはずなので、何か適当なファイルを選んで、Submitボタンを押す。

laravel-setup-9-request-parameters-1

以下のような画面が出てくればOK。

laravel-setup-9-request-parameters-2

まとめ

  • 今回、すべてのパラメータを取得するにとどめたが、query('get_a', 'default_a')post('post_a', 'default_a')file('img')などとすれば特定のパラメータの値の取得がZend FrameworkgetParam()のようにできる
  • $\_REQUESTに対応する値を取得するメソッドとしてはinput()がある

Zend FrameworkからLaravelに移行する話(8)

はじめに

お仕事で、Zend Frameworkのバージョンアップをしなければならなくなった・・と思ったら、Zend Frameworkはもうなくて、Laminas Projectに移って新たなフレームワークとして公開されている。 しかし、Laminas MVCでは必要な要件を満たさないことが分かってとん挫していた。

そこで、次の候補としてLaravelを挙げて、必要な要件を満たせるかどうかを一歩ずつ調査していく。

要件(8)

8つ目の要件は、「カスタマイズされたユーザー認証手順での認証ができること」。

Zend Frameworkで言うところのZend_Authを使った独自認証機構。 渡されたユーザー名からテナントDBを特定して、(アルゴリズムは一般的だけど)独自のパスワードハッシュ関数を使って渡されたパスワードをハッシュ化して照合して、ログイン認証に成功したらセッションにユーザー情報を保持しておく、というような流れを一通り作っていく。

導入

こちらでセットアップした環境を(コピーして)使っていく。

hhelibex.hatenablog.jp

私は以下のようにコピーを作成。

cp -pr laravel-setup-7-session-data laravel-setup-8-user-auth
cd laravel-setup-8-user-auth

検証方針

すべてを真面目に組み上げると、パスワードのハッシュ関数なども実装しなければならなくなるので、ここでは流れだけを確認するために、以下のようなものを実装することにする。

  • 認証時には以下のパラメータを渡す
    • GETパラメータでテナントコード(tenant_code)を渡す
    • GETパラメータでユーザー名(user_name)を渡す
  • 渡されたテナントコードを元にテナントDBを特定する。特定できない場合は認証エラー
  • 渡されたユーザー名を元に、特定されたテナントDB内に該当するユーザー名が存在するかを確認する。存在しなければ認証エラー
  • 特定されたユーザーアカウントの識別子をセッション情報に保持する
  • 検証用ページでは、以下のような表示をする
    • ユーザー識別子がセッションになければ「未ログイン」と表示する
    • ユーザー識別子がセッションに保存されていたら、テナントDBから取得できる実名(real_name)とともに「ログイン済み」と表示する

設定・実装

まず、コントローラーを以下のように作成する。

php artisan make:controller '\App\Http\Controllers\UserAuthController'

このapp/Http/Controllers/UserAuthController.phpを以下のように編集する。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Session;

class UserAuthController extends Controller
{
    public function index()
    {
        if (Session::has('user.userId') && Session::has('user.tenantCode')) {
            $tenantCode = Session::get('user.tenantCode');
            $user = DB::connection('tenant-' . $tenantCode)->table('user_master')
                ->where('user_id', Session::get('user.userId'))->first();
            $message = 'ログイン済み:' . $user->real_name;
        } else {
            $message = '未ログイン';
        }
        return view('user-auth.index', ['message' => $message]);
    }
    public function login(Request $request)
    {
        $tenantCode = $request->get('tenant_code');
        $userName = $request->get('user_name');
        $user = DB::connection('tenant-' . $tenantCode)->table('user_master')
            ->where('user_name', $userName)->first();
        Session::put('user.tenantCode', $request->get('tenant_code'));
        Session::put('user.userId', $user->id);
        return redirect('/user-auth');
    }
    public function logout()
    {
        Session::forget('user.userId');
        Session::forget('user.tenantCode');
        return redirect('/user-auth');
    }
}

次に、ビューテンプレートresources/views/user-auth/index.tplを以下の内容で作成する。

{$message|escape:"html"}

最後に、routes/web.phpに以下を追記する。

Route::get('/user-auth', [\App\Http\Controllers\UserAuthController::class, 'index']);
Route::get('/user-auth/login', [\App\Http\Controllers\UserAuthController::class, 'login']);
Route::get('/user-auth/logout', [\App\Http\Controllers\UserAuthController::class, 'logout']);

動作確認

以下のURLにアクセスする。

http://<your-ip-address>/laravel-setup-8-user-auth/public/user-auth

最初は以下のような画面が表示されればOK。

未ログイン

次に、以下のURLにアクセスする。

http://<your-ip-address>/laravel-setup-8-user-auth/public/user-auth/login?tenant_code=0001&user_name=tenant0001

以下のような画面が表示されればOK。

ログイン済み:テナント1 太郎

まとめ

  • セッションを使ってやりたいことは実現できた
  • 本当は、存在しないユーザーなどのエラー処理をしなければならないのだけど、今回は割愛した

Zend FrameworkからLaravelに移行する話(7)

はじめに

お仕事で、Zend Frameworkのバージョンアップをしなければならなくなった・・と思ったら、Zend Frameworkはもうなくて、Laminas Projectに移って新たなフレームワークとして公開されている。 しかし、Laminas MVCでは必要な要件を満たさないことが分かってとん挫していた。

そこで、次の候補としてLaravelを挙げて、必要な要件を満たせるかどうかを一歩ずつ調査していく。

要件(7)

7つ目の要件は、「セッションデータを扱うインタフェースがあること」。

Zend Frameworkで言うところのZend_Session周り。 まぁ、なければ$_SESSIONを直に触るだけなんだけど、何かあるでしょうということで、その辺を調べていく。

導入

こちらでセットアップした環境を(コピーして)使っていく。

hhelibex.hatenablog.jp

私は以下のようにコピーを作成。

cp -pr laravel-setup-6-join-queries laravel-setup-7-session-data
cd laravel-setup-7-session-data

設定・実装

新たなコントローラを作成する。

php artisan make:controller '\App\Http\Controllers\SessionController'

このapp/Http/Controllers/SessionController.phpを以下のように編集する。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Session;

class SessionController extends Controller
{
    public function session()
    {
        Session::put('hoge.hogeString', 'hogehoge');

        if (!Session::has('default.count')) {
            Session::put('default.count', 1);
        } else {
            Session::put('default.count', Session::get('default.count') + 1);
        }
        if (!Session::has('hoge.num')) {
            Session::put('hoge.num', 1);
        } else {
            Session::put('hoge.num', Session::get('hoge.num') + 2);
        }
        if (!Session::has('hoge.obj')) {
            Session::put('hoge.obj', json_decode(json_encode(['cnt' => 1])));
        } else {
            $obj = Session::get('hoge.obj');
            $obj->cnt++;
            Session::put('hoge.obj', $obj);
        }

        if (Session::has('default.hogeString')) {
            $hoge1 = 'Default hoge string is set: ' . Session::get('default.hogeString');
        } else {
            $hoge1 = 'Default hoge string is NOT set';
        }
        if (Session::has('hoge.hogeString')) {
            $hoge2 = 'HOGE hoge string is set: ' . Session::get('hoge.hogeString');
        } else {
            $hoge2 = 'HOGE hoge string is NOT set';
        }

        return view('session.session',
                [
                    'count' => Session::get('default.count'),
                    'num' => Session::get('hoge.num'),
                    'obj' => Session::get('hoge.obj'),
                    'hoge1' => $hoge1,
                    'hoge2' => $hoge2,
                ]);
    }
    public function clear()
    {
        // Session::flush(); // 全データが削除されてしまう
        Session::forget('default.count');
        Session::forget('hoge.hogeString');
        Session::forget('hoge.num');
        Session::forget('hoge.obj');
        // あるいは、Session::all() ですべてのデータを取得して、
        // キー名が正規表現にマッチするもののみSession::forget() する手も考えられる

        return redirect('/session/session');
    }
}

次に、ビューテンプレートresources/views/session/session.tplを作成する。

Count: {$count|escape:"html"}
<br>        
Num: {$num|escape:"html"}
<br>    
Obj: {$obj->cnt|escape:"html"}
<br>
Hoge1: {$hoge1|escape:"html"}
<br>        
Hoge2: {$hoge2|escape:"html"}

最後に、routes/web.phpに以下の行を追記する。

Route::get('/session/session', [\App\Http\Controllers\SessionController::class, 'session']);
Route::get('/session/clear', [\App\Http\Controllers\SessionController::class, 'clear']);

動作確認

以下のURLにアクセスする。

http://<your-ip-address>/laravel-setup-7-session-data/public/session/session

最初のアクセスでは以下のような画面が表示されればOK。

Count: 1
Num: 1
Obj: 1
Hoge1: Default hoge string is NOT set
Hoge2: HOGE hoge string is set: hogehoge 

リロードするごとに、値が以下のように変わっていけばOK。

ount: 3
Num: 5
Obj: 3
Hoge1: Default hoge string is NOT set
Hoge2: HOGE hoge string is set: hogehoge 

そして、以下のURLにアクセスしたときに最初の画面に戻ればOK。

http://<your-ip-address>/laravel-setup-7-session-data/public/session/clear

まとめ

  • Zend Frameworkのようなnamespaceはないが、セッションデータを扱うことはできる

Zend FrameworkからLaravelに移行する話(6)

はじめに

お仕事で、Zend Frameworkのバージョンアップをしなければならなくなった・・と思ったら、Zend Frameworkはもうなくて、Laminas Projectに移って新たなフレームワークとして公開されている。 しかし、Laminas MVCでは必要な要件を満たさないことが分かってとん挫していた。

そこで、次の候補としてLaravelを挙げて、必要な要件を満たせるかどうかを一歩ずつ調査していく。

要件(6)

6つ目の要件は、「DBのテーブルに対してJOINクエリが発行できること」。

Zend Frameworkでは、クエリビルダーがあったので、それでJOINクエリを書いていたが、Laravelではどうなっているのかを調査していく。

導入

こちらでセットアップした環境を(コピーして)使っていく。

hhelibex.hatenablog.jp

私は以下のようにコピーを作成。

cp -pr laravel-setup-5-multiple-databases laravel-setup-6-join-queries
cd laravel-setup-6-join-queries

DBへの追加

今回、テナントDBの方がメインになるので、テナントDBの方でJOINクエリを試していく。 以下のようにテナントDBに新たにテーブルを追加する。

\c tenant_0001 tenant_0001_user

CREATE TABLE messages(
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    title VARCHAR(128) NOT NULL,
    content TEXT NOT NULL);

INSERT INTO messages(user_id, title, content)
 VALUES(1, 'テナント1ー1さんへのメッセージ1', 'メッセージ1の内容');
INSERT INTO messages(user_id, title, content)
 VALUES(2, 'テナント1ー2さんへのメッセージ1', 'メッセージ1の内容');

INSERT INTO user_master(user_name, real_name)
 VALUES('tenant0002', 'テナント1 次郎');
\c tenant_0002 tenant_0002_user

CREATE TABLE messages(
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    title VARCHAR(128) NOT NULL,
    content TEXT NOT NULL);

INSERT INTO messages(user_id, title, content)
 VALUES(1, 'テナント2ー1さんへのメッセージ1', 'メッセージ1の内容');
INSERT INTO messages(user_id, title, content)
 VALUES(2, 'テナント2ー2さんへのメッセージ1', 'メッセージ1の内容');

INSERT INTO user_master(user_name, real_name)
 VALUES('tenant0001', 'テナント2 太郎');

想定するクエリ

先に追加したテーブルを使って、「実名に"太郎"を含むユーザーのメッセージ一覧」を取得するクエリを書いてみる。 SQL文で書くと以下のような感じになる。

SELECT m.id,
        m.user_id,
        m.title,
        m.content
    FROM messages m
    JOIN user_master u
    ON m.user_id = u.id
    WHERE u.real_name LIKE '%太郎%'
    ORDER BY m.id
    LIMIT 10 OFFSET 0

設定・実装

ここでは、DBファサードを使って問い合わせを行なう場合を想定する。

まず、app/Http/Controllers/TenantController.phpを以下のように編集する。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class TenantController extends Controller
{
    public function tenant(Request $request)
    {
        $tenantCode = $request->get('tenant_code');
        $result = DB::table('db_master_2')->where('tenant_code', $tenantCode)->first();
        return view('tenant.tenant', ['db_name' => $result->db_name]);
    }
    public function users(Request $request)
    {
        $tenantCode = $request->get('tenant_code');
        $result = DB::connection('tenant-' . $tenantCode)->table('user_master')->get();
        return view('tenant.users', ['users' => $result]);
    }
    public function messages(Request $request)
    {
        $tenantCode = $request->get('tenant_code');
        $allResult = DB::connection('tenant-' . $tenantCode)->table('messages')->get();
        $userResult = DB::connection('tenant-' . $tenantCode)
                ->table('user_master')->join('messages', 'user_master.id', '=', 'messages.user_id')
                ->whereLike('real_name', '%太郎%')->get();
        return view('tenant.messages',
            [
                'all_messages' => $allResult,
                'user_messages' => $userResult,
            ]);
    }
}

次に、ビューテンプレート。ファイルパスはresources/views/tenant/messages.tpl

全メッセージ
<ul>
{foreach from=$all_messages item='message'}
<li>{$message->id|escape:"html"}:{$message->user_id|escape:"html"}({$message->title|escape:"html"})</li>
{/foreach}
</ul>
ユーザーメッセージ
<ul>
{foreach from=$user_messages item='message'}
<li>{$message->id|escape:"html"}:{$message->user_id|escape:"html"}({$message->title|escape:"html"})</li>
{/foreach}
</ul>

最後に、routes/web.phpに以下の行を追記する。

Route::get('/tenant/messages', [\App\Http\Controllers\TenantController::class, 'messages']);

動作確認

以下のURLにアクセスして、ページが表示されるかを確認する。

http://<your-ip-address>/laravel-setup-6-join-queries/public/tenant/messages?tenant_code=0001

以下のようなテキストが画面に表示されればOK。

全メッセージ

・1:1(テナント1ー1さんへのメッセージ1)
・2:2(テナント1ー2さんへのメッセージ1)

ユーザーメッセージ

・1:1(テナント1ー1さんへのメッセージ1)

まとめ

  • DBファサードの機能が充実しているので、たいていのクエリは書けそう
  • WHERE条件も、述語メソッドが一通り用意されており、困ることはなさそう