fulltext_searchable 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|