引き続き Rails と Ember.js でブログアプリを作りながらフロントエンドの開発を学習中です。
今回は全文検索機能を実装していきます。
対象が学習用のブログアプリになるので、一番シンプルにできる方法で進めていきます。
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 のフォームコントロールとコンポーネントを使って検索フォームを作成します。
// 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(); }); } }
まず queryParams
に query
を追加してパラメータとして使えるようにします。
パラメータの query
と分けて _query
を @tracked
に指定をしているのは理由があります。
_query
は template 側で @value={{this._query}}
として利用されているのですが、この部分は検索フォームに入力された値をバインドしていて入力があるたびに更新がされます。
@value={{this._query}}
の部分を query
としてそのまま使ってしまうと入力があるたびにリアルタイムでURLのパラメータに反映がされてしまうため、入力された値を一時的に保持する内部変数として _query
を用意し、検索ボタンが押されて search
アクションが実行されるタイミングで _query
を this.query
に代入することでURLにパラメータが付与されるようにしています。
また、HTMLの form
タグは submit
イベントが発火するとリクエストが発生してページがリロードされるデフォルトの動作があります。
検索フォームに入力された文字を保持して検索結果を適切に描画するために search
アクションでは e.preventDefault();
を指定しています。
this.page = 1;
をアクション内で指定しているのは検索ボタンを押した際に1ページ目として描画するためです。
これを指定しないとページネーションで2ページ目以降にいた際にパラメータにページ番号が反映されたままリクエストが飛ぶため検索結果が適切に描画されなくなってしまいます。
runTask
の部分については、 tracked
で追跡している値の状態が変わるよう場合、状態が変わる前に次の処理が走ってしまい、うまく描画されない可能性を防いでいます。
runTask
は ember-lifeline
というライブラリの API です。
後はパラメータを 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 も使っているとやっぱり便利だなと思います。
完全に自分の理解を整理するためを目的としているフロントエンド学習記録ですが、もうしばらく続きそうです。