Skip to main content

検索

ほとんどのサービスでなんらかのコンテンツを検索する機能が実装されている。 Railsの検索というはそのままデータベースの検索機能、つまりはMysqlの検索機能を指すことになる。 Mysqlは自然言語を検索するのには実際はあまり向いていない。 決まったデータ型を広く、大量に捌くことはできたとしても、自然言語の曖昧な検索は得意としていない。 自然言語の全文検索はその後シェアを拡大しているNoSqlと呼ばれるものが得意としている。

MVCを利用している場合、Ransackというgemが検索する場合最も有名である。 Ransackは検索用viewヘルパーを使わないと、検索ができない。 そのため、React + Railsの様な構成ではほぼほぼ使用が不可能である。

代わりに検索するGemはいくつかあるのだが、多くのGemはMysqlが自然言語検索に適していないと判断して、 elasticsearchやSolr、aligoliaなど別の検索エンジンにデータを登録して、それの操作を可能にするラッパーのGemを提供するものが多い。 大規模なサービスになり、検索機能に開発コストをかけても良いのならばそちらを利用することもありだが、 小さく開発していく段階でこれらの提携やややオーバースペックである。

Mysqlでも検索はできないくはないので、簡単な検索機能を独自で実装してみる。

検索APIの実装

最初に検索用のコントローラーを作成する。

bin/rails g controller posts/search

ルーティングを通す。

/config/routes.rb
resources :posts, param: :q, only: [] do
member do
scope module: :posts do
resources :search, only: %i[index]
end
end
end

このルーティングもかなり込み入った書き方をしている。 URLの:qに検索ワードを入れた買ったのでこのようになってしまった。

 % bin/rails routes
Prefix Verb URI Pattern Controller#Action
search_index GET /posts/:q/search(.:format) posts/search#index

中身をこのようにする。

app/controllers/posts/search_controller.rb
def index
words = params[:q].split(" ") # urlの検索ワードをスペース毎に配列にする
query_string = words.map {|val| "%#{val}%" } # 各単語に曖昧検索の記号を加える
sql_text = words.map.with_index {|val, i| words.length == i+1 ? "title LIKE ?" : "title LIKE ? AND "} # AND検索用のSQLクエリ文を作成
sql_text = sql_text.join() # 配列になっているSQLクエリ文を文字列に変換
@post = Post.eager_load(:user, :favorite_posts, :tags).where(sql_text, *query_string).order(created_at: :desc).map { |post|
post.is_favorite = post.favorite_posts.filter_map{|favorite_post| favorite_post.user_id == (user_signed_in? ? current_user.id : nil)}.first
post
}
paginate json: @post, json_for: :list, include: {user: {json_for: :public}, tags: {json_for: :default}}
end

:qに入ってきた検索ワードを配列にし、曖昧検索を加えて、単語の数に応じてtitle LIKEを加えるSQL構文を生成し、結合して検索する。

検索はtitleだけをAND検索できるようになっている。他のフィールドを跨いだり、ORやNOTを加えたり、検索順位の変更などをすると、複雑になるのでここでは割愛する。

git add .
git commit -m "Create a search controller and search function"

フロントから検索

最初に、検索APIを叩くための関数を作成する。

lib/api/post.js
export const searchPosts = (keyword, page, per_page) => {
if (!Cookies.get('_access_token') || !Cookies.get('_client') || !Cookies.get('_uid')) {
return client.get(`/posts/${keyword}/search?page=${page}&per_page=${per_page}`)
} else {
return client.get(`/posts/${keyword}/search?page=${page}&per_page=${per_page}`, {
headers: {
'access-token': Cookies.get('_access_token') || '',
client: Cookies.get('_client') || '',
uid: Cookies.get('_uid') || '',
},
})
}
}

検索フォームはHeaderコンポーネントに作成する。

Headerコンポーネントで新たに、useFormuseRouterTextFieldをインポートする。

components/Header.jsx
import { useForm } from 'react-hook-form'
import { useRouter } from 'next/router'
import { Box, Clickable, Text, Motion, Svg, Link, TextField } from '../atomic/'

ルーターとフォームのオブジェクトを宣言し、検索処理へ飛ばすhandleSearchを作成する。

components/Header.jsx
const router = useRouter()
const { register, handleSubmit } = useForm()

const handleSearch = (data) => {
const keyword = data.keyword.replace(/ /g, ' ')
router.push(`/posts/search/${keyword}`)
}

jsxでメニューの横に検索フォームを配置する。

components/Header.jsx
<Box position="relative" mr={8}>
<TextField
name="keyword"
{...register('keyword', {
required: true,
})}
height={38}
width={220}
borderColor="gray"
borderWidth={1}
borderRadius={3}
py={10}
px={14}
fontColor="black"
fontSize={16}
letterSpacing={0.8}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSubmit(handleSearch)()
}
}}
/>
<Clickable
position="absolute"
width={18}
height={18}
onClick={handleSubmit(handleSearch)}
css={css`
right: 12px;
top: 7px;
`}
>
<Svg name="Search" width={24} height={24} />
</Clickable>
</Box>

search

