fulltext_searchable 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/.gitignore +12 -0
- data/.rspec +2 -0
- data/Gemfile +20 -0
- data/LICENSE.txt +20 -0
- data/README.md +4 -0
- data/Rakefile +1 -0
- data/app/models/fulltext_index.rb +163 -0
- data/fulltext_searchable.gemspec +33 -0
- data/lib/fulltext_searchable/active_record.rb +208 -0
- data/lib/fulltext_searchable/engine.rb +4 -0
- data/lib/fulltext_searchable/mysql2_adapter.rb +64 -0
- data/lib/fulltext_searchable/version.rb +3 -0
- data/lib/fulltext_searchable.rb +50 -0
- data/lib/rails/generators/fulltext_searchable/fulltext_searchable_generator.rb +46 -0
- data/lib/rails/generators/fulltext_searchable/templates/initializer.rb +7 -0
- data/lib/rails/generators/fulltext_searchable/templates/migration.rb +9 -0
- data/lib/rails/generators/fulltext_searchable/templates/schema.rb +5 -0
- data/lib/tasks/fulltext_searchable.rake +27 -0
- data/spec/dummy/Rakefile +7 -0
- data/spec/dummy/app/controllers/application_controller.rb +3 -0
- data/spec/dummy/app/helpers/application_helper.rb +2 -0
- data/spec/dummy/app/models/blog.rb +7 -0
- data/spec/dummy/app/models/comment.rb +6 -0
- data/spec/dummy/app/models/news.rb +4 -0
- data/spec/dummy/app/models/reply.rb +4 -0
- data/spec/dummy/app/models/user.rb +5 -0
- data/spec/dummy/app/views/layouts/application.html.erb +14 -0
- data/spec/dummy/config/application.rb +44 -0
- data/spec/dummy/config/boot.rb +10 -0
- data/spec/dummy/config/database.yml.example +19 -0
- data/spec/dummy/config/environment.rb +5 -0
- data/spec/dummy/config/environments/development.rb +26 -0
- data/spec/dummy/config/environments/production.rb +49 -0
- data/spec/dummy/config/environments/test.rb +35 -0
- data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/spec/dummy/config/initializers/fulltext_searchable.rb +7 -0
- data/spec/dummy/config/initializers/inflections.rb +10 -0
- data/spec/dummy/config/initializers/mime_types.rb +5 -0
- data/spec/dummy/config/initializers/secret_token.rb +7 -0
- data/spec/dummy/config/initializers/session_store.rb +8 -0
- data/spec/dummy/config/locales/en.yml +5 -0
- data/spec/dummy/config/routes.rb +66 -0
- data/spec/dummy/config.ru +4 -0
- data/spec/dummy/db/migrate/20110119090740_create_blogs.rb +15 -0
- data/spec/dummy/db/migrate/20110119090753_create_news.rb +15 -0
- data/spec/dummy/db/migrate/20110124031824_create_users.rb +13 -0
- data/spec/dummy/db/migrate/20110203091209_create_comments.rb +14 -0
- data/spec/dummy/db/migrate/20110215091428_create_fulltext_indices_table.rb +9 -0
- data/spec/dummy/public/404.html +26 -0
- data/spec/dummy/public/422.html +26 -0
- data/spec/dummy/public/500.html +26 -0
- data/spec/dummy/public/favicon.ico +0 -0
- data/spec/dummy/public/javascripts/application.js +2 -0
- data/spec/dummy/public/javascripts/controls.js +965 -0
- data/spec/dummy/public/javascripts/dragdrop.js +974 -0
- data/spec/dummy/public/javascripts/effects.js +1123 -0
- data/spec/dummy/public/javascripts/prototype.js +6001 -0
- data/spec/dummy/public/javascripts/rails.js +175 -0
- data/spec/dummy/public/stylesheets/.gitkeep +0 -0
- data/spec/dummy/public/stylesheets/scaffold.css +56 -0
- data/spec/dummy/script/rails +6 -0
- data/spec/fulltext_searchable_spec.rb +7 -0
- data/spec/models/blog_spec.rb +132 -0
- data/spec/models/comment_spec.rb +9 -0
- data/spec/models/fulltext_index_spec.rb +114 -0
- data/spec/models/news_spec.rb +100 -0
- data/spec/spec_helper.rb +44 -0
- data/spec/support/factories/blogs.rb +29 -0
- data/spec/support/factories/comments.rb +7 -0
- data/spec/support/factories/news.rb +23 -0
- data/spec/support/factories/users.rb +27 -0
- metadata +364 -0
data/.document
ADDED
data/.gitignore
ADDED
data/.rspec
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
source "http://rubygems.org"
|
2
|
+
|
3
|
+
if ENV['RAILS_VER'] == '3.0'
|
4
|
+
gem "rails", "~> 3.0.5"
|
5
|
+
gem "mysql2", "~> 0.2.6"
|
6
|
+
gem 'will_paginate', :git => 'git://github.com/mislav/will_paginate.git', :branch => 'rails3'
|
7
|
+
else
|
8
|
+
gem "rails", "~> 3.0"
|
9
|
+
gem "mysql2"
|
10
|
+
gem 'will_paginate', "~> 3.0.4"
|
11
|
+
end
|
12
|
+
|
13
|
+
if ENV['PARANOID'] == 'original'
|
14
|
+
gem 'acts_as_paranoid'
|
15
|
+
else
|
16
|
+
gem 'rails3_acts_as_paranoid', :git => 'git://github.com/mshibuya/rails3_acts_as_paranoid.git'
|
17
|
+
end
|
18
|
+
gem "debugger"
|
19
|
+
|
20
|
+
gemspec
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2011 Mitsuhiro Shibuya
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
@@ -0,0 +1,163 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
require 'digest/md5'
|
4
|
+
|
5
|
+
##
|
6
|
+
# == 概要
|
7
|
+
# 全文検索インデックスとして機能するモデル。
|
8
|
+
#
|
9
|
+
class FulltextIndex < ActiveRecord::Base
|
10
|
+
self.table_name = FulltextSearchable::TABLE_NAME
|
11
|
+
self.primary_key = :_id
|
12
|
+
after_create :set_grn_insert_id
|
13
|
+
|
14
|
+
## :nodoc:
|
15
|
+
# Association
|
16
|
+
belongs_to :item, :polymorphic => true
|
17
|
+
|
18
|
+
class << self
|
19
|
+
cattr_accessor :target_models
|
20
|
+
|
21
|
+
##
|
22
|
+
# 全文検索で絞り込む。
|
23
|
+
#
|
24
|
+
# ==== phrase
|
25
|
+
# 検索する単語。スペース区切りで複数指定。
|
26
|
+
#
|
27
|
+
# ==== options
|
28
|
+
# ===== model
|
29
|
+
# 検索対象にするモデルを指定。
|
30
|
+
# 例: FulltextIndex.match('天気', :model => User)
|
31
|
+
# ===== with
|
32
|
+
# 検索対象にしたいレコード(AR object)を指定する。
|
33
|
+
# 例: FulltextIndex.match('天気', :with => @user)
|
34
|
+
#
|
35
|
+
def match(phrase, options={})
|
36
|
+
options = options.symbolize_keys
|
37
|
+
if phrase.is_a? String
|
38
|
+
phrase = phrase.split(/[\s ]/)
|
39
|
+
end
|
40
|
+
|
41
|
+
# モデルで絞り込む
|
42
|
+
model_keywords = []
|
43
|
+
if options[:model]
|
44
|
+
Array.wrap(options.delete(:model)).each do |t|
|
45
|
+
if t.is_a?(Class) && indexed?(t)
|
46
|
+
model_keywords.push(FulltextSearchable.to_model_keyword(t))
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
# レコードで絞り込む
|
51
|
+
item_keywords = []
|
52
|
+
if options[:with]
|
53
|
+
Array.wrap(options.delete(:with)).each do |t|
|
54
|
+
if indexed?(t.class)
|
55
|
+
item_keywords.push(FulltextSearchable.to_item_keyword(t))
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
[model_keywords, item_keywords].each do |keywords|
|
60
|
+
case keywords.count
|
61
|
+
when 0
|
62
|
+
when 1
|
63
|
+
phrase.unshift(keywords.first)
|
64
|
+
else
|
65
|
+
phrase.unshift("(#{keywords.join(' ')})")
|
66
|
+
end
|
67
|
+
end
|
68
|
+
phrase.map!{|i| '+' + i }
|
69
|
+
|
70
|
+
if connection.mroonga_match_against?
|
71
|
+
where("MATCH(`text`) AGAINST(? IN BOOLEAN MODE)",phrase.join(' ')).
|
72
|
+
order(sanitize_sql_array(["MATCH(`text`) AGAINST(? IN BOOLEAN MODE)",phrase.join(' ')]))
|
73
|
+
else
|
74
|
+
where("MATCH(`text`) AGAINST(? IN BOOLEAN MODE)",phrase.join(' ')).
|
75
|
+
order('`_score` DESC')
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
##
|
80
|
+
# 結果を取得し、インデックス元モデルの配列に変換。
|
81
|
+
#
|
82
|
+
def items(*args)
|
83
|
+
begin
|
84
|
+
# ActiveRecordの参照テーブル判定処理がおかしいので、SQLクエリ内にピリオドが現れる際に
|
85
|
+
# ピリオドの前の文字列を参照テーブル名と誤認し、eager loadをjoinの方針に切り替えて
|
86
|
+
# polymorphic associationをjoinでeager loadできず例外を吐く。
|
87
|
+
# なのでrescueしてeager loadなしで再度クエリを試みておく。
|
88
|
+
select('`_id`, `item_type`, `item_id`').
|
89
|
+
includes(:item).all(*args).map{|i| i.item }
|
90
|
+
rescue ActiveRecord::EagerLoadPolymorphicError
|
91
|
+
# eager loadなしで試みる
|
92
|
+
select('`_id`, `item_type`, `item_id`').all(*args).map{|i| i.item }
|
93
|
+
end
|
94
|
+
end
|
95
|
+
##
|
96
|
+
# 特定レコードの更新をインデックスに非同期で反映する。
|
97
|
+
#
|
98
|
+
def update(item)
|
99
|
+
l = lambda do
|
100
|
+
self.match(FulltextSearchable.to_item_keyword(item)).
|
101
|
+
includes(:item).all.each do |record|
|
102
|
+
next unless record.item
|
103
|
+
record.text = record.item.fulltext_keywords
|
104
|
+
record.save
|
105
|
+
end
|
106
|
+
end
|
107
|
+
if FulltextSearchable::Engine.config.respond_to?(:async) &&
|
108
|
+
FulltextSearchable::Engine.config.async
|
109
|
+
Thread.new{ l.call }
|
110
|
+
else
|
111
|
+
l.call
|
112
|
+
end
|
113
|
+
end
|
114
|
+
##
|
115
|
+
# 全文検索インデックスを再構築する。
|
116
|
+
#
|
117
|
+
def rebuild_all
|
118
|
+
if connection.tables.include?(FulltextSearchable::TABLE_NAME)
|
119
|
+
connection.drop_table FulltextSearchable::TABLE_NAME
|
120
|
+
end
|
121
|
+
connection.create_fulltext_index_table
|
122
|
+
|
123
|
+
ActiveRecord::Base.descendants.each do |model|
|
124
|
+
next unless model.table_exists? &&
|
125
|
+
model.fulltext_searchable?
|
126
|
+
n = 0
|
127
|
+
depends = model.fulltext_dependent_models
|
128
|
+
begin
|
129
|
+
rows = model.unscoped.includes(depends).offset(n).
|
130
|
+
limit(FulltextSearchable::PROCESS_UNIT).order(:id).all
|
131
|
+
rows.each do |r|
|
132
|
+
index = self.find_or_initialize_by_key(create_key(r))
|
133
|
+
index.update_attributes :text => r.fulltext_keywords, :item => r
|
134
|
+
end
|
135
|
+
n += rows.count
|
136
|
+
end while rows.count >= FulltextSearchable::PROCESS_UNIT
|
137
|
+
end
|
138
|
+
end
|
139
|
+
##
|
140
|
+
# key用文字列を生成する。
|
141
|
+
#
|
142
|
+
def create_key(item)
|
143
|
+
"#{item.class.name}_#{'% 10d' % item.id}"
|
144
|
+
end
|
145
|
+
|
146
|
+
protected
|
147
|
+
|
148
|
+
##
|
149
|
+
# 全文検索対応モデルかどうかを判定し、返す。
|
150
|
+
#
|
151
|
+
def indexed?(klass)
|
152
|
+
klass.respond_to?(:fulltext_searchable?) && klass.fulltext_searchable?
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
protected
|
157
|
+
##
|
158
|
+
# 互換のためレコード作成時に主キーをセット。
|
159
|
+
#
|
160
|
+
def set_grn_insert_id
|
161
|
+
self.id = connection.execute('SELECT last_insert_grn_id();').to_a.first.first
|
162
|
+
end
|
163
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'fulltext_searchable/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "fulltext_searchable"
|
8
|
+
spec.version = FulltextSearchable::VERSION
|
9
|
+
spec.authors = ["M.Shibuya"]
|
10
|
+
spec.email = ["mit.shibuya@gmail.com"]
|
11
|
+
spec.summary = %q{Rails engine that provides fulltext-search capability using mroonga}
|
12
|
+
spec.description = %q{Rails engine that provides fulltext-search capability using mroonga(formerly. groonga storage engine). Requires Rails ~> 3.0}
|
13
|
+
spec.homepage = "https://github.com/greenbell/fulltext_searchable"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files`.split($/)
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_dependency "activerecord", "~> 3.0"
|
22
|
+
spec.add_dependency "mysql2"
|
23
|
+
spec.add_dependency "htmlentities"
|
24
|
+
spec.add_development_dependency "bundler", "~> 1.3"
|
25
|
+
spec.add_development_dependency "cover_me"
|
26
|
+
spec.add_development_dependency "database_cleaner"
|
27
|
+
spec.add_development_dependency "factory_girl"
|
28
|
+
spec.add_development_dependency "faker"
|
29
|
+
spec.add_development_dependency "jeweler"
|
30
|
+
spec.add_development_dependency "rake"
|
31
|
+
spec.add_development_dependency "rdoc"
|
32
|
+
spec.add_development_dependency "rspec-rails"
|
33
|
+
end
|
@@ -0,0 +1,208 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require 'htmlentities'
|
3
|
+
|
4
|
+
module FulltextSearchable
|
5
|
+
##
|
6
|
+
# == 概要
|
7
|
+
# ActiveRecord::Baseを拡張するモジュール
|
8
|
+
#
|
9
|
+
module ActiveRecord # :nodoc:
|
10
|
+
def self.included(base) # :nodoc:
|
11
|
+
base.extend(ClassMethods)
|
12
|
+
end
|
13
|
+
#
|
14
|
+
# ActiveRecord::Baseにextendされるモジュール
|
15
|
+
#
|
16
|
+
module ClassMethods
|
17
|
+
##
|
18
|
+
# 全文検索機能を有効にする。
|
19
|
+
#
|
20
|
+
# ==== columns
|
21
|
+
# 全文検索の対象とするカラムを指定。
|
22
|
+
#
|
23
|
+
# ==== 例:
|
24
|
+
# fulltext_searchable :title, :body
|
25
|
+
#
|
26
|
+
def fulltext_searchable(columns=[], options={}, &block)
|
27
|
+
options = options.symbolize_keys
|
28
|
+
cattr_accessor :fulltext_columns,
|
29
|
+
:fulltext_keyword_proc, :fulltext_referenced_columns
|
30
|
+
|
31
|
+
referenced = options.delete(:referenced)
|
32
|
+
self.fulltext_columns = Array.wrap(columns)
|
33
|
+
self.fulltext_referenced_columns = Array.wrap(referenced) if referenced
|
34
|
+
self.fulltext_keyword_proc = block
|
35
|
+
|
36
|
+
class_eval <<-EOV
|
37
|
+
has_one :fulltext_index, {
|
38
|
+
:as => :item,
|
39
|
+
:conditions => proc{ {:key => FulltextIndex.create_key(self) } }
|
40
|
+
}
|
41
|
+
|
42
|
+
include FulltextSearchable::ActiveRecord::Behaviors
|
43
|
+
|
44
|
+
after_commit :save_fulltext_index
|
45
|
+
after_destroy :destroy_fulltext_index
|
46
|
+
EOV
|
47
|
+
if self.fulltext_referenced_columns
|
48
|
+
class_eval <<-EOV
|
49
|
+
before_save :check_fulltext_changes
|
50
|
+
EOV
|
51
|
+
end
|
52
|
+
end
|
53
|
+
##
|
54
|
+
# 全文検索対応モデルかどうかを返す。
|
55
|
+
#
|
56
|
+
def fulltext_searchable?
|
57
|
+
self.ancestors.include?(
|
58
|
+
::FulltextSearchable::ActiveRecord::Behaviors)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
#
|
62
|
+
# 各モデルにincludeされるモジュール
|
63
|
+
#
|
64
|
+
module Behaviors
|
65
|
+
extend ActiveSupport::Concern
|
66
|
+
|
67
|
+
module ClassMethods
|
68
|
+
##
|
69
|
+
# 各モデルに対し全文検索を行う。
|
70
|
+
#
|
71
|
+
def fulltext_match(phrase)
|
72
|
+
FulltextIndex.match(phrase, :model => self)
|
73
|
+
end
|
74
|
+
|
75
|
+
##
|
76
|
+
# eager loadのために全文検索対応モデルが依存する他のモデルを返す。
|
77
|
+
#
|
78
|
+
def fulltext_dependent_models(columns=nil)
|
79
|
+
columns ||= fulltext_columns
|
80
|
+
if columns.is_a? Hash
|
81
|
+
columns = Array.wrap(columns)
|
82
|
+
end
|
83
|
+
if columns.is_a? Array
|
84
|
+
result = []
|
85
|
+
columns.flatten!
|
86
|
+
columns.each do |i|
|
87
|
+
if i.is_a?(Hash)
|
88
|
+
i.each do |k,v|
|
89
|
+
if v.is_a?(Hash) || v.is_a?(Array)
|
90
|
+
r = fulltext_dependent_models(v)
|
91
|
+
if r
|
92
|
+
result.push({k=>r})
|
93
|
+
else
|
94
|
+
result.push(k)
|
95
|
+
end
|
96
|
+
elsif v.to_s.downcase != 'html'
|
97
|
+
result.push(k)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
case result.count
|
103
|
+
when 0
|
104
|
+
nil
|
105
|
+
when 1
|
106
|
+
result.first
|
107
|
+
else
|
108
|
+
result
|
109
|
+
end
|
110
|
+
else
|
111
|
+
nil
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
##
|
117
|
+
# レコードの内容を全文検索インデックス用に変換
|
118
|
+
#
|
119
|
+
def fulltext_keywords
|
120
|
+
# 論理削除されていたら空に
|
121
|
+
return '' if self.respond_to?(:deleted?) && self.deleted?
|
122
|
+
[
|
123
|
+
FulltextSearchable.to_model_keyword(self.class.name),
|
124
|
+
FulltextSearchable.to_item_keyword(self),
|
125
|
+
].
|
126
|
+
tap{|a| a.push(fulltext_keyword_proc.call) if fulltext_keyword_proc }.
|
127
|
+
concat(collect_fulltext_keywords(self, fulltext_columns)).
|
128
|
+
flatten.join(' ')
|
129
|
+
end
|
130
|
+
|
131
|
+
private
|
132
|
+
|
133
|
+
##
|
134
|
+
# before_saveにフック。全文検索対象カラムが変更されているかどうか調べる。
|
135
|
+
#
|
136
|
+
def check_fulltext_changes
|
137
|
+
@fulltext_change = fulltext_referenced_columns &&
|
138
|
+
fulltext_referenced_columns.any?{|i| changes[i]}
|
139
|
+
true
|
140
|
+
end
|
141
|
+
##
|
142
|
+
# after_commitにフック。
|
143
|
+
#
|
144
|
+
def save_fulltext_index
|
145
|
+
if self.fulltext_index
|
146
|
+
if !fulltext_index.text.empty? && (!defined?(@fulltext_change) || @fulltext_change)
|
147
|
+
FulltextIndex.update(self)
|
148
|
+
else
|
149
|
+
self.fulltext_index.text = fulltext_keywords
|
150
|
+
self.fulltext_index.save
|
151
|
+
end
|
152
|
+
else
|
153
|
+
self.create_fulltext_index(
|
154
|
+
:key => FulltextIndex.create_key(self),
|
155
|
+
:text => fulltext_keywords
|
156
|
+
)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
##
|
160
|
+
# after_destroyにフック。全文検索インデックスを削除
|
161
|
+
#
|
162
|
+
def destroy_fulltext_index
|
163
|
+
return unless fulltext_index
|
164
|
+
unless persisted?
|
165
|
+
fulltext_index.destroy
|
166
|
+
else
|
167
|
+
fulltext_index.update_attributes(:text => '')
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def collect_fulltext_keywords(target, columns)
|
172
|
+
result = []
|
173
|
+
return result unless target
|
174
|
+
if columns.is_a? Hash
|
175
|
+
columns = Array.wrap(columns)
|
176
|
+
end
|
177
|
+
unless columns.is_a? Array
|
178
|
+
return result.push(target.send(columns).to_s)
|
179
|
+
end
|
180
|
+
columns.flatten!
|
181
|
+
columns.each do |column|
|
182
|
+
if column.is_a? Hash
|
183
|
+
column.each do |k,v|
|
184
|
+
if v.to_s.downcase == 'html'
|
185
|
+
result.push(
|
186
|
+
HTMLEntities.new(:xhtml1).decode(
|
187
|
+
target.send(k.to_s).to_s.gsub(/<[^>]*>/ui,'')
|
188
|
+
).gsub(/[ \s]+/u, ' ') # contains
|
189
|
+
)
|
190
|
+
else
|
191
|
+
Array.wrap(target.send(k)).each do |t|
|
192
|
+
result.concat([
|
193
|
+
FulltextSearchable.to_item_keyword(t),
|
194
|
+
collect_fulltext_keywords(t, v)
|
195
|
+
])
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
199
|
+
else
|
200
|
+
result.push(collect_fulltext_keywords(target, column))
|
201
|
+
end
|
202
|
+
end
|
203
|
+
result.flatten
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module FulltextSearchable
|
4
|
+
module Mysql2Adapter
|
5
|
+
def create_fulltext_index_table
|
6
|
+
execute( <<SQL
|
7
|
+
CREATE TABLE IF NOT EXISTS `#{::FulltextSearchable::TABLE_NAME}` (
|
8
|
+
`_id` INT(11),
|
9
|
+
`key` VARCHAR(32),
|
10
|
+
`item_type` VARCHAR(255),
|
11
|
+
`item_id` INT(11),
|
12
|
+
`text` TEXT,
|
13
|
+
#{"`_score` FLOAT," unless mroonga_match_against?}
|
14
|
+
PRIMARY KEY(`key`),
|
15
|
+
#{"UNIQUE " if mroonga_unique_hash_index_safe?}INDEX(`_id`) USING HASH,
|
16
|
+
FULLTEXT INDEX (`text`)
|
17
|
+
) ENGINE = #{mroonga_storage_engine_name} COLLATE utf8_unicode_ci;
|
18
|
+
SQL
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
def mroonga_match_against?
|
23
|
+
mroonga_version >= '1.2'
|
24
|
+
end
|
25
|
+
|
26
|
+
def mroonga_version
|
27
|
+
execute("SHOW VARIABLES LIKE '#{mroonga_storage_engine_name}_version';").map(&:last).first ||
|
28
|
+
'0.0' # older than 1.0
|
29
|
+
end
|
30
|
+
|
31
|
+
def mroonga_storage_engine_name
|
32
|
+
@mroonga_storage_engine_name ||=
|
33
|
+
execute('SHOW ENGINES;').map(&:first).find{|name| name =~ /.+roonga/} or
|
34
|
+
raise "mroonga or groonga storage engine is not installed"
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
##
|
40
|
+
# mroonga on MacOS X fails truncation/deletion of unique hash indexed table.
|
41
|
+
# As a workaround, we actually temporary table with unique hash index and
|
42
|
+
# see if it is safe to truncate it.
|
43
|
+
#
|
44
|
+
def mroonga_unique_hash_index_safe?
|
45
|
+
temporary_table_name = ::FulltextSearchable::TABLE_NAME + '_temp_' + Time.now.to_i.to_s
|
46
|
+
execute( <<SQL
|
47
|
+
CREATE TABLE IF NOT EXISTS `#{temporary_table_name}` (
|
48
|
+
`_id` INT(11),
|
49
|
+
UNIQUE INDEX(`_id`) USING HASH
|
50
|
+
) ENGINE = #{mroonga_storage_engine_name} COLLATE utf8_unicode_ci;
|
51
|
+
SQL
|
52
|
+
)
|
53
|
+
safe = true
|
54
|
+
begin
|
55
|
+
execute("TRUNCATE TABLE `#{temporary_table_name}`;")
|
56
|
+
rescue
|
57
|
+
safe = false
|
58
|
+
end
|
59
|
+
drop_table temporary_table_name
|
60
|
+
safe
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
$:.unshift(File.dirname(__FILE__))
|
3
|
+
|
4
|
+
require 'digest/md5'
|
5
|
+
##
|
6
|
+
# == 概要
|
7
|
+
# モデルを全文検索対応にするプラグイン
|
8
|
+
#
|
9
|
+
module FulltextSearchable
|
10
|
+
require 'fulltext_searchable/engine' if defined?(Rails)
|
11
|
+
|
12
|
+
# 再構築タスク時の一回の処理レコード数
|
13
|
+
PROCESS_UNIT = 1000
|
14
|
+
TABLE_NAME = 'fulltext_indices'
|
15
|
+
|
16
|
+
class << self
|
17
|
+
def to_model_keyword(model)
|
18
|
+
'' + Digest::MD5.hexdigest('FulltextSearchable_'+model.to_s)[0,9] + ''
|
19
|
+
end
|
20
|
+
def to_item_keyword(item)
|
21
|
+
'' + Digest::MD5.hexdigest('FulltextSearchable_'+item.class.to_s)[0,8] + '_' + item.id.to_s + ''
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
autoload :ActiveRecord, 'fulltext_searchable/active_record'
|
26
|
+
autoload :Mysql2Adapter, 'fulltext_searchable/mysql2_adapter'
|
27
|
+
end
|
28
|
+
|
29
|
+
ActiveSupport.on_load(:active_record) do
|
30
|
+
ActiveRecord::Base.class_eval { include FulltextSearchable::ActiveRecord }
|
31
|
+
|
32
|
+
require 'active_record/connection_adapters/mysql2_adapter'
|
33
|
+
ActiveRecord::ConnectionAdapters::Mysql2Adapter.
|
34
|
+
send(:include, FulltextSearchable::Mysql2Adapter)
|
35
|
+
|
36
|
+
ActiveRecord::SchemaDumper.class_eval do
|
37
|
+
def table_with_grn(table, stream)
|
38
|
+
if table.to_s == ::FulltextSearchable::TABLE_NAME
|
39
|
+
tbl = StringIO.new
|
40
|
+
tbl.puts " create_fulltext_index_table"
|
41
|
+
tbl.puts ""
|
42
|
+
tbl.rewind
|
43
|
+
stream.print tbl.read
|
44
|
+
else
|
45
|
+
table_without_grn(table, stream)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
alias_method_chain :table, :grn
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
require 'rails/generators/migration'
|
3
|
+
|
4
|
+
class FulltextSearchableGenerator < Rails::Generators::Base
|
5
|
+
include Rails::Generators::Migration
|
6
|
+
|
7
|
+
def self.source_root
|
8
|
+
File.join(File.dirname(__FILE__), 'templates')
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.next_migration_number(dirname) #:nodoc:
|
12
|
+
if ActiveRecord::Base.timestamped_migrations
|
13
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
14
|
+
else
|
15
|
+
"%.3d" % (current_migration_number(dirname) + 1)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
|
20
|
+
# Every method that is declared below will be automatically executed when the generator is run
|
21
|
+
|
22
|
+
def create_migration_file
|
23
|
+
f = File.open File.join(File.dirname(__FILE__), 'templates', 'schema.rb')
|
24
|
+
schema = f.read; f.close
|
25
|
+
|
26
|
+
schema.gsub!(/ActiveRecord::Schema.*\n/, '')
|
27
|
+
schema.gsub!(/^end\n*$/, '')
|
28
|
+
|
29
|
+
f = File.open File.join(File.dirname(__FILE__), 'templates', 'migration.rb')
|
30
|
+
migration = f.read; f.close
|
31
|
+
migration.gsub!(/SCHEMA_AUTO_INSERTED_HERE/, schema)
|
32
|
+
|
33
|
+
tmp = File.open "tmp/~migration_ready.rb", "w"
|
34
|
+
tmp.write migration
|
35
|
+
tmp.close
|
36
|
+
|
37
|
+
migration_template File.expand_path(tmp.path),
|
38
|
+
'db/migrate/create_fulltext_indices_table.rb'
|
39
|
+
remove_file 'tmp/~migration_ready.rb'
|
40
|
+
end
|
41
|
+
|
42
|
+
def copy_initializer_file
|
43
|
+
copy_file 'initializer.rb', 'config/initializers/fulltext_searchable.rb'
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|