この記事はTechCommit Advent Calendar 2022の22日目の記事です。
なんとなく参加しました。
注意
ただの個人用メモ
解説用に現物の一部を改変,省略,簡易化,簡略化などしています。
やり方が間違っている可能性が高いです。
参考にする場合は自己責任でお願いします。
バージョン,環境など
バック:php,laravel
フロント:html,css,javascript,Vue,axios
自分ではわからないもの:Inertia
言語 フレームワーク | バージョン |
---|---|
PHP | 8.0.24 |
Laravel | 9.43.0 |
Vue | 3.2.31 |
axios | 0.25 |
事の始まり
個人開発でブックマークにタグを付けて保存するアプリを開発しているから
ディレクトリ構造
今回の解説に必要な分だけ取り出しています。
repositoryディレクトリは自分でつくりました。1
データベース
仕様
-
deleted_at
にnull
以外の値が入っていたら削除扱いとする -
tag_id
がnull
の場合タグが1つもついていないものとする2
book_marks
名前 | タイプ | その他 |
---|---|---|
id | bigint | 主キー |
title | varchar | |
url | longtext | |
deleted_at | timestamp | nullable,デフォルト:null |
created_at | timestamp | nullable,デフォルト:null |
updated_at | timestamp | nullable,デフォルト:null |
tags
名前 | タイプ | その他 |
---|---|---|
id | bigint | 主キー |
name | varchar | |
deleted_at | timestamp | nullable,デフォルト:null |
created_at | timestamp | nullable,デフォルト:null |
updated_at | timestamp | nullable,デフォルト:null |
book_mark_tags
book_marksテーブルとtagsテーブルの中間テーブル
名前 | タイプ | その他 |
---|---|---|
book_mark_id | bigint | |
tag_id | bigint | nullable |
deleted_at | timestamp | nullable,デフォルト:null |
created_at | timestamp | nullable,デフォルト:null |
updated_at | timestamp | nullable,デフォルト:null |
3つのテーブルの関係
book_mark_tagsテーブルはbook_marksテーブルとtagsテーブルの中間テーブル
子カラム | 親テーブル | 親カラム |
---|---|---|
book_mark_id | book_marks | id |
tag_id | tags | id |
前提など
各ファイルには以下のようなファイルをuseして、変数を宣言してある
use DB;
use App\Repository\BookMarkRepository;
use App\Repository\BookMarkTagRepository;
use App\Tools\NullAvoidanceToolKit;
private $bookMarkRepository;
private $bookMarkTagRepository;
private $nullAvoidanceToolKit;
class BookMarkController extends Controller {
public function __construct()
{
$this->bookMarkRepository = new BookMarkRepository();
$this->bookMarkTagRepository = new BookMarkTagRepository();
$this->nullAvoidanceToolKit = new NullAvoidanceToolKit();
}
}
use DB;
use App\Tools\searchToolKit;
use App\Models\BookMark;
use App\Models\BookMarkTag;
use App\Tools\NullAvoidanceToolKit;
class BookMarkRepository
{
private $nullAvoidanceToolKit;
public function __construct()
{
$this->nullAvoidanceToolKit = new NullAvoidanceToolKit();
}
}
use DB;
use App\Models\BookMarkTag;
自作したツールについて
searchToolKit
一部サイトを参考に複数のキーワードで検索できるに変換する処理をまとめたファイル
class searchToolKit
{
//sqlでlike検索する前にするエスケープ処理
public function sqlEscape($arg)
{
// %をエスケープ
$escaped = preg_replace(
'/%/',
'\%',
$arg);
// _をエスケープ
$escaped = preg_replace(
'/_/',
'\_',
$escaped);
return $escaped;
}
//and検索できるように空白で区切って、配列にする
public function preparationToAndSearch($arg)
{
// 全角スペースを半角に変換
$spaceConversion = mb_convert_kana($arg, 's');
// 単語を半角スペースで区切り、配列にする
return preg_split('/[\s,]+/', $spaceConversion, -1, PREG_SPLIT_NO_EMPTY);
}
}
NullAvoidanceToolKit
nullを入れてしまうとエラーになるような処理で使う
ファイルにif文を書いて分岐するよりこのツールを使ったほうがコード短くきれいにかけると私は思う
class NullAvoidanceToolKit
{
// 引数1がnullだったら引数2を返す
// 引数1がnullでなかったら引数1を返す
public function ifnull($arg1,$arg2)
{
if (is_null($arg1)) { return $arg2; }
else {return $arg1;}
}
}
ものすごく雑な全体の流れ
大体こんな感じだと思います。
※ requestだとかミドルウェアだとかポリシーだとかは今回全部省略します。
※ BookMarkControllerの一部の処理が今回の解説に関係ないので一部処理します。
新規作成でのタグの付け外し
フロント
前提
私が作ったアプリでは以下のようなチェックボックスリストを採用しています。
タグはサーバーから以下のような形で渡される
[{id:1,name:アーキテクチャ},{id:2,name:アイデア}]
大まかな流れとしては
- データベースから登録してあるタグをとってくる
- 取ってきたタグを
tagList
配列に保存 -
tagList
配列をもとに、ユーザーがブックマークに紐づけるタグにチェックを入れる - チェックをつけたタグを
checkedTagList
配列に保存 -
checkedTagList
配列をサーバーに送信(私の場合はaxiosを使いました)
一部のみ解説する
3 tagList
配列をもとに、ユーザーがブックマークに紐づけるタグにチェックを入れる
4 チェックをつけたタグをcheckedTagList
配列に保存
チェックボックスリストのコードに<input type="checkbox" :id="tag.id" v-model="checkedTagList" :value="{tag.id}">
と書いてあるので
チェックボックスにチェックを入れるとcheckedTagList
は以下ようにタグのidが入る
[1,2,3]
サーバーサイド
BookMarkController
ここではタグが一つでも紐づいているかどうかで処理を分岐させる
前提
フロントからは、以下のようなデータが送られてくる
[
"bookMarkTitle" => "タイトル", // ブックマークのタイトル
"bookMarkUrl" => "url", //ブックマークのurl
"checkedTagList" => [1,2,3] // チェックを付けたタグのid
]
雑な全体の流れ
コード
public function store($request)
{
DB::transaction(function () use($request){
// ブックマークを保存してブックマークのidを取得
$bookMarkId = $this->bookMarkRepository->store(
title : $request->bookMarkTitle,
url : $request->bookMarkUrl,
);
// なんのタグも設定されていない時(checkedTagListが空の時)
if (empty($request->checkedTagList) == true) {
$this->bookMarkTagRepository->store(
tagId : null,
bookMarkId : $bookMarkId,
);
}
//タグが設定されている時
else {
foreach($request->checkedTagList as $tagId){
$this->bookMarkTagRepository->store(
tagId : $tagId,
bookMarkId : $bookMarkId,
);
}
}
});
}
bookMarkRepository->storeについては解説の重要度が低いので下の方に書いてあります
例1
受け取ったデータ
[
"bookMarkTitle" => "タイトル",
"bookMarkUrl" => "url",
"checkedTagList" => []
]
$bookMarkId=1
book_mark_tagsテーブルには以下のように保存される(一部省略)
book_mark_id | tag_id |
---|---|
1 | null |
例2
受け取ったデータ
[
"bookMarkTitle" => "タイトル",
"bookMarkUrl" => "url",
"checkedTagList" => [1,2,3]
]
$bookMarkId=1
book_mark_tagsテーブルには以下のように保存される(一部省略)
book_mark_id | tag_id |
---|---|
1 | 1 |
1 | 2 |
1 | 3 |
BookMarkTagRepository
データベースにデータを保存する
public function store($tagId,$bookMarkId)
{
BookMarkTag::create([
'book_mark_id' => $bookMarkId,
'tag_id' => $tagId,
]);
}
更新での付け外し
フロント
大まかな流れ
- データベースから登録してあるタグをとって来て
tagList
配列に保存 - データベースからブックマークに紐づいているタグを取ってくるきて
checkedTagList
配列に保存 - ユーザーがブックマークに紐づけるタグを追加したり、紐づけたタグを削除したりする(
checkedTagList
配列を変化させる) -
checkedTagList
配列をサーバーに送信
バック
BookMarkController
フロントから受け取ったデータをBookMarkTagRepositoryに渡す
BookMarkTagRepositoryから受け取ったデータをフロントに渡す
BookMarkTagRepository
ブックマークに紐づいているタグのIDを取ってくる処理
public function serveTagsRelatedToBookMark($bookMarkId){
$relatingTagList = DB::table('book_mark_tags')
->select('tag_id')
->whereNull('deleted_at')
->where('book_mark_id','=',$bookMarkId)
->get();
// [[tag_id => 1],[tag_id => 2],...]みたいな形を
// [1,2,...]みたいな形にする
$convertedRelatingTagList = $this->convertAssociativeArrayToSimpleArray($relatingTagList);
// 何もタグがついてなかったら空配列を返す
if (is_null($convertedRelatingTagList[0])) {return [];}
else {return $convertedRelatingTagList }
}
//[[tag_id => 1],[tag_id => 2],...]みたいな配列が渡されるので
//[1,2...]みたいな形に変換する
public function convertAssociativeArrayToSimpleArray($array)
{
$temp = [];
foreach($array as $element){array_push($temp,$element->tag_id);}
return $temp;
}
※2 なぜ空配列を返すのか
フロントのcheckedTagList
配列にわた時,空配列ならどのチェックボックスにもチェックがついていない状態になるから
例
book_mark_tagテーブルが以下のような状態とする
book_mark_id | tag_id | delete_at |
---|---|---|
1 | 1 | null |
1 | 2 | null |
1 | 3 | null |
1 | 4 | 2022-12-19 16:26:56 |
2 | 1 | null |
2 | 4 | null |
3 | 6 | null |
-
book_mark_id
が1 -
deleted_at
がnull
のデータを探す
book_mark_id | tag_id | delete_at |
---|---|---|
1 | 1 | null |
1 | 2 | null |
1 | 3 | null |
ここから加工してtag_id
だけを配列に入れる -> [1,2,3]
更新作業
前提
フロントから以下のようなデータが渡される
[
"bookMarkId" => 1, //更新するブックマークのId
"bookMarkTitle" => "タイトル", // ブックマークのタイトル
"bookMarkUrl" => "url", //ブックマークのurl
"checkedTagList" => [1,2,3] // チェックを付けたタグのid
]
雑な全体の流れ
コード
public function update($bookMarkId,$checkedTagList)
{
// 更新前のブックマークに紐付けられていたタグを取得
$originalTagList = $this->serveTagsRelatedToBookMark($bookMarkId);
// 更新前のブックマークにタグが1つでもついていたかいなかったかで処理を分ける
if (empty($originalTagList)) {$this->ProcessingifOriginalHasNoTags($bookMarkId,$checkedTagList,$originalTagList);}
else {$this->ProcessingifOriginalHasAnyTags($bookMarkId,$originalTagList,$checkedTagList);}
}
// タグが1つもついてなかった
public function ProcessingifOriginalHasNoTags($bookMarkId,$checkedTagList,$originalTagList)
{
// 追加されたタグ
$addedTagList = array_diff($checkedTagList, $originalTagList);
// なにか新しくタグが紐づけられていた場合
if (!empty($addedTagList)) {
// 追加
foreach($addedTagList as $tag) {
$this->store(
tagId:$tag,
bookMarkId:$bookMarkId,
);
}
// nullの削除
$this->delete(
tagId:null,
bookMarkId:$bookMarkId,
);
}
}
// タグがついていた
public function ProcessingifOriginalHasAnyTags($bookMarkId,$originalTagList,$checkedTagList)
{
// 追加されたタグ
$addedTagList = array_diff($checkedTagList, $originalTagList);
// 削除されたタグ
$deletedTagList = array_diff($originalTagList, $checkedTagList);
//削除
if (!empty($deletedTagList)) {
foreach($deletedTagList as $tag) {
$this->delete(
tagId:$tag,
bookMarkId:$bookMarkId,
);
}
}
// ブックマークのタグをすべて消した時の処理
// 新しく追加されたタグがない場合
if (empty($addedTagList)) {
//もともとついていたタグがすべてはずされたか確認
$isAllDeleted = array_diff($originalTagList,$deletedTagList);
if (empty($isAllDeleted)) {
// 紐付けられていたタグすべて削除されたのならtag_id = nullのデータをついか
$this->store(
tagId:null,
bookMarkId:$bookMarkId,
);
}
} else {
// 新しく追加されたタグがある場合
foreach($addedTagList as $tag) {
$this->store(
tagId:$tag,
bookMarkId:$bookMarkId,
);
}
}
}
例1
フロントからのデータ(一部省略)
[
"bookMarkId" => 1,
"checkedTagList" => [1,2,3]
]
更新前データベースの状態
book_mark_id | tag_id | deleted_at |
---|---|---|
1 | null | null |
2 | 1 | null |
2 | 2 | null |
↓
更新後データベースの状態
book_mark_id | tag_id | deleted_at |
---|---|---|
1 | null | 2022-12-19 16:26:56 |
1 | 1 | null |
1 | 2 | null |
1 | 3 | null |
2 | 1 | null |
2 | 2 | null |
例2
フロントからのデータ(一部省略)
[
"bookMarkId" => 1,
"checkedTagList" => [3,4]
]
更新前データベースの状態
book_mark_id | tag_id | deleted_at |
---|---|---|
1 | 1 | null |
1 | 2 | null |
1 | 3 | null |
2 | 1 | null |
2 | 2 | null |
↓
更新後データベースの状態
book_mark_id | tag_id | deleted_at |
---|---|---|
1 | 1 | 2022-12-19 16:26:56 |
1 | 2 | 2022-12-19 16:26:56 |
1 | 3 | null |
1 | 4 | null |
2 | 1 | null |
2 | 2 | null |
例3
フロントからのデータ(一部省略)
[
"bookMarkId" => 1,
"checkedTagList" => []
]
更新前データベースの状態
book_mark_id | tag_id | deleted_at |
---|---|---|
1 | 1 | null |
1 | 2 | null |
1 | 3 | null |
2 | 1 | null |
2 | 2 | null |
↓
更新後データベースの状態
book_mark_id | tag_id | deleted_at |
---|---|---|
1 | 1 | 2022-12-19 16:26:56 |
1 | 2 | 2022-12-19 16:26:56 |
1 | 3 | 2022-12-19 16:26:56 |
1 | null | null |
2 | 1 | null |
2 | 2 | null |
array_diff()とは
上記のコードで出てきたarray_diff()について自分なりの意訳で説明
簡単に説明すると
引数1の配列から引数2の配列の要素を消して残った要素を返す。
※人によっては引き算と表したほうがわかるかもしれない
例1
配列1:[1,2,3]
配列2:[2,4,6]
array_diff(配列1,配列2) →[1,3]
array_diff(配列1,配列2) = [1,2,3] - [2,4,6] = [1,3]
例2
配列1:[1,2,3]
配列2:[4,5,6]
array_diff(配列1,配列2) →[1,2,3]
array_diff(配列1,配列2) = [1,2,3] - [4,5,6] = [1,2,3]
例3
配列1:[1,2,3]
配列2:[1,2,3]
array_diff(配列1,配列2) →[]
array_diff(配列1,配列2) = [1,2,3] - [1,2,3] = []
検索
前提
vueとInertiaとlaravelを組み合わせてしまっているのでlaravelのpagenationがそのまま使うことができない。
なので色々自分でコードを用意しないといけない。
フロント
- データベースから登録してあるタグをとってくる
- 取ってきたタグを
tagList
配列に保存 -
tagList
配列をもとに、ユーザーがブックマークに紐づけるタグにチェックを入れる - チェックをつけたタグを
checkedTagList
配列に保存 - フォームからユーザーからキーワードを受け取る
- 受け取った
keyword
変数に代入 -
keyword
変数,checkedTagList
配列をサーバーに送信(私の場合はinertiaを使いました)
バック
BookMarkController
前提
フロントからは以下のようなデータが渡される
[
"keyword" => "検索するキーワード"
"checkedTagList" => [1,2,3]
]
キーワードもタグもすべてAnd
検索で実行する
コード
public function search(Request $request)
{
// 検索
$result = $this->bookMarkRepository->search(
keyword:$request->keyword,
checkedTagList:$request->checkedTagList
);
// 私の場合はInertia::renderを使ってフロントにデータを返すことにした
return Inertia::render('BookMark/SearchBookMark',[
'result' => $result,
]);
}
BookMarkController
コード
public function search($keyword,$checkedTagList)
{
// ツールを実体化
$searchToolKit = new searchToolKit();
// %と_をエスケープ
$escaped = $searchToolKit->sqlEscape($keyword);
//and検索のために空白区切りでつくった配列を用意
$wordListToSearch = $searchToolKit->preparationToAndSearch($escaped);
//タグも検索する場合
if (!empty($checkedTagList)) {
//副問合せのテーブルから検索
$subTable = $this->createSubTableForSearch($userId,$tagList);
$query = DB::table($subTable,"book_marks");
} else {
//タグ検索が不要な場合
$query = DB::table("book_marks")
->select('*')
->whereNull('deleted_at');
}
// titleをlike検索
foreach($wordListToSearch as $word){ $query->where('title','like',"%$word%");
//検索
return $query->get();
}
//検索時のサブテーブル作成
public function createSubTableForSearch($checkedTagList)
{
//book_markテーブルとbook_mark_tagsテーブルを結合
$subTable = BookMark::select('book_marks.*')
->leftjoin('book_mark_tags','book_mark_tags.book_mark_id','=','book_marks.id')
->where(function($subTable) { // 削除されたブックマーク、外されたブックマークのデータをとってこないようにする
$subTable->WhereNull('book_marks.deleted_at')
->WhereNull('book_mark_tags.deleted_at')
})
->where(function($subTable) use($checkedTagList) { // 指定したタグがついたデータをとってくる
foreach($checkedTagList as $tag){
$subTable->orWhere('book_mark_tags.tag_id','=',$tag);
}
});
// 指定したタグがすべて付いているデータだけに絞る
$subTable->groupBy('book_marks.id')
->having(DB::raw('count(*)'), '=', count($checkedTagList));
return $subTable;
}
createSubTableForSearch
で実行されるsqlがどんな感じに動くかのイメージ
例
以下のようなデータベースがあるとする
book_mark_tagsテーブル
book_mark_id | tag_id | deleted_at |
---|---|---|
1 | 1 | null |
1 | 2 | null |
1 | 3 | null |
1 | 4 | 2022-12-19 16:26:56 |
2 | 1 | null |
2 | 2 | null |
2 | 3 | 2022-12-19 16:26:56 |
3 | 3 | null |
4 | null | null |
5 | 5 | null |
book_marksテーブル
id | title | url | deleted_at |
---|---|---|---|
1 | aaa | aaa | null |
2 | bbb | bbb | null |
3 | ccc | ccc | null |
4 | ddd | ddd | null |
5 | eee | eee | 2022-12-19 16:26:56 |
以下のようなデータが渡されたとする
[
"checkedTagList" => [1,2,3]
]
バックでは大体以下のように動く
- book_markテーブルとbook_mark_tagsテーブルを結合
- 削除されたブックマーク、外されたブックマークのデータを除外する
- 指定したタグがついたデータをとってくる
-
book_mark_id
ごとにデータを分ける - 配列の要素数と、データの数が同じものだけ取ってくる
1. book_markテーブルとbook_mark_tagsテーブルを結合
book_mark_id | tag_id | deleted_at | title | url | deleted_at |
---|---|---|---|---|---|
1 | 1 | null | aaa | aaa | null |
1 | 2 | null | aaa | aaa | null |
1 | 3 | null | aaa | aaa | null |
1 | 4 | 2022-12-19 16:26:56 | aaa | aaa | null |
2 | 1 | null | bbb | bbb | null |
2 | 2 | null | bbb | bbb | null |
2 | 3 | 2022-12-19 16:26:56 | bbb | bbb | null |
3 | 3 | null | ccc | ccc | null |
4 | null | null | ddd | ddd | null |
5 | 5 | null | eee | eee | 2022-12-19 16:26:56 |
2. 削除されたブックマーク、外されたブックマークのデータをとってこないようにする
book_mark_id | tag_id | deleted_at | title | url | deleted_at |
---|---|---|---|---|---|
1 | 1 | null | aaa | aaa | null |
1 | 2 | null | aaa | aaa | null |
1 | 3 | null | aaa | aaa | null |
2 | 1 | null | bbb | bbb | null |
2 | 2 | null | bbb | bbb | null |
3 | 3 | null | ccc | ccc | null |
4 | null | null | ddd | ddd | null |
3. 指定したタグがついたデータをとってくる
今回は"checkedTagList" => [1,2,3]
なのでtag_id
が1
か2
か3
のデータを取ってくる
book_mark_id | tag_id | deleted_at | title | url | deleted_at |
---|---|---|---|---|---|
1 | 1 | null | aaa | aaa | null |
1 | 2 | null | aaa | aaa | null |
1 | 3 | null | aaa | aaa | null |
2 | 1 | null | bbb | bbb | null |
2 | 2 | null | bbb | bbb | null |
3 | 3 | null | ccc | ccc | null |
4. book_mark_id
ごとにデータを分ける
book_mark_id = 1
book_mark_id | tag_id | deleted_at | title | url | deleted_at |
---|---|---|---|---|---|
1 | 1 | null | aaa | aaa | null |
1 | 2 | null | aaa | aaa | null |
1 | 3 | null | aaa | aaa | null |
book_mark_id = 2
book_mark_id | tag_id | deleted_at | title | url | deleted_at |
---|---|---|---|---|---|
2 | 1 | null | bbb | bbb | null |
2 | 2 | null | bbb | bbb | null |
book_mark_id = 3
book_mark_id | tag_id | deleted_at | title | url | deleted_at |
---|---|---|---|---|---|
3 | 3 | null | ccc | ccc | null |
5. 配列の要素数と、データの数が同じものだけ取ってくる
"checkedTagList" => [1,2,3]
なので要素数は3つ
なので、データが3つのデータを取ってくる
book_mark_id | tag_id | deleted_at | title | url | deleted_at |
---|---|---|---|---|---|
1 | 1 | null | aaa | aaa | null |
1 | 2 | null | aaa | aaa | null |
1 | 3 | null | aaa | aaa | null |
実際のデータベースでは以下のように返される
select book_marks.*
とかいたので
id | title | url | deleted_at | create_at | updated_at |
---|---|---|---|---|---|
1 | aaa | aaa | null | 2022-12-19 16:26:56 | 2022-12-19 16:26:56 |
これをsearch
関数に返して、そこから更にタイトルをlike
検索したりする
解説重要度が低いが,気になる人のためにここコードをまとめておこうのコーナー
チェックボックスリスト
<v-list>
<v-list-item v-for="tag of tagList" :key="tag.id">
<input type="checkbox" :id="tag.id" v-model="checkedTagList" :value="{tag.id}">
<label :for="tag.id">{{tag.name}}</label>
</v-list-item>
</v-list>
bookMarkRepository->store
//新規ブックマーク作成 登録したブックマークのIDを返す
public function store($title,$url)
{
$bookMark = BookMark::create([
// タイトルが産められてなかったら日時で埋める
'title' => $this->nullAvoidanceToolKit->ifnull($title,Carbon::now()),
'url' => $url,
]);
return $bookMark->id;
}
bookMarkTagRepository->store
//ブックマークに紐付けらたタグを登録
public function store($tagId,$bookMarkId)
{
BookMarkTag::create([
'book_mark_id' => $bookMarkId,
'tag_id' => $tagId,
]);
}
bookMarkTagRepository->delete
//ブックマークからはずされたタグを削除
public function delete($tagId,$bookMarkId)
{
BookMarkTag::where('book_mark_id','=',$bookMarkId)
->where('tag_id','=',$tagId)
->delete();
}
参考にしたもの