HHeLiBeXの日記 正道編

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

PostgreSQLでdeferred constraintを知らなくて苦労した話

はじめに

もう10年以上前にはなるけど、当時PostgreSQLのdeferred constraintを知らなくて、プログラムのロジックで対応したというお話。 それじゃあどう書けばよかったのかというところを整理する。

そもそも

PostgreSQLにはdeferred constraint(遅延制約)というのがあり(MySQL/MariaDBにはない)、各制約は3つのうちの1つが特性として生成時に設定される。

Upon creation, a constraint is given one of three characteristics: DEFERRABLE INITIALLY DEFERRED, DEFERRABLE INITIALLY IMMEDIATE, or NOT DEFERRABLE.

PostgreSQL: Documentation: 18: SET CONSTRAINTS

IMMEDIATEだと、制約はトランザクションの終了を待たずに即座にチェックされ、DEFERREDだと、制約のチェックはトランザクションの終了まで遅延させられる。

やったこと(現状)

例えば、「表示順」というカラムをテーブルに持たせて、そのカラムで常にソートして結果を返すようなケースを想定する。 その場合、行を途中に追加する場合の「挿入」、行が削除される場合の「削除」、そして行と行を入れ替える「入れ替え」という操作が必要になる。

SQLで書き下すと以下のような感じ。

DROP TABLE test1;
CREATE TABLE test1(id BIGSERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, show_order BIGINT NOT NULL UNIQUE);

INSERT INTO test1(name, show_order) VALUES('Taro', 1);
INSERT INTO test1(name, show_order) VALUES('Jiro', 2);
INSERT INTO test1(name, show_order) VALUES('Saburo', 3);
SELECT * FROM test1 ORDER BY show_order;
-- 挿入
START TRANSACTION;
-- 一括で更新しようとしても、1行ずつ更新していく過程で重複が発生してエラーになる
-- UPDATE test1 SET show_order = show_order + 1 WHERE show_order >= 2;
UPDATE test1 SET show_order = 4 WHERE show_order = 3;
UPDATE test1 SET show_order = 3 WHERE show_order = 2;
INSERT INTO test1(name, show_order) values('Ichiro', 2);
COMMIT;
SELECT * FROM test1 ORDER BY show_order;
-- 削除
START TRANSACTION;
DELETE FROM test1 WHERE name = 'Ichiro';
-- 一括で更新しようとしても、1行ずつ更新していく過程で重複が発生してエラーになる
-- UPDATE test1 SET show_order = show_order - 1 WHERE show_order > 2;
UPDATE test1 SET show_order = 2 WHERE show_order = 3;
UPDATE test1 SET show_order = 3 WHERE show_order = 4;
COMMIT;
SELECT * FROM test1 ORDER BY show_order;
-- 入れ替え
START TRANSACTION;
-- 「3を2に、2を3に」ということをしたいが、3を2にした時点で2が重複する
-- UPDATE test1 SET show_order = 2 WHERE show_order = 3;
-- UPDATE test1 SET show_order = 3 WHERE show_order = 2;
-- これも一緒で、DBMS的には「一括」はありえないので、どこかで重複が発生する
-- UPDATE test1 SET show_order = CASE
--   WHEN show_order = 3 THEN 2
--   WHEN show_order = 2 THEN 3
--  END WHERE show_order IN (2, 3);
-- 絶対にありえない値(例:-1)を使って3段階で更新する
UPDATE test1 SET show_order = -1 WHERE show_order = 3;
UPDATE test1 SET show_order = 3 WHERE show_order = 2;
UPDATE test1 SET show_order = 2 WHERE show_order = -1;
COMMIT;
SELECT * FROM test1 ORDER BY show_order;

これ、特に「挿入」と「削除」で、データが数個~十数個くらいだったらいいけど、いちいちプログラム側で「更新すべきshow_orderの範囲を取得して、その範囲内でループを回して、重複が発生しないように順番に更新していく」なんてことをやってしまったわけですよ。 この辺が、当時の私の限界だった。

どうすればよかったのか

