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
@@ -0,0 +1,175 @@
|
|
1
|
+
(function() {
|
2
|
+
// Technique from Juriy Zaytsev
|
3
|
+
// http://thinkweb2.com/projects/prototype/detecting-event-support-without-browser-sniffing/
|
4
|
+
function isEventSupported(eventName) {
|
5
|
+
var el = document.createElement('div');
|
6
|
+
eventName = 'on' + eventName;
|
7
|
+
var isSupported = (eventName in el);
|
8
|
+
if (!isSupported) {
|
9
|
+
el.setAttribute(eventName, 'return;');
|
10
|
+
isSupported = typeof el[eventName] == 'function';
|
11
|
+
}
|
12
|
+
el = null;
|
13
|
+
return isSupported;
|
14
|
+
}
|
15
|
+
|
16
|
+
function isForm(element) {
|
17
|
+
return Object.isElement(element) && element.nodeName.toUpperCase() == 'FORM'
|
18
|
+
}
|
19
|
+
|
20
|
+
function isInput(element) {
|
21
|
+
if (Object.isElement(element)) {
|
22
|
+
var name = element.nodeName.toUpperCase()
|
23
|
+
return name == 'INPUT' || name == 'SELECT' || name == 'TEXTAREA'
|
24
|
+
}
|
25
|
+
else return false
|
26
|
+
}
|
27
|
+
|
28
|
+
var submitBubbles = isEventSupported('submit'),
|
29
|
+
changeBubbles = isEventSupported('change')
|
30
|
+
|
31
|
+
if (!submitBubbles || !changeBubbles) {
|
32
|
+
// augment the Event.Handler class to observe custom events when needed
|
33
|
+
Event.Handler.prototype.initialize = Event.Handler.prototype.initialize.wrap(
|
34
|
+
function(init, element, eventName, selector, callback) {
|
35
|
+
init(element, eventName, selector, callback)
|
36
|
+
// is the handler being attached to an element that doesn't support this event?
|
37
|
+
if ( (!submitBubbles && this.eventName == 'submit' && !isForm(this.element)) ||
|
38
|
+
(!changeBubbles && this.eventName == 'change' && !isInput(this.element)) ) {
|
39
|
+
// "submit" => "emulated:submit"
|
40
|
+
this.eventName = 'emulated:' + this.eventName
|
41
|
+
}
|
42
|
+
}
|
43
|
+
)
|
44
|
+
}
|
45
|
+
|
46
|
+
if (!submitBubbles) {
|
47
|
+
// discover forms on the page by observing focus events which always bubble
|
48
|
+
document.on('focusin', 'form', function(focusEvent, form) {
|
49
|
+
// special handler for the real "submit" event (one-time operation)
|
50
|
+
if (!form.retrieve('emulated:submit')) {
|
51
|
+
form.on('submit', function(submitEvent) {
|
52
|
+
var emulated = form.fire('emulated:submit', submitEvent, true)
|
53
|
+
// if custom event received preventDefault, cancel the real one too
|
54
|
+
if (emulated.returnValue === false) submitEvent.preventDefault()
|
55
|
+
})
|
56
|
+
form.store('emulated:submit', true)
|
57
|
+
}
|
58
|
+
})
|
59
|
+
}
|
60
|
+
|
61
|
+
if (!changeBubbles) {
|
62
|
+
// discover form inputs on the page
|
63
|
+
document.on('focusin', 'input, select, texarea', function(focusEvent, input) {
|
64
|
+
// special handler for real "change" events
|
65
|
+
if (!input.retrieve('emulated:change')) {
|
66
|
+
input.on('change', function(changeEvent) {
|
67
|
+
input.fire('emulated:change', changeEvent, true)
|
68
|
+
})
|
69
|
+
input.store('emulated:change', true)
|
70
|
+
}
|
71
|
+
})
|
72
|
+
}
|
73
|
+
|
74
|
+
function handleRemote(element) {
|
75
|
+
var method, url, params;
|
76
|
+
|
77
|
+
var event = element.fire("ajax:before");
|
78
|
+
if (event.stopped) return false;
|
79
|
+
|
80
|
+
if (element.tagName.toLowerCase() === 'form') {
|
81
|
+
method = element.readAttribute('method') || 'post';
|
82
|
+
url = element.readAttribute('action');
|
83
|
+
params = element.serialize();
|
84
|
+
} else {
|
85
|
+
method = element.readAttribute('data-method') || 'get';
|
86
|
+
url = element.readAttribute('href');
|
87
|
+
params = {};
|
88
|
+
}
|
89
|
+
|
90
|
+
new Ajax.Request(url, {
|
91
|
+
method: method,
|
92
|
+
parameters: params,
|
93
|
+
evalScripts: true,
|
94
|
+
|
95
|
+
onComplete: function(request) { element.fire("ajax:complete", request); },
|
96
|
+
onSuccess: function(request) { element.fire("ajax:success", request); },
|
97
|
+
onFailure: function(request) { element.fire("ajax:failure", request); }
|
98
|
+
});
|
99
|
+
|
100
|
+
element.fire("ajax:after");
|
101
|
+
}
|
102
|
+
|
103
|
+
function handleMethod(element) {
|
104
|
+
var method = element.readAttribute('data-method'),
|
105
|
+
url = element.readAttribute('href'),
|
106
|
+
csrf_param = $$('meta[name=csrf-param]')[0],
|
107
|
+
csrf_token = $$('meta[name=csrf-token]')[0];
|
108
|
+
|
109
|
+
var form = new Element('form', { method: "POST", action: url, style: "display: none;" });
|
110
|
+
element.parentNode.insert(form);
|
111
|
+
|
112
|
+
if (method !== 'post') {
|
113
|
+
var field = new Element('input', { type: 'hidden', name: '_method', value: method });
|
114
|
+
form.insert(field);
|
115
|
+
}
|
116
|
+
|
117
|
+
if (csrf_param) {
|
118
|
+
var param = csrf_param.readAttribute('content'),
|
119
|
+
token = csrf_token.readAttribute('content'),
|
120
|
+
field = new Element('input', { type: 'hidden', name: param, value: token });
|
121
|
+
form.insert(field);
|
122
|
+
}
|
123
|
+
|
124
|
+
form.submit();
|
125
|
+
}
|
126
|
+
|
127
|
+
|
128
|
+
document.on("click", "*[data-confirm]", function(event, element) {
|
129
|
+
var message = element.readAttribute('data-confirm');
|
130
|
+
if (!confirm(message)) event.stop();
|
131
|
+
});
|
132
|
+
|
133
|
+
document.on("click", "a[data-remote]", function(event, element) {
|
134
|
+
if (event.stopped) return;
|
135
|
+
handleRemote(element);
|
136
|
+
event.stop();
|
137
|
+
});
|
138
|
+
|
139
|
+
document.on("click", "a[data-method]", function(event, element) {
|
140
|
+
if (event.stopped) return;
|
141
|
+
handleMethod(element);
|
142
|
+
event.stop();
|
143
|
+
});
|
144
|
+
|
145
|
+
document.on("submit", function(event) {
|
146
|
+
var element = event.findElement(),
|
147
|
+
message = element.readAttribute('data-confirm');
|
148
|
+
if (message && !confirm(message)) {
|
149
|
+
event.stop();
|
150
|
+
return false;
|
151
|
+
}
|
152
|
+
|
153
|
+
var inputs = element.select("input[type=submit][data-disable-with]");
|
154
|
+
inputs.each(function(input) {
|
155
|
+
input.disabled = true;
|
156
|
+
input.writeAttribute('data-original-value', input.value);
|
157
|
+
input.value = input.readAttribute('data-disable-with');
|
158
|
+
});
|
159
|
+
|
160
|
+
var element = event.findElement("form[data-remote]");
|
161
|
+
if (element) {
|
162
|
+
handleRemote(element);
|
163
|
+
event.stop();
|
164
|
+
}
|
165
|
+
});
|
166
|
+
|
167
|
+
document.on("ajax:after", "form", function(event, element) {
|
168
|
+
var inputs = element.select("input[type=submit][disabled=true][data-disable-with]");
|
169
|
+
inputs.each(function(input) {
|
170
|
+
input.value = input.readAttribute('data-original-value');
|
171
|
+
input.removeAttribute('data-original-value');
|
172
|
+
input.disabled = false;
|
173
|
+
});
|
174
|
+
});
|
175
|
+
})();
|
File without changes
|
@@ -0,0 +1,56 @@
|
|
1
|
+
body { background-color: #fff; color: #333; }
|
2
|
+
|
3
|
+
body, p, ol, ul, td {
|
4
|
+
font-family: verdana, arial, helvetica, sans-serif;
|
5
|
+
font-size: 13px;
|
6
|
+
line-height: 18px;
|
7
|
+
}
|
8
|
+
|
9
|
+
pre {
|
10
|
+
background-color: #eee;
|
11
|
+
padding: 10px;
|
12
|
+
font-size: 11px;
|
13
|
+
}
|
14
|
+
|
15
|
+
a { color: #000; }
|
16
|
+
a:visited { color: #666; }
|
17
|
+
a:hover { color: #fff; background-color:#000; }
|
18
|
+
|
19
|
+
div.field, div.actions {
|
20
|
+
margin-bottom: 10px;
|
21
|
+
}
|
22
|
+
|
23
|
+
#notice {
|
24
|
+
color: green;
|
25
|
+
}
|
26
|
+
|
27
|
+
.field_with_errors {
|
28
|
+
padding: 2px;
|
29
|
+
background-color: red;
|
30
|
+
display: table;
|
31
|
+
}
|
32
|
+
|
33
|
+
#error_explanation {
|
34
|
+
width: 450px;
|
35
|
+
border: 2px solid red;
|
36
|
+
padding: 7px;
|
37
|
+
padding-bottom: 0;
|
38
|
+
margin-bottom: 20px;
|
39
|
+
background-color: #f0f0f0;
|
40
|
+
}
|
41
|
+
|
42
|
+
#error_explanation h2 {
|
43
|
+
text-align: left;
|
44
|
+
font-weight: bold;
|
45
|
+
padding: 5px 5px 5px 15px;
|
46
|
+
font-size: 12px;
|
47
|
+
margin: -7px;
|
48
|
+
margin-bottom: 0px;
|
49
|
+
background-color: #c00;
|
50
|
+
color: #fff;
|
51
|
+
}
|
52
|
+
|
53
|
+
#error_explanation ul li {
|
54
|
+
font-size: 12px;
|
55
|
+
list-style: square;
|
56
|
+
}
|
@@ -0,0 +1,6 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application.
|
3
|
+
|
4
|
+
APP_PATH = File.expand_path('../../config/application', __FILE__)
|
5
|
+
require File.expand_path('../../config/boot', __FILE__)
|
6
|
+
require 'rails/commands'
|
@@ -0,0 +1,132 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require 'spec_helper'
|
3
|
+
|
4
|
+
def thread_wait
|
5
|
+
Thread.list.each{|t| t.join if t != Thread.current }
|
6
|
+
end
|
7
|
+
|
8
|
+
describe Blog do
|
9
|
+
it "should be valid" do
|
10
|
+
Blog.superclass.should == ActiveRecord::Base
|
11
|
+
end
|
12
|
+
|
13
|
+
it "should be checkd with changes" do
|
14
|
+
@blog = Blog.new
|
15
|
+
@blog.should_receive(:check_fulltext_changes)
|
16
|
+
@blog.save
|
17
|
+
end
|
18
|
+
|
19
|
+
context "creation" do
|
20
|
+
it "should create fulltext index" do
|
21
|
+
FulltextIndex.match('お知らせ').items.should == []
|
22
|
+
@blog = Blog.new :title => '題名', :body => '<h1>お知らせ</h1>', :user => FactoryGirl.create(:hanako)
|
23
|
+
@blog.save
|
24
|
+
@blog.fulltext_index.text.should ==
|
25
|
+
"#{FulltextSearchable.to_model_keyword(Blog)} #{FulltextSearchable.to_item_keyword(@blog)} 題名 お知らせ #{FulltextSearchable.to_item_keyword(@blog.user)} 花子"
|
26
|
+
FulltextIndex.match('お知らせ').items.should == [@blog]
|
27
|
+
end
|
28
|
+
|
29
|
+
it "should create fulltext index with deep nested models" do
|
30
|
+
@user = FactoryGirl.create(:john)
|
31
|
+
@user.fulltext_index.text.should == '7d3ecc6a9 7d3ecc6a_1 john 392955b1_1 昨日の天気は a69403a7_1 超寒い!!!11<> 0e4e4340_1 1'
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
context "retrieval" do
|
36
|
+
before do
|
37
|
+
@blog = FactoryGirl.create(:today)
|
38
|
+
end
|
39
|
+
|
40
|
+
it "should return item" do
|
41
|
+
FulltextIndex.match('晴れ').items.should == [@blog]
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
context "updating" do
|
46
|
+
before do
|
47
|
+
@blog = FactoryGirl.create(:taro).blogs.first
|
48
|
+
end
|
49
|
+
|
50
|
+
it "should update fulltext index" do
|
51
|
+
FulltextIndex.match('晴れ 雨').items.should == []
|
52
|
+
@blog.body = '<DIV><FONT size="2"> </FONT>曇り時々晴れでところにより一時にわか雨です。</DIV>'
|
53
|
+
@blog.save
|
54
|
+
@blog.fulltext_index.reload.text.should ==
|
55
|
+
"#{FulltextSearchable.to_model_keyword(Blog)} #{FulltextSearchable.to_item_keyword(@blog)} 今日の天気は 曇り時々晴れでところにより一時にわか雨です。 #{FulltextSearchable.to_item_keyword(@blog.user)} 太郎"
|
56
|
+
FulltextIndex.match('晴れ 雨').items.should == [@blog]
|
57
|
+
end
|
58
|
+
|
59
|
+
it "should work with malformed html" do
|
60
|
+
FulltextIndex.match('晴れ 雨').items.should == []
|
61
|
+
@blog.body = '<DIV><FONT size="2"> 曇り<a name="abc">時々晴れで<b>ところにより</FONT>一時超にわか雨</b>です。</DIV>'
|
62
|
+
@blog.save
|
63
|
+
@blog.fulltext_index.reload.text.should ==
|
64
|
+
"#{FulltextSearchable.to_model_keyword(Blog)} #{FulltextSearchable.to_item_keyword(@blog)} 今日の天気は 曇り時々晴れでところにより一時超にわか雨です。 #{FulltextSearchable.to_item_keyword(@blog.user)} 太郎"
|
65
|
+
FulltextIndex.match('晴れ 雨').items.should == [@blog]
|
66
|
+
end
|
67
|
+
|
68
|
+
it "should also update fulltext index with update of associated model" do
|
69
|
+
@blog.user.update_attributes :name => '東京太郎'
|
70
|
+
@blog.fulltext_index.reload.text.should ==
|
71
|
+
"#{FulltextSearchable.to_model_keyword(Blog)} #{FulltextSearchable.to_item_keyword(@blog)} 今日の天気は 曇り時々晴れです。 #{FulltextSearchable.to_item_keyword(@blog.user)} 東京太郎"
|
72
|
+
FulltextIndex.match('晴れ 東京').items.should == [@blog]
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
context "deletion" do
|
77
|
+
before do
|
78
|
+
@blog = FactoryGirl.create(:today)
|
79
|
+
end
|
80
|
+
|
81
|
+
it "should destroy fulltext index" do
|
82
|
+
@blog.destroy
|
83
|
+
lambda do
|
84
|
+
@blog.fulltext_index.reload or raise ActiveRecord::RecordNotFound
|
85
|
+
end.should raise_error ActiveRecord::RecordNotFound
|
86
|
+
FulltextIndex.match('今日').items.should == []
|
87
|
+
end
|
88
|
+
|
89
|
+
it "should not care fulltext index if not exists" do
|
90
|
+
FulltextIndex.delete_all
|
91
|
+
lambda{ @blog.destroy }.should_not raise_error
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
context "updating asynchronously" do
|
96
|
+
before(:all) { FulltextSearchable::Engine.config.async = true }
|
97
|
+
after(:all) { FulltextSearchable::Engine.config.async = false }
|
98
|
+
before do
|
99
|
+
@user = FactoryGirl.create(:user)
|
100
|
+
@count = @user.blogs.count + 1
|
101
|
+
end
|
102
|
+
|
103
|
+
it "should update fulltext index" do
|
104
|
+
@user.name = 'taro'
|
105
|
+
@user.save
|
106
|
+
FulltextIndex.match('taro').count.should_not == @count
|
107
|
+
thread_wait
|
108
|
+
FulltextIndex.match('taro').count.should == @count
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
context "update optimization" do
|
113
|
+
before do
|
114
|
+
@user = FactoryGirl.create(:taro)
|
115
|
+
@blog = @user.blogs.first
|
116
|
+
end
|
117
|
+
|
118
|
+
it "should update related models with change of referenced attribute" do
|
119
|
+
@blog.title = @blog.title + 'foo'
|
120
|
+
FulltextIndex.should_receive(:update).with(@blog)
|
121
|
+
@blog.save
|
122
|
+
end
|
123
|
+
|
124
|
+
it "should not update related models with change of non-referenced attribute" do
|
125
|
+
@blog.body = @blog.body + 'foo'
|
126
|
+
FulltextIndex.should_not_receive(:update)
|
127
|
+
@blog.save
|
128
|
+
@blog.fulltext_index.reload.text.should ==
|
129
|
+
'392955b1b 392955b1_1 今日の天気は 曇り時々晴れです。foo 7d3ecc6a_1 太郎'
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require 'spec_helper'
|
3
|
+
|
4
|
+
describe FulltextIndex do
|
5
|
+
it "should be valid" do
|
6
|
+
FulltextIndex.superclass.should == ActiveRecord::Base
|
7
|
+
end
|
8
|
+
|
9
|
+
context "rebuilding" do
|
10
|
+
before do
|
11
|
+
@taisyaku = FactoryGirl.create(:taisyaku)
|
12
|
+
News.delete_all! @taisyaku.id
|
13
|
+
@soneki = FactoryGirl.create(:soneki)
|
14
|
+
News.update_all("body = '夕飯はカレーです。'", ['id = ?',@soneki.id])
|
15
|
+
end
|
16
|
+
it "should update fulltext index" do
|
17
|
+
FulltextIndex.all.map{|i| i.text}.should == [
|
18
|
+
"1b5f32164 1b5f3216_1 貸借対照表 営業年度の終了時、決算において資産、負債、資本がどれだけあるかを一定のルールにのっとって財務状態を表にしたもの",
|
19
|
+
"1b5f32164 1b5f3216_2 損益計算書 営業年度中の売り上げと経費、それを差し引いた利益(損失)を記載して表にしたもの",
|
20
|
+
]
|
21
|
+
FulltextIndex.rebuild_all
|
22
|
+
FulltextIndex.all.map{|i| i.text}.should == [
|
23
|
+
"1b5f32164 1b5f3216_2 損益計算書 夕飯はカレーです。",
|
24
|
+
]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
context "retrieval" do
|
29
|
+
before do
|
30
|
+
FactoryGirl.create(:taisyaku)
|
31
|
+
FactoryGirl.create(:soneki)
|
32
|
+
FactoryGirl.create(:eigyo)
|
33
|
+
FactoryGirl.create(:rieki)
|
34
|
+
@taro = FactoryGirl.create(:taro)
|
35
|
+
@jiro = FactoryGirl.create(:jiro)
|
36
|
+
end
|
37
|
+
it "should fulltext searchable with '営業'" do
|
38
|
+
FulltextIndex.match('営業').items.count.should == 4
|
39
|
+
end
|
40
|
+
|
41
|
+
it "should fulltext searchable with '営業 状態'" do
|
42
|
+
FulltextIndex.match('営業 状態').items.count.should == 2
|
43
|
+
end
|
44
|
+
|
45
|
+
it "should fulltext searchable with '営業 状態'(fullwidth-space delimiterd)" do
|
46
|
+
FulltextIndex.match('営業 状態').items.count.should == 2
|
47
|
+
end
|
48
|
+
|
49
|
+
it "should not match with html tags" do
|
50
|
+
FulltextIndex.match('DIV').items.count.should == 0
|
51
|
+
FulltextIndex.match('FONT').items.count.should == 0
|
52
|
+
end
|
53
|
+
|
54
|
+
it "should fulltext searchable with associated model's keyword" do
|
55
|
+
FulltextIndex.match('晴れ 太郎').items.count.should == 1
|
56
|
+
FulltextIndex.match('今日 太郎').items.count.should == 2
|
57
|
+
end
|
58
|
+
|
59
|
+
it "should fulltext searchable with target model specified" do
|
60
|
+
FulltextIndex.match('太郎', :model => News).items.count.should == 0
|
61
|
+
FulltextIndex.match('太郎', :model => [Blog, News]).items.count.should == 2
|
62
|
+
FulltextIndex.match('太郎', :model => [Blog, News, User]).items.count.should == 3
|
63
|
+
end
|
64
|
+
|
65
|
+
it "should fulltext searchable with target item specified" do
|
66
|
+
FulltextIndex.match('天気').items.count.should == 5
|
67
|
+
FulltextIndex.match('天気', :with => @taro).items.count.should == 3
|
68
|
+
end
|
69
|
+
|
70
|
+
it "should fulltext searchable with target items and models specified" do
|
71
|
+
FulltextIndex.match('天気').items.count.should == 5
|
72
|
+
FulltextIndex.match('天気', :with => @jiro, :model => Blog).items.count.should == 1
|
73
|
+
FulltextIndex.match('天気', :with => @jiro, :model => [Blog, User]).items.count.should == 2
|
74
|
+
FulltextIndex.match('天気', :with => [@taro, @jiro], :model => Blog).items.count.should == 3
|
75
|
+
FulltextIndex.match('天気', :with => [@taro, @jiro], :model => [Blog, User]).items.count.should == 5
|
76
|
+
end
|
77
|
+
|
78
|
+
it "should perform workaround with ActiveRecord's string-followed-by-period bug" do
|
79
|
+
FulltextIndex.match('ab.').items.should_not raise_error ActiveRecord::EagerLoadPolymorphicError
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
context "optimization" do
|
84
|
+
before do
|
85
|
+
@taro = FactoryGirl.create(:taro)
|
86
|
+
@jiro = FactoryGirl.create(:jiro)
|
87
|
+
FactoryGirl.create(:taisyaku)
|
88
|
+
end
|
89
|
+
it "should utilize groonga_fast_order_limit optization" do
|
90
|
+
fast = get_mroonga_status_var('fast_order_limit')
|
91
|
+
FulltextIndex.match('天気').limit(1).all
|
92
|
+
(get_mroonga_status_var('fast_order_limit') - fast).should == 1
|
93
|
+
end
|
94
|
+
|
95
|
+
it "should utilize groonga_count_skip optization" do
|
96
|
+
skip = get_mroonga_status_var('count_skip')
|
97
|
+
FulltextIndex.match('天気').count
|
98
|
+
(get_mroonga_status_var('count_skip') - skip).should == 1
|
99
|
+
end
|
100
|
+
|
101
|
+
it "should utilize both of optization with pagination" do
|
102
|
+
fast = get_mroonga_status_var('fast_order_limit')
|
103
|
+
skip = get_mroonga_status_var('count_skip')
|
104
|
+
FulltextIndex.match('天気').paginate(:per_page=>1, :page=>3).to_a
|
105
|
+
(get_mroonga_status_var('fast_order_limit') - fast).should == 1
|
106
|
+
(get_mroonga_status_var('count_skip') - skip).should == 1
|
107
|
+
end
|
108
|
+
|
109
|
+
def get_mroonga_status_var(name)
|
110
|
+
ActiveRecord::Base.connection.
|
111
|
+
execute("SHOW STATUS LIKE '#{ActiveRecord::Base.connection.mroonga_storage_engine_name}_#{name}';").first.last.to_i
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require 'spec_helper'
|
3
|
+
|
4
|
+
describe News do
|
5
|
+
it "should be valid" do
|
6
|
+
News.superclass.should == ActiveRecord::Base
|
7
|
+
end
|
8
|
+
|
9
|
+
it "should not be checkd with changes" do
|
10
|
+
@news = News.new
|
11
|
+
@news.should_not_receive(:check_fulltext_changes)
|
12
|
+
@news.save
|
13
|
+
end
|
14
|
+
|
15
|
+
context "creation" do
|
16
|
+
it "should create fulltext index" do
|
17
|
+
FulltextIndex.match('例 ダミー').items.should == []
|
18
|
+
@news = News.new :title => '例', :body => 'ダミー'
|
19
|
+
@news.save
|
20
|
+
@news.fulltext_index.text.should ==
|
21
|
+
"#{FulltextSearchable.to_model_keyword(News)} #{FulltextSearchable.to_item_keyword(@news)} 例 ダミー"
|
22
|
+
FulltextIndex.match('例 ダミー').items.should == [@news]
|
23
|
+
end
|
24
|
+
|
25
|
+
it "should not create fulltext index when soft-deleted initially" do
|
26
|
+
FulltextIndex.match('例 ダミー').items.should == []
|
27
|
+
@news = News.create! :title => '例', :body => 'ダミー'
|
28
|
+
@news.destroy
|
29
|
+
@news.fulltext_index.text.should == ''
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
context "retrieval" do
|
34
|
+
before do
|
35
|
+
@news = FactoryGirl.create(:taisyaku)
|
36
|
+
FactoryGirl.create(:soneki)
|
37
|
+
end
|
38
|
+
it "should return item" do
|
39
|
+
FulltextIndex.match('決算').items.should == [@news]
|
40
|
+
end
|
41
|
+
it "should be fulltext-searched with model restriction" do
|
42
|
+
FactoryGirl.create(:day_after_tomorrow)
|
43
|
+
FulltextIndex.match('決算').items.count.should == 2
|
44
|
+
FulltextIndex.match('決算', :model => News).items.should == [@news]
|
45
|
+
News.fulltext_match('決算').items.should == [@news]
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
context "updating" do
|
50
|
+
before do
|
51
|
+
@news = FactoryGirl.create(:taisyaku)
|
52
|
+
end
|
53
|
+
it "should update fulltext index" do
|
54
|
+
FulltextIndex.match('営業年度 楽しい').items.should == []
|
55
|
+
@news.body = "営業年度の終了時、決算において資産、負債、資本がどれだけあるかを一定のルールにのっとって財務状態を表にした楽しいもの"
|
56
|
+
@news.save
|
57
|
+
@news.fulltext_index.reload.text.should ==
|
58
|
+
"#{FulltextSearchable.to_model_keyword(News)} #{FulltextSearchable.to_item_keyword(@news)} 貸借対照表 営業年度の終了時、決算において資産、負債、資本がどれだけあるかを一定のルールにのっとって財務状態を表にした楽しいもの"
|
59
|
+
FulltextIndex.match('営業年度 楽しい').items.should == [@news]
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
context "deletion",f:true do
|
64
|
+
before do
|
65
|
+
@news = FactoryGirl.create(:taisyaku)
|
66
|
+
end
|
67
|
+
describe "with paranoid removal" do
|
68
|
+
it "should nulify fulltext index" do
|
69
|
+
@news.destroy
|
70
|
+
@news.reload.deleted_at.should_not be_nil
|
71
|
+
@news.fulltext_index.should_not be_nil
|
72
|
+
@news.fulltext_index.text.should == ''
|
73
|
+
FulltextIndex.match('営業年度').items.should == []
|
74
|
+
end
|
75
|
+
end
|
76
|
+
describe "with real removal" do
|
77
|
+
it "should destroy fulltext index" do
|
78
|
+
@fulltext_index = @news.fulltext_index
|
79
|
+
@news.destroy!
|
80
|
+
FulltextIndex.find_by__id(@fulltext_index.id).should be_nil
|
81
|
+
FulltextIndex.match('営業年度').items.should == []
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
context "recovery" do
|
87
|
+
before do
|
88
|
+
@news = FactoryGirl.create(:taisyaku)
|
89
|
+
@news.destroy
|
90
|
+
end
|
91
|
+
it "should rebuild fulltext index" do
|
92
|
+
@news.fulltext_index.text.should == ''
|
93
|
+
@news.recover
|
94
|
+
@news.deleted_at.should be_nil
|
95
|
+
@news.fulltext_index.reload.text.should ==
|
96
|
+
"#{FulltextSearchable.to_model_keyword(News)} #{FulltextSearchable.to_item_keyword(@news)} 貸借対照表 営業年度の終了時、決算において資産、負債、資本がどれだけあるかを一定のルールにのっとって財務状態を表にしたもの"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
# Configure Rails Envinronment
|
2
|
+
ENV["RAILS_ENV"] = "test"
|
3
|
+
|
4
|
+
require 'cover_me'
|
5
|
+
CoverMe.config do |c|
|
6
|
+
c.at_exit = Proc.new {}
|
7
|
+
c.file_pattern = /(#{c.project.root}\/app\/.+\.rb|#{c.project.root}\/lib\/.+\.rb)/ix
|
8
|
+
end
|
9
|
+
|
10
|
+
require File.expand_path("../dummy/config/environment.rb", __FILE__)
|
11
|
+
require "rails/test_help"
|
12
|
+
require "rspec/rails"
|
13
|
+
|
14
|
+
require "factory_girl"
|
15
|
+
require "faker"
|
16
|
+
|
17
|
+
require "database_cleaner"
|
18
|
+
|
19
|
+
ActionMailer::Base.delivery_method = :test
|
20
|
+
ActionMailer::Base.perform_deliveries = true
|
21
|
+
ActionMailer::Base.default_url_options[:host] = "test.com"
|
22
|
+
|
23
|
+
Rails.backtrace_cleaner.remove_silencers!
|
24
|
+
|
25
|
+
# Load support files
|
26
|
+
Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f }
|
27
|
+
|
28
|
+
RSpec.configure do |config|
|
29
|
+
# Remove this line if you don't want RSpec's should and should_not
|
30
|
+
# methods or matchers
|
31
|
+
require 'rspec/expectations'
|
32
|
+
config.include RSpec::Matchers
|
33
|
+
|
34
|
+
# == Mock Framework
|
35
|
+
config.mock_with :rspec
|
36
|
+
config.before(:suite) do
|
37
|
+
DatabaseCleaner.app_root = "#{File.dirname(__FILE__)}/dummy/"
|
38
|
+
DatabaseCleaner.strategy = :truncation
|
39
|
+
end
|
40
|
+
config.before(:each){ DatabaseCleaner.clean }
|
41
|
+
# filtering
|
42
|
+
config.filter_run :focus => true
|
43
|
+
config.run_all_when_everything_filtered = true
|
44
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
FactoryGirl.define do
|
4
|
+
factory :today, :class => Blog do
|
5
|
+
title '今日の天気は'
|
6
|
+
body '<DIV><FONT size="2"> </FONT>曇り時々晴れです。</DIV>'
|
7
|
+
end
|
8
|
+
|
9
|
+
factory :tomorrow, :class => Blog do
|
10
|
+
title '明日の天気は'
|
11
|
+
body '<DIV><FONT size="2"> </FONT>雨のち曇りです。</DIV>'
|
12
|
+
end
|
13
|
+
|
14
|
+
factory :day_after_tomorrow, :class => Blog do
|
15
|
+
title '明後日の天気は'
|
16
|
+
body '<DIV><FONT size="2">決算の </FONT>雪です。</DIV>'
|
17
|
+
end
|
18
|
+
|
19
|
+
factory :yesterday, :class => Blog do
|
20
|
+
title '昨日の天気は'
|
21
|
+
body '<marquee>雹でした</marquee>'
|
22
|
+
comments {[ FactoryGirl.create(:comment) ]}
|
23
|
+
end
|
24
|
+
|
25
|
+
factory :blog do
|
26
|
+
title Faker::Lorem.sentence[1..20]
|
27
|
+
body Faker::Lorem.paragraph
|
28
|
+
end
|
29
|
+
end
|