そしたら、検索結果を表示するページを作成する。

mkdir pages/posts/search
touch pages/posts/search/[keyword].jsx

少し長いが、検索結果のページはこうなる。

pages/posts/search/[keyword].jsx
import Head from 'next/head'
import { useRouter } from 'next/router'
import { useState, useEffect } from 'react'
import { searchPosts, destroyPost, createFavoritePost, destroyFavoritePost } from '../../../lib/api/post'
import { Text, Box, Link, Svg, Clickable, Image } from '../../../atomic/'
import Header from '../../../components/Header'
import Footer from '../../../components/Footer'

export default function Search() {
const router = useRouter()
const [posts, setPosts] = useState([])
const [page, setPage] = useState(1)
const { keyword } = router.query
const per_page = 10

const handleGetPosts = async () => {
if (keyword) {
try {
const res = await searchPosts(keyword, 1, per_page)
if (res?.data.length > 0) {
setPage(page + 1)
setPosts(res?.data)
} else {
setPosts([])
setPage(1)
console.log('Something went wrong')
}
} catch (err) {
console.log(err)
}
}
}

useEffect(() => {
handleGetPosts()
}, [keyword])

const handleGetMorePosts = async () => {
try {
const res = await searchPosts(keyword, page, per_page)
console.log(res)

if (res?.data.length > 0) {
setPage(page + 1)
setPosts([...posts, ...res.data])
} else {
console.log('Something went wrong')
}
} catch (err) {
console.log(err)
}
}

const handleDestroyPost = async (post) => {
try {
const res = await destroyPost(post.id)
console.log(res)

if (res.status === 204) {
const res = await searchPosts(keyword, 1, per_page * (page - 1))
setPosts(res?.data)
} else {
console.log('Something went wrong')
}
} catch (err) {
console.log(err)
}
}

const handleFavoritePost = async (post) => {
try {
const res = post.isFavorite > 0 ? await destroyFavoritePost(post.id) : await createFavoritePost(post.id)
console.log(res)

if (res.status === 201 || res.status === 204) {
const res = await searchPosts(keyword, 1, per_page * (page - 1))
setPosts(res?.data)
} else {
console.log('Something went wrong')
}
} catch (err) {
console.log(err)
}
}

return (
<div>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<Header />
<Box display="flex" alignItems="center">
<Box mx="auto" mt={120} borderBottomWidth={1} borderColor="gray">
{posts.map((post) => {
return (
<Box key={post.id} position="relative" width={[380, null, 440]} height={[80, null, 120]} borderTopWidth={1} borderColor="gray" py={[16, null, 18]} px={[6, null, 8.5]}>
<Box display="flex" position="absolute" top={[16, null, 18]} right={[6, null, 8.5]}>
<Clickable onClick={() => handleFavoritePost(post)} display="flex">
<Svg name="Star" height={14} width={14} my="auto" fill={post.isFavorite > 0 ? 'gold' : null} />
</Clickable>
<Link display="flex" href={`/posts/${post.id}/edit`} ml={15}>
<Svg name="Edit" height={12} width={12} my="auto" />
</Link>
<Clickable onClick={() => handleDestroyPost(post)} display="flex" ml={15}>
<Svg name="Trash" height={12} width={12} my="auto" />
</Clickable>
</Box>
<Box display="flex">
<Text color="dimggray" fontSize={[18, null, 21]}>
{post.id},
</Text>
<Link href={`/posts/${post.id}`} color="gray" hoverColor="darkgray" style={{ textDecoration: 'underline' }}>
<Text color="black" fontSize={[18, null, 21]} ml={6}>
{post.title}
</Text>
</Link>
</Box>
<Box display="flex" position="absolute" bottom={0} right={0}>
<Box display="flex" style={{ gap: '6px' }} mr={15}>
{post.tags.map((tag) => (
<Link href={'/tags/' + tag.name} key={tag.id}>
<Text display="flex" color="dimgray" fontSize={12} style={{ textDecoration: 'underline' }}>
{tag.name}
</Text>
</Link>
))}
</Box>
<Text display="flex" color="dimgray" fontSize={12} mr={15}>
{post.favoritePostsCount}
<Text display="inline" ml={2}>
いいね
</Text>
</Text>
<Text color="dimgray" fontSize={12}>
{post.createdAt}
</Text>
</Box>
</Box>
)
})}
</Box>
</Box>
<Box display="flex" mt={30}>
<Clickable onClick={() => handleGetMorePosts()} width={[280, null, 320]} height={[44, null, 50]} borderRadius={[44 / 2, null, 50 / 2]} hoverShadow="silver" borderColor="dimgray" borderWidth={1} display="flex" alignItems="center" justifyContent="center" overflow="hidden" mx="auto">
<Text color="gray" fontSize={[12, null, 16]}>
もっと読み込む
</Text>
</Clickable>
</Box>
<Footer />
</main>
</div>
)
}

ヘッダーの検索フォームで検索すると、検索ページに移動し、検索結果が表示される。 別の単語で検索したい場合は、そのままフォームにキーワードを入れて送信すれば結果が表示される。

result

git add .
git commit -m "Create a search form and result page"