PostgreSQLが持つdeferred constraintを使うとどう書き換えられるのか、実際にやってみた。

DROP TABLE test2;
CREATE TABLE test2(id BIGSERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, show_order BIGINT NOT NULL UNIQUE DEFERRABLE INITIALLY DEFERRED);

INSERT INTO test2(name, show_order) VALUES('Taro', 1);
INSERT INTO test2(name, show_order) VALUES('Jiro', 2);
INSERT INTO test2(name, show_order) VALUES('Saburo', 3);
SELECT * FROM test2 ORDER BY show_order;
-- 挿入
START TRANSACTION;
UPDATE test2 SET show_order = show_order + 1 WHERE show_order >= 2;
INSERT INTO test2(name, show_order) VALUES('Ichiro', 2);
COMMIT;
SELECT * FROM test2 ORDER BY show_order;
-- 削除
START TRANSACTION;
DELETE FROM test2 WHERE name = 'Ichiro';
UPDATE test2 SET show_order = show_order - 1 WHERE show_order > 2;
COMMIT;
SELECT * FROM test2 ORDER BY show_order;
-- 入れ替え
START TRANSACTION;
UPDATE test2 SET show_order = CASE
  WHEN show_order = 3 THEN 2
  WHEN show_order = 2 THEN 3
 END WHERE show_order IN (2, 3);
COMMIT;
SELECT * FROM test2 ORDER BY show_order;

だいぶすっきりした。 ポイントは、show_orderカラムにDEFERRABLE INITIALLY DEFERREDが付いたところ。 「入れ替え」に関しては、フレームワークのクエリビルダーで書こうとするとちょっとめんどくさいので現状の方がいいのかなと思ったり、「絶対にありえない値」は「本当に絶対なのか?」というのがあるのでやっぱり後者なのかと思ったりするけど。

MySQL/MariaDBの場合

先にちょっと触れたように、MySQL/MariaDBにはdeferred constraintがないらしく、どうやって書くことになるのか、というのも合わせて書いてみる。

DROP TABLE IF EXISTS test2;
CREATE TABLE test2(id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, show_order BIGINT NOT NULL UNIQUE);

INSERT INTO test2(name, show_order) VALUES('Taro', 1);
INSERT INTO test2(name, show_order) VALUES('Jiro', 2);
INSERT INTO test2(name, show_order) VALUES('Saburo', 3);
SELECT * FROM test2 ORDER BY show_order;
-- 挿入
START TRANSACTION;
UPDATE test2 SET show_order = show_order + 1 WHERE show_order >= 2 ORDER BY show_order DESC;
INSERT INTO test2(name, show_order) VALUES('Ichiro', 2);
COMMIT;
SELECT * FROM test2 ORDER BY show_order;
-- 削除
START TRANSACTION;
DELETE FROM test2 WHERE name = 'Ichiro';
UPDATE test2 SET show_order = show_order - 1 WHERE show_order > 2 ORDER BY show_order ASC;
COMMIT;
SELECT * FROM test2 ORDER BY show_order;
-- 入れ替え
START TRANSACTION;
-- 構文的には書けるけど、一瞬だけ重複が発生してエラーになる
-- UPDATE test2 SET show_order = CASE
--   WHEN show_order = 3 THEN 2
--   WHEN show_order = 2 THEN 3
--  END WHERE show_order IN (2, 3);
UPDATE test2 SET show_order = -1 WHERE show_order = 3;
UPDATE test2 SET show_order = 3 WHERE show_order = 2;
UPDATE test2 SET show_order = 2 WHERE show_order = -1;
COMMIT;
SELECT * FROM test2 ORDER BY show_order;

MySQL/MariaDBでは、UPDATE文でORDER BY句を指定することができて、更新順を制御できるらしい。それを使って「挿入」と「削除」の場合の問題を解決している。 入れ替えの場合は、PostgreSQLのようにはいかないので、媒介変数的な値を使うしか手段が見つけられなかった。

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

はじめに

ちょっとお仕事で必要が生じて、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はないが、セッションデータを扱うことはできる