LoginSignup
2
0

エンジニアを目指す私が実装したタグをつける機能とタグの検索機能(n番煎じ)

Last updated at Posted at 2022-12-22

この記事は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
ディレクトリ構造.drawio.png

データベース

仕様

  • deleted_atnull以外の値が入っていたら削除扱いとする
  • tag_idnullの場合タグが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テーブルの中間テーブル

※図は一部カラムを省略
bookmarktags.drawio.png

子カラム 親テーブル 親カラム
book_mark_id book_marks id
tag_id tags id

前提など

各ファイルには以下のようなファイルをuseして、変数を宣言してある

BookMarkController.php
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();
    }
}
BookMarkRepository.php
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();
    }
}
BookMarkTagRepository.php
use DB;
use App\Models\BookMarkTag;

自作したツールについて

searchToolKit

一部サイトを参考に複数のキーワードで検索できるに変換する処理をまとめたファイル

searchToolKit.php
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文を書いて分岐するよりこのツールを使ったほうがコード短くきれいにかけると私は思う

NullAvoidanceToolKit.php
class NullAvoidanceToolKit
{
    // 引数1がnullだったら引数2を返す
    // 引数1がnullでなかったら引数1を返す
    public function ifnull($arg1,$arg2)
    {
        if (is_null($arg1)) { return $arg2; }
        else {return $arg1;}
    }
}

ものすごく雑な全体の流れ

大体こんな感じだと思います。
ものすごく雑な全体のながれ.drawio.png
※ requestだとかミドルウェアだとかポリシーだとかは今回全部省略します。
※ BookMarkControllerの一部の処理が今回の解説に関係ないので一部処理します。

新規作成でのタグの付け外し

フロント

前提

私が作ったアプリでは以下のようなチェックボックスリストを採用しています。
スクリーンショット (4265).png
タグはサーバーから以下のような形で渡される

[{id:1,name:アーキテクチャ},{id:2,name:アイデア}]

大まかな流れとしては

  1. データベースから登録してあるタグをとってくる
  2. 取ってきたタグをtagList配列に保存
  3. tagList配列をもとに、ユーザーがブックマークに紐づけるタグにチェックを入れる
  4. チェックをつけたタグをcheckedTagList配列に保存
  5. checkedTagList配列をサーバーに送信(私の場合はaxiosを使いました)

一部のみ解説する

3 tagList配列をもとに、ユーザーがブックマークに紐づけるタグにチェックを入れる

コードはここ

4 チェックをつけたタグをcheckedTagList配列に保存

チェックボックスリストのコードに<input type="checkbox" :id="tag.id" v-model="checkedTagList" :value="{tag.id}">と書いてあるので
チェックボックスにチェックを入れるとcheckedTagListは以下ようにタグのidが入る

checkedTagList
[1,2,3]

サーバーサイド

BookMarkController

ここではタグが一つでも紐づいているかどうかで処理を分岐させる

前提

フロントからは、以下のようなデータが送られてくる

フロントから渡される例
[
    "bookMarkTitle"  => "タイトル", // ブックマークのタイトル
    "bookMarkUrl"    => "url", //ブックマークのurl
    "checkedTagList" => [1,2,3] // チェックを付けたタグのid
]
雑な全体の流れ

BookMarkController全体図.drawio (1).png

コード
BookMarkController.php
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

データベースにデータを保存する

BookMarkRepository.php
public  function store($tagId,$bookMarkId)
    {
        BookMarkTag::create([
            'book_mark_id' => $bookMarkId,
            'tag_id'     => $tagId,
        ]);
    }

更新での付け外し

フロント

大まかな流れ

  1. データベースから登録してあるタグをとって来てtagList配列に保存
  2. データベースからブックマークに紐づいているタグを取ってくるきてcheckedTagList配列に保存
  3. ユーザーがブックマークに紐づけるタグを追加したり、紐づけたタグを削除したりする(checkedTagList配列を変化させる)
  4. checkedTagList配列をサーバーに送信

バック

BookMarkController

フロントから受け取ったデータをBookMarkTagRepositoryに渡す
BookMarkTagRepositoryから受け取ったデータをフロントに渡す

BookMarkTagRepository

ブックマークに紐づいているタグのIDを取ってくる処理

BookMarkTagRepository.php
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 }
    }
BookMarkTagRepository.php
    //[[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_atnull

のデータを探す

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
]
雑な全体の流れ

更新.drawio.png

コード
BookMarkTag.php
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がそのまま使うことができない。
なので色々自分でコードを用意しないといけない。

フロント

  1. データベースから登録してあるタグをとってくる
  2. 取ってきたタグをtagList配列に保存
  3. tagList配列をもとに、ユーザーがブックマークに紐づけるタグにチェックを入れる
  4. チェックをつけたタグをcheckedTagList配列に保存
  5. フォームからユーザーからキーワードを受け取る
  6. 受け取ったkeyword変数に代入
  7. keyword変数,checkedTagList配列をサーバーに送信(私の場合はinertiaを使いました)

バック

BookMarkController

前提

フロントからは以下のようなデータが渡される

渡される例
[
    "keyword"        => "検索するキーワード"
    "checkedTagList" => [1,2,3]
]

キーワードもタグもすべてAnd検索で実行する

コード
BookMarkController.php
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

コード
BookMarkRepository.php
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]
]

バックでは大体以下のように動く

  1. book_markテーブルとbook_mark_tagsテーブルを結合
  2. 削除されたブックマーク、外されたブックマークのデータを除外する
  3. 指定したタグがついたデータをとってくる
  4. book_mark_idごとにデータを分ける
  5. 配列の要素数と、データの数が同じものだけ取ってくる

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_id123のデータを取ってくる

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検索したりする

解説重要度が低いが,気になる人のためにここコードをまとめておこうのコーナー:clap:

チェックボックスリスト
チェックボックスリスト
<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
BookMarkRepository.php
//新規ブックマーク作成 登録したブックマークのIDを返す
public  function store($title,$url)
    {
        $bookMark = BookMark::create([
            // タイトルが産められてなかったら日時で埋める
            'title'    => $this->nullAvoidanceToolKit->ifnull($title,Carbon::now()),
            'url'      => $url,
        ]);
        return $bookMark->id;
    }
bookMarkTagRepository->store
BookMarkRepository.php
//ブックマークに紐付けらたタグを登録
    public  function store($tagId,$bookMarkId)
    {
        BookMarkTag::create([
            'book_mark_id' => $bookMarkId,
            'tag_id'     => $tagId,
        ]);
    }
bookMarkTagRepository->delete
BookMarkRepository.php
//ブックマークからはずされたタグを削除
    public  function delete($tagId,$bookMarkId)
    {
        BookMarkTag::where('book_mark_id','=',$bookMarkId)
            ->where('tag_id','=',$tagId)
            ->delete();
    }

参考にしたもの

  1. なぜそのような仕様にしたのか
    現段階ではまだ実装できてないが、タグが1つもついていないブックマークを検索するのに使えそうだと判断したため。

  2. なぜわざわざrepositoryディレクトリを作ったのか
    もともとデータベースに関するコードはモデルの方に書いていたが、数人のエンジニアの方々にみてもらった時に、ファットモデルになってしまうから別のファイルに書いたほうが良いというアドバイスをうけたため。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0