【フロントエンド学習記録】Rails と Ember.js で全文検索機能を実装

引き続き Rails と Ember.js でブログアプリを作りながらフロントエンドの開発を学習中です。

maimux2x.hatenablog.com

今回は全文検索機能を実装していきます。

対象が学習用のブログアプリになるので、一番シンプルにできる方法で進めていきます。

Rails 側の実装

gem は使用せず、where を使って検索を行う形をとりました。

def index
    @posts = @posts.where("title LIKE :query OR body LIKE :query", query: "%#{ActiveRecord::Base.sanitize_sql_like(params[:query])}%") if params[:query]
  end

where を使うことで上記のように LIKE 検索でシンプルに実装ができます。

ただこの実装には注意点があります。

まず、LIKE '%keyword%' での検索は前方一致でないため、インデックスが効かず全件スキャンされます。そのため、大量のデータを扱うような場合は適切ではないと言えます。

もう一つは LIKE 演算子を使う場合、位置指定ハンドラや名前付きハンドラを使って条件を組み立てるだけではサニタイズが行われない点です。

Posts.where("title LIKE :query OR body LIKE :query", query: "%#{params[:query]}%"})

上記のようにコードを書いてしまうとサニタイズが行われず、SQLインジェクションのリスクがあります。

最初のコード例にあるように ActiveRecord::Base.sanitize_sql_like を使って適切にサニタイズが行われるようにします。

Ember.js 側の実装

Bootstrap のフォームコントロールコンポーネントを使って検索フォームを作成します。

getbootstrap.jp

// web/app/templates/index.hbs

<div class="mb-3">
  <form class="d-flex" role="search" {{on "submit" this.search}}>
    <Input @value={{this._query}} class="form-control me-2" placeholder="Search" aria-labelledby="button-search" />
    <button class="btn btn-outline-success me-2" type="submit">Search</button>
    <button class="btn btn-outline-secondary" type="button" {{on "click" this.cancel}}>Cancel</button>
  </form>
</div>

上記 template 内では Ember.js の Controller で用意した検索用の search アクションと cancel アクションを呼び出しています。 また、Input コンポーネントでは @value={{this._query}} の部分で入力された文字列を Controller 側に同期してしています。

対応する Controller の実装は以下のようになりました。

// web/app/controllers/index.js

import Controller from '@ember/controller';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { runTask } from 'ember-lifeline';

export default class IndexController extends Controller {
  queryParams = [
    {
      page: { type: 'number' },
      query: { type: 'string' },
    },
  ];

  @service session;
  @service router;

  @tracked page = 1;
  @tracked query = '';
  @tracked _query = '';

  @action
  search(e) {
    e.preventDefault();
    this.query = this._query;
    this.page = 1;

    runTask(this, () => {
      this.router.refresh();
    });
  }

  @action
  cancel() {
    this.query = '';
    this._query = '';
    this.page = 1;

    runTask(this, () => {
      this.router.refresh();
    });
  }
}

まず queryParamsquery を追加してパラメータとして使えるようにします。

パラメータの query と分けて _query@tracked に指定をしているのは理由があります。

_query は template 側で @value={{this._query}} として利用されているのですが、この部分は検索フォームに入力された値をバインドしていて入力があるたびに更新がされます。 @value={{this._query}} の部分を query としてそのまま使ってしまうと入力があるたびにリアルタイムでURLのパラメータに反映がされてしまうため、入力された値を一時的に保持する内部変数として _query を用意し、検索ボタンが押されて search アクションが実行されるタイミングで _querythis.query に代入することでURLにパラメータが付与されるようにしています。

また、HTMLの form タグは submit イベントが発火するとリクエストが発生してページがリロードされるデフォルトの動作があります。 検索フォームに入力された文字を保持して検索結果を適切に描画するために search アクションでは e.preventDefault(); を指定しています。

this.page = 1; をアクション内で指定しているのは検索ボタンを押した際に1ページ目として描画するためです。 これを指定しないとページネーションで2ページ目以降にいた際にパラメータにページ番号が反映されたままリクエストが飛ぶため検索結果が適切に描画されなくなってしまいます。

runTask の部分については、 tracked で追跡している値の状態が変わるよう場合、状態が変わる前に次の処理が走ってしまい、うまく描画されない可能性を防いでいます。

runTaskember-lifeline というライブラリの API です。

github.com

後はパラメータを Rails 側にリクエストする URL にセットして一緒に送ることで全文検索が実行されます。

// web/app/routes/index.js

import Route from '@ember/routing/route';
export default class IndexRoute extends Route {
  queryParams = {
    page: {
      refreshModel: true,
    },
  };
  async model(args) {
    const url = new URL('http://localhost:3000/posts');
    url.searchParams.set('page', args.page);
    url.searchParams.set('query', args.query);

    return await fetch(url).then((res) => res.json());
  }

まとめ

今回は Rails 側で where を使う意図や注意点が大切なポイントだったなと思います。 Ember 側は SPA ならではの気をつけるべき点を学ぶことができました。 また、Bootstrap も使っているとやっぱり便利だなと思います。

完全に自分の理解を整理するためを目的としているフロントエンド学習記録ですが、もうしばらく続きそうです。