Ludia 用の Rails プラグイン acts_as_ludia を作りました
第四十六回
使い方や機能など,詳しくは後で書きます書きました.
acts_as_ludiaの更新情報もご覧下さい.
acts_as_ludiaの概要と機能
LudiaによるPostgreSQLの高速全文検索機能をRuby on Railsから利用するためのプラグインです.名前はacts_as_ludiaといいます(そのまんまですいません).
今までRailsからLudiaを使う場合,findのconditionsオプションにクエリをごりごり書く必要がありました.
Model.find(:all, :conditions => "body @@ '*D+ ruby rails'")
これが,acts_as_ludiaを利用すると以下のように簡潔にコードを書くことができます.もちろん,AND/OR検索や日本語での検索も可能です.
Model.find_fulltext(:body => "ruby rails")
また,Ludia 1.0.0から追加された検索単語周辺の文章(いわゆるスニペット(snippet))を簡単な書式で検索結果と同時に取得することができます.
Model.find_fulltext({:body => "ruby rails"}, :snippet => true)
このエントリの続きには,acts_as_ludiaの導入方法,使い方について書いてあります.
デモページ
acts_as_ludiaを使ってさくっとはてブ全文検索を作ってみました.
acts_as_ludia を使ってLudiaのデモWebアプリケーションを作りました - のほほん徒然
acts_as_ludiaの導入方法
acts_as_ludiaの導入は以下の手順で行います.なお,Ludiaの導入はすでに済んでいて,正常に稼動しているとします.
- Railsアプリケーションの作成
- ダウンロードと配置
- Modelへの追記
適当なRailsアプリケーションの作成
acts_as_ludiaプラグインを利用するためのRailsアプリケーションをPostgreSQLをデータベースに利用して作成します.すでにPostgreSQLを利用しているアプリケーションがあり,そこに導入する場合,この項は読み飛ばしていただいてかまいません.
ここでは,アプリケーション名をludiaとし,以下の手順で作成します.
- railsコマンドによる雛形作成
- config/database.yml修正
- config/environment.rbへの追記
- データベースとテーブルの作成
railsコマンドによる雛形作成
PostgreSQLを利用するよう明示的に指定しています.$ rails ludia --database=postgresql
config/database.yml修正
以下のようにdatabase.ymlを修正します(コメント部分は削ってあります).なお,PostgreSQLのユーザとしてrailsをパスワードrailsで作ってあるものとします.login: &login adapter: postgresql username: rails password: rails host: localhost development: database: ludia_development <<: *login test: database: ludia_test <<: *login production: database: ludia_production <<: *login
config/environment.rbへの追記
なんのことはありません,日本語を扱うための記述を先頭に追加するだけです.# 以下の2行をenvironment.rbの先頭に追加 $KCODE = 'u' require 'jcode'
データベースとテーブルの作成
PostgreSQLにデータベースとテーブルを作成します.今回はまず,データベースの作成とLudia用のSQLを流し込みます.
$ createdb ludia_development -U rails Password for user rails: CREATE DATABASE $ psql -f vendor/plugin/acts_as_ludia/share/pgsenna2.sql ludia_development -U rails Password for user rails: CREATE FUNCTION CREATE FUNCTION (中略) CREATE OPERATOR CLASS CREATE OPERATOR CLASS
次に,Migrationを利用してテーブルを作成します.ついでにModelも作っちゃいましょう.
$ ruby script/generate model Test
適当なエディタでマイグレーションファイル(#{RAILS_ROOT}/db/migrate/001_create_tests.rb)を開き,以下のように記述します.
class CreateTests < ActiveRecord::Migration def self.up create_table :tests do |t| t.column :col1, :text t.column :col2, :string end execute(%|CREATE INDEX index1 ON tests USING fulltext(col1);|) execute(%|CREATE INDEX index2 ON tests USING fulltextb((col2::text));|) end def self.down execute(%|DROP INDEX index1;|) execute(%|DROP INDEX index2;|) drop_table :tests execute(%|SELECT pgs2destroy();|) end end
rakeを実行してテーブルを作成します.
$ rake migrate
ダウンロードと配置
script/pluginを利用したインストールが可能です.下記追記部分をご覧ください.
wgetやcurlなどを利用して以下のURLからacts_as_ludiaをダウンロードして,解凍後アプリケーションのプラグインディレクトリに配置してください.
acts_as_ludia-0.2.0.tar.gz
※これはrobyforgeでプロジェクトが開始され,script/pluginでインストールできるようになるまでの限定措置です.リンク先がなくなったり,最新版ではなくなる可能性があります.
追記(2007/03/19 21:30)
rubyforgeへのプロジェクト登録が完了したので,script/pluginを利用したインストールが可能になりました.#{RAILS_ROOT}で以下のコマンドを実行することでダウンロードとインストールが可能です.$ ruby script/plugin install svn://rubyforge.org/var/svn/actsasludia
acts_as_ludiaの使い方
acts_as_ludiaの使い方はいたって簡単です.ActiveRecord::Baseにfind_fulltextメソッドが追加されていますので,通常のfindのように呼び出すことでLudiaの全文検索を利用することができます.
ここでは,実際の利用例とともに使い方を説明します.
テストデータ作成
まず,テストデータを流し込んでおきます.前述のacts_as_ludiaの導入方法にしたがってRailsアプリケーション(ludia)を作成された方はTestというモデルがあり,Ludiaのインデックスが張ってあると思いますので,そこにデータを作ってみます.script/consoleから以下のコード*1を実行してください.
Test.create(:col1=>"すもももももももものうち",:col2=>"あの壺はよいものだ") Test.create(:col1=>"ももから生まれた桃太郎",:col2=>"あの壷はよいものだ") Test.create(:col1=>"ももんが飛んだら木が揺れた",:col2=>"あの壺は悪いものだ") Test.create(:col1=>"あなたももうけ話が聞きたいの",:col2=>"あの壷は悪いものだ") Test.create(:col1=>"昨夜もももの缶詰を開けた",:col2=>"この壷はよいものだ") Test.create(:col1=>"にわにはにわにわとりがいる",:col2=>"この壺はよいものだ")
find_fulltextを利用した全文検索
acts_as_ludiaを利用するとActiveRecord::Baseにfind_fulltextというメソッドが追加されます.
find_fulltext(query, options={})
上記のようにfind_fulltextメソッドは引数を一つ(とfindオプション)をとります.queryにはハッシュで検索対象列と検索語を渡します.
例えば,col1列に「もも」という語を含む行を検索する場合,以下のように実行します(見やすいようにppを利用しています).
>> pp Test.find_fulltext(:col1 => "もも") [#<Test:0xb768c880 @attributes= {"id"=>"1", "col1"=>"すもももももももものうち", "col2"=>"あの壺はよいものだ"}>, #<Test:0xb768c650 @attributes= {"id"=>"5", "col1"=>"昨夜もももの缶詰を開けた", "col2"=>"この壷はよいものだ"}>, #<Test:0xb768c614 @attributes= {"id"=>"2", "col1"=>"ももから生まれた桃太郎", "col2"=>"あの壷はよいものだ"}>] => nil
また,クエリハッシュの値に半角の空白で区切って語を入力するとAND検索になります.
>> pp Test.find_fulltext(:col1 => "もも 桃太郎") [#<Test:0xb6c9d680 @attributes= {"id"=>"2", "col1"=>"ももから生まれた桃太郎", "col2"=>"あの壷はよいものだ"}>] => nil
さらに,クエリハッシュの値に配列を渡すと配列の各要素のOR検索になります.以下ではAND検索と組み合わせた例を示します.
>> pp Test.find_fulltext(:col2 => ["あの 壺", "悪い"]) [#<Test:0xb6c652e4 @attributes= {"id"=>"3", "col1"=>"ももんが飛んだら木が揺れた", "col2"=>"あの壺は悪いものだ"}>, #<Test:0xb6c652a8 @attributes= {"id"=>"4", "col1"=>"あなたももうけ話が聞きたいの", "col2"=>"あの壷は悪いものだ"}>] => nil
さらに,複数列を対象とした全文検索も可能です.これは,クエリハッシュにキーと値を追加します.以下にcol1に「もも」を含み,かつcol2に「壺」を含む行を検索する例を示します.
>> pp Test.find_fulltext(:col1 => "もも", :col2 => "壺") [#<Test:0xb6c5c554 @attributes= {"id"=>"1", "col1"=>"すもももももももものうち", "col2"=>"あの壺はよいものだ"}>, #<Test:0xb6c5c518 @attributes= {"id"=>"3", "col1"=>"ももんが飛んだら木が揺れた", "col2"=>"あの壺は悪いものだ"}>] => nil
col1に「もも」を含むか,またはcol2に「壺」を含む行を検索することもできます.この場合,オプションに:all => trueを指定します.
※クエリ部分を単一のハッシュとするため{と}で囲んでいることに注意してください.
>> pp Test.find_fulltext({:col1 => "もも", :col2 => "壺"}, :all => true) [#<Test:0xb6c4bd58 @attributes= {"id"=>"1", "col1"=>"すもももももももものうち", "col2"=>"あの壺はよいものだ"}>, #<Test:0xb6c4bd1c @attributes= {"id"=>"2", "col1"=>"ももから生まれた桃太郎", "col2"=>"あの壷はよいものだ"}>, #<Test:0xb6c4bce0 @attributes= {"id"=>"3", "col1"=>"ももんが飛んだら木が揺れた", "col2"=>"あの壺は悪いものだ"}>, #<Test:0xb6c4bca4 @attributes= {"id"=>"5", "col1"=>"昨夜もももの缶詰を開けた", "col2"=>"この壷はよいものだ"}>, #<Test:0xb6c4bc68 @attributes= {"id"=>"6", "col1"=>"にわにはにわにわとりがいる", "col2"=>"この壺はよいものだ"}>] => nil
スニペットも一緒に取得する
find_fulltextメソッドには検索語周辺の文章(スニペット)を取得するオプションがあります.
利用方法は引数に:snippet => trueを追加します.
(結構重大な)注意点
Ludiaのスニペット取得関数の仕様(?)により,スニペット取得を利用する場合は検索できる列が一つに限定されます.:snippet => true のデフォルト動作(規約)
単純にfind_fulltextメソッドに:snippet => trueオプションを追加した場合,以下のスニペットオプションのdefault値が適用されます.- :length => 60
- スニペットの長さ(byte)を指定します.defaultは 60byte です.
- :decorations => ['<strong>', '</strong>']
- スニペット内で検索語の前後に追加する文字をそれぞれ指定します.defaultは<strong>タグで囲みます.
- :label => 'l_snippet'
- 得られるオブジェクトに対してスニペット用のラベルを指定します.defaultは"l_snippet"です.
>> pp Test.find_fulltext({:col1 => "桃太郎"}, :snippet => true) [#<Test:0xb7643540 @attributes= {"l_snippet"=>"ももから生まれた<strong>桃太郎</strong>", "id"=>"2", "col1"=>"ももから生まれた桃太郎", "col2"=>"あの壷はよいものだ"}>] => nil
:snippetへオプションを追加する
find_fulltextメソッドの:snippetオプションに明示的にスニペットオプション*2を指定することもできます.これは:snippetにハッシュで渡します.以下に全部指定した例を示します.>> pp Test.find_fulltext({:col1 => "桃太郎"}, :snippet => {:length => 20, :decorations => ["<em>", "</em>"], :label => "other_ludia_snippet_label"}) [#<Test:0xb75b51f0 @attributes= {"other_ludia_snippet_label"=>"まれた<em>桃太郎</em>", "id"=>"2", "col1"=>"ももから生まれた桃太郎", "col2"=>"あの壷はよいものだ"}>] => nil
とりあえず,今のところこんな感じです.これからも機能追加などしていきます.ご意見・ご要望などあれば気軽にコメントしてください.
デモページ
とりあえず,こんなものが簡単に作れるよということではてブ全文検索を作ってみました.
acts_as_ludia を使ってLudiaのデモWebアプリケーションを作りました - のほほん徒然
参考ページ
ソースコード
汚いソース(acts_as_ludia-0.2.0時点)ですがとりあえず置いておきますので,使ってみよう(または参考にしよう)という方はどうぞ.
#{RAILS_ROOT}/vendor/plugins/acts_as_ludia/init.rb
require File.dirname(__FILE__) + '/lib/ludia/acts_as_ludia' ActiveRecord::Base.send :include, Ludia::ActsAsLudia
#{RAILS_ROOT}/vendor/plugins/acts_as_ludia/lib/ludia/acts_as_ludia.rb
# ActsAsLudia module Ludia module ActsAsLudia def self.included(base) base.extend ClassMethods end module ClassMethods def acts_as_ludia(options = {}) return if self.included_modules.include?(Ludia::ActsAsLudia::InstanceMethods) send :include, Ludia::ActsAsLudia::InstanceMethods end end module InstanceMethods def self.included(base) base.extend SingletonMethods end module SingletonMethods # Model.find_fulltext({:col1 => ["hoge moge", "hoho"]}) # => SELECT * FROM models WHERE col1 @@ '*D+ hoge moge OR hoho'; # Model.find_fulltext({:col1 => "hoge", :col2 => ["moge", "hoho"]}, :all => true) # => SELECT * FROM models WHERE col1 @@ '*D+ hoge' OR col2 @@ '*D+ moge OR hoho'; # Model.find_fulltext({:col1 => "hoge moge"}, :snippet => true) # Model.find_fulltext({:col1 => "hoge moge"}, :snippet => {:length => 45, :decorations => ['<em>', '</em>']) def find_fulltext(query, options={}) raise ArgumentError, "wrong argument class" unless query.is_a? Hash raise ArgumentError, "one query is available, if use SNIPPET." if options[:snippet] && query.length > 1 conditions = [] query.each_pair do |column, string| string = string.join(' OR ') if string.is_a? Array conditions << %|#{column.to_s} @@ '*D+ #{string}'| end conjunction = options.delete(:all) ? ' OR ' : ' AND ' options[:conditions] = conditions.join(conjunction) option_select = snippet(query, options.delete(:snippet)) if options[:snippet] if option_select if options[:select] options[:select] += ", #{option_select}" else options[:select] = "*, #{option_select}" end end results = find(:all, options) end private def snippet(query, snippet_options) options = { :query => query.values.first.is_a?(Array) ? query.values.first.join(' ') : query.values.first, :length => 60, :column => query.keys.first.to_s, :escape => true, :decorations => ["<strong>", "</strong>"], :label => "l_snipett" } snippet_options = snippet_options.is_a?(Hash) ? snippet_options.reverse_merge(options) : options select_string = %|pgs2snippet1(1, #{snippet_options[:length]}, 1, | + %|'#{snippet_options[:decorations].first}', | + %|'#{snippet_options[:decorations][1]}', | + %|#{snippet_options[:escape] ? -1 : 0}, | + %|'#{snippet_options[:query]}', | + %|#{snippet_options[:column]}) | %|as #{snippet_options[:label]| end end end end end