tinia 0.0.3
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/.rspec +3 -0
- data/Gemfile +23 -0
- data/Gemfile.lock +95 -0
- data/Guardfile +19 -0
- data/LICENSE.txt +20 -0
- data/README.rdoc +130 -0
- data/Rakefile +42 -0
- data/VERSION +1 -0
- data/lib/tinia.rb +48 -0
- data/lib/tinia/connection.rb +24 -0
- data/lib/tinia/exceptions.rb +8 -0
- data/lib/tinia/index.rb +112 -0
- data/lib/tinia/railtie.rb +9 -0
- data/lib/tinia/search.rb +86 -0
- data/spec/acceptance/aws_spec.rb +45 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/tinia/connection_spec.rb +46 -0
- data/spec/tinia/index_spec.rb +197 -0
- data/spec/tinia/search_spec.rb +124 -0
- data/spec/tinia_spec.rb +45 -0
- data/tinia.gemspec +94 -0
- metadata +204 -0
data/lib/tinia/search.rb
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
module Tinia
|
2
|
+
|
3
|
+
module Search
|
4
|
+
|
5
|
+
def self.included(klass)
|
6
|
+
klass.send(:extend, ClassMethods)
|
7
|
+
klass.class_eval do
|
8
|
+
# lambda block for the scope
|
9
|
+
scope_def = lambda{|*ids|
|
10
|
+
{
|
11
|
+
:conditions => [
|
12
|
+
"#{self.table_name}.#{self.primary_key} IN (?)", ids.flatten
|
13
|
+
]
|
14
|
+
}
|
15
|
+
}
|
16
|
+
named_scope :tinia_scope, scope_def do
|
17
|
+
include WillPaginateMethods
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# methods of WillPaginate compatibility
|
23
|
+
module WillPaginateMethods
|
24
|
+
|
25
|
+
attr_accessor :current_page, :per_page, :total_entries
|
26
|
+
|
27
|
+
# calculated offset given the current page and the number
|
28
|
+
# of entries per page
|
29
|
+
def offset
|
30
|
+
(self.current_page - 1) * self.per_page
|
31
|
+
end
|
32
|
+
|
33
|
+
# total number of pages
|
34
|
+
def total_pages
|
35
|
+
(self.total_entries.to_f / self.per_page.to_f).ceil
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
|
40
|
+
module ClassMethods
|
41
|
+
|
42
|
+
# return a scope with the subset of ids
|
43
|
+
def cloud_search(*args)
|
44
|
+
opts = {
|
45
|
+
:page => 1,
|
46
|
+
:per_page => 20
|
47
|
+
}
|
48
|
+
opts = opts.merge(args.extract_options!)
|
49
|
+
query = args.first
|
50
|
+
|
51
|
+
response = self.cloud_search_response(args.first, opts)
|
52
|
+
ids = response.hits.collect{|h| h["id"]}
|
53
|
+
|
54
|
+
proxy = self.tinia_scope(ids)
|
55
|
+
proxy.per_page = opts[:per_page]
|
56
|
+
proxy.current_page = opts[:page]
|
57
|
+
proxy.total_entries = response.found
|
58
|
+
proxy
|
59
|
+
end
|
60
|
+
|
61
|
+
protected
|
62
|
+
|
63
|
+
# perform a query to CloudSearch and get a response
|
64
|
+
def cloud_search_response(query, opts)
|
65
|
+
self.cloud_search_connection.search(
|
66
|
+
self.cloud_search_request(query, opts)
|
67
|
+
)
|
68
|
+
end
|
69
|
+
|
70
|
+
# generate a request to CloudSearch
|
71
|
+
def cloud_search_request(query, opts)
|
72
|
+
AWSCloudSearch::SearchRequest.new.tap do |req|
|
73
|
+
if query.nil?
|
74
|
+
req.bq = "type:'#{self.base_class.name}'"
|
75
|
+
else
|
76
|
+
req.bq = "(and '#{query}' type:'#{self.base_class.name}')"
|
77
|
+
end
|
78
|
+
req.size = opts[:per_page]
|
79
|
+
req.start = (opts[:page] - 1) * opts[:per_page]
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe "AWS" do
|
4
|
+
|
5
|
+
before(:all) do
|
6
|
+
|
7
|
+
conn = ActiveRecord::Base.connection
|
8
|
+
conn.create_table(:clients, :force => true) do |t|
|
9
|
+
t.string(:first_name)
|
10
|
+
t.string(:last_name)
|
11
|
+
t.string(:email)
|
12
|
+
t.timestamps
|
13
|
+
end
|
14
|
+
|
15
|
+
Client = Class.new(ActiveRecord::Base) do
|
16
|
+
indexed_with_cloud_search do |config|
|
17
|
+
config.cloud_search_domain = "client-4wwi2n4ghrnro46w2adiw2temy"
|
18
|
+
end
|
19
|
+
|
20
|
+
def cloud_search_data
|
21
|
+
self.attributes
|
22
|
+
end
|
23
|
+
|
24
|
+
end
|
25
|
+
|
26
|
+
Client.create(
|
27
|
+
:first_name => "Dan",
|
28
|
+
:last_name => "Langevin",
|
29
|
+
:email => "test@test.com"
|
30
|
+
)
|
31
|
+
end
|
32
|
+
|
33
|
+
after(:all) do
|
34
|
+
Client.all.each(&:destroy)
|
35
|
+
end
|
36
|
+
|
37
|
+
it "should be able to index and search records" do
|
38
|
+
|
39
|
+
Client.cloud_search_reindex
|
40
|
+
c = Client.cloud_search("Dan Langevin").first
|
41
|
+
c.should be_instance_of(Client)
|
42
|
+
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
2
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
3
|
+
require 'rspec'
|
4
|
+
require 'tinia'
|
5
|
+
require 'active_record'
|
6
|
+
require 'mocha'
|
7
|
+
|
8
|
+
require 'ruby-debug'
|
9
|
+
Debugger.start
|
10
|
+
|
11
|
+
# Requires supporting files with custom matchers and macros, etc,
|
12
|
+
# in ./support/ and its subdirectories.
|
13
|
+
Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f}
|
14
|
+
|
15
|
+
ActiveRecord::Base.establish_connection({
|
16
|
+
"adapter" => "sqlite3",
|
17
|
+
"database" => "/tmp/tinia_test.sqlite"
|
18
|
+
})
|
19
|
+
|
20
|
+
Tinia.activate_active_record!
|
21
|
+
|
22
|
+
Mocha::Configuration.prevent(:stubbing_non_existent_method)
|
23
|
+
|
24
|
+
RSpec.configure do |config|
|
25
|
+
config.mock_with :mocha
|
26
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Tinia::Connection do
|
4
|
+
|
5
|
+
before(:each) do
|
6
|
+
|
7
|
+
ConnectionMock = Class.new(ActiveRecord::Base) do
|
8
|
+
indexed_with_cloud_search do |config|
|
9
|
+
config.cloud_search_domain = "connection-mock"
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
end
|
14
|
+
|
15
|
+
context ".cloud_search_connection" do
|
16
|
+
|
17
|
+
it "should get a namespaced connection" do
|
18
|
+
ConnectionMock.stubs(:cloud_search_domain => "connection-mock")
|
19
|
+
Tinia.expects(:connection).with("connection-mock")
|
20
|
+
ConnectionMock.cloud_search_connection
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
|
25
|
+
context ".cloud_search_domain" do
|
26
|
+
|
27
|
+
it "should provide a setter and getter" do
|
28
|
+
ConnectionMock.cloud_search_domain = "123"
|
29
|
+
ConnectionMock.cloud_search_domain.should eql "123"
|
30
|
+
|
31
|
+
ConnectionMock2 = Class.new(ConnectionMock)
|
32
|
+
ConnectionMock2.cloud_search_domain.should eql "123"
|
33
|
+
end
|
34
|
+
|
35
|
+
it "should raise an error if cloud_search_domain is not defined" do
|
36
|
+
|
37
|
+
lambda{
|
38
|
+
ErrorMock = Class.new(ActiveRecord::Base) do
|
39
|
+
indexed_with_cloud_search
|
40
|
+
end
|
41
|
+
}.should raise_error(Tinia::MissingSearchDomain)
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
@@ -0,0 +1,197 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Tinia::Index do
|
4
|
+
|
5
|
+
before(:all) do
|
6
|
+
|
7
|
+
conn = ActiveRecord::Base.connection
|
8
|
+
|
9
|
+
conn.create_table(:mock_index_classes, :force => true) do |t|
|
10
|
+
t.string(:name)
|
11
|
+
t.timestamps
|
12
|
+
end
|
13
|
+
|
14
|
+
conn.create_table(:mock_index_with_datas, :force => true) do |t|
|
15
|
+
t.string(:name)
|
16
|
+
t.string(:type)
|
17
|
+
t.timestamps
|
18
|
+
end
|
19
|
+
|
20
|
+
MockIndexClass = Class.new(ActiveRecord::Base) do
|
21
|
+
indexed_with_cloud_search do |config|
|
22
|
+
config.cloud_search_domain = "connection-mock"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
MockIndexWithData = Class.new(ActiveRecord::Base) do
|
27
|
+
indexed_with_cloud_search do |config|
|
28
|
+
config.cloud_search_domain = "mock-index-with-data"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
|
34
|
+
shared_examples_for "batching" do
|
35
|
+
|
36
|
+
it "should add a document to its batch, flushing each time" do
|
37
|
+
|
38
|
+
doc = AWSCloudSearch::Document.new
|
39
|
+
doc2 = AWSCloudSearch::Document.new
|
40
|
+
|
41
|
+
batcher = AWSCloudSearch::DocumentBatcher.new(stub)
|
42
|
+
batcher.expects(document_method).with(doc)
|
43
|
+
batcher.expects(document_method).with(doc2)
|
44
|
+
batcher.expects(:flush).twice
|
45
|
+
|
46
|
+
MockIndexWithData.stubs(:cloud_search_document_batcher => batcher)
|
47
|
+
|
48
|
+
MockIndexWithData.send(batch_method, doc)
|
49
|
+
MockIndexWithData.send(batch_method, doc2)
|
50
|
+
|
51
|
+
end
|
52
|
+
|
53
|
+
it "should add a document to its batch flushing only at the end
|
54
|
+
when within a cloud_search_batch_documents block" do
|
55
|
+
|
56
|
+
|
57
|
+
doc = AWSCloudSearch::Document.new
|
58
|
+
doc2 = AWSCloudSearch::Document.new
|
59
|
+
|
60
|
+
batcher = AWSCloudSearch::DocumentBatcher.new(stub)
|
61
|
+
batcher.expects(document_method).with(doc)
|
62
|
+
batcher.expects(document_method).with(doc2)
|
63
|
+
|
64
|
+
# exactly one
|
65
|
+
batcher.expects(:flush).once
|
66
|
+
|
67
|
+
MockIndexWithData.stubs(:cloud_search_document_batcher => batcher)
|
68
|
+
|
69
|
+
MockIndexWithData.cloud_search_batch_documents do
|
70
|
+
MockIndexWithData.send(batch_method, doc)
|
71
|
+
MockIndexWithData.send(batch_method, doc2)
|
72
|
+
end
|
73
|
+
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
context ".cloud_search_add_document" do
|
78
|
+
|
79
|
+
let(:batch_method) do
|
80
|
+
:cloud_search_add_document
|
81
|
+
end
|
82
|
+
|
83
|
+
let(:document_method) do
|
84
|
+
:add_document
|
85
|
+
end
|
86
|
+
|
87
|
+
it_should_behave_like("batching")
|
88
|
+
end
|
89
|
+
|
90
|
+
context ".cloud_search_delete_document" do
|
91
|
+
|
92
|
+
let(:batch_method) do
|
93
|
+
:cloud_search_delete_document
|
94
|
+
end
|
95
|
+
|
96
|
+
let(:document_method) do
|
97
|
+
:delete_document
|
98
|
+
end
|
99
|
+
|
100
|
+
it_should_behave_like("batching")
|
101
|
+
end
|
102
|
+
|
103
|
+
context ".cloud_search_domain" do
|
104
|
+
|
105
|
+
it "should be an inheritable attribute" do
|
106
|
+
NewKlass = Class.new(MockIndexWithData)
|
107
|
+
NewKlass.cloud_search_domain.should eql(
|
108
|
+
MockIndexWithData.cloud_search_domain
|
109
|
+
)
|
110
|
+
end
|
111
|
+
|
112
|
+
end
|
113
|
+
|
114
|
+
|
115
|
+
context ".cloud_search_reindex" do
|
116
|
+
|
117
|
+
it "should reindex the entire collection" do
|
118
|
+
mock_index_with_data = MockIndexWithData.new
|
119
|
+
mock_index_with_data.expects(:add_to_cloud_search)
|
120
|
+
|
121
|
+
MockIndexWithData.cloud_search_document_batcher.expects(:flush)
|
122
|
+
MockIndexWithData.expects(:find_each)
|
123
|
+
.with(:conditions => ["x = y"])
|
124
|
+
.yields(mock_index_with_data)
|
125
|
+
|
126
|
+
MockIndexWithData.cloud_search_reindex(:conditions => ["x = y"])
|
127
|
+
end
|
128
|
+
|
129
|
+
end
|
130
|
+
|
131
|
+
|
132
|
+
context "#add_to_cloud_search" do
|
133
|
+
|
134
|
+
it "should add a document to CloudSearch" do
|
135
|
+
doc = AWSCloudSearch::Document.new
|
136
|
+
|
137
|
+
mock_index_with_data = MockIndexWithData.new
|
138
|
+
mock_index_with_data.stubs(:cloud_search_document => doc)
|
139
|
+
|
140
|
+
MockIndexWithData.expects(:cloud_search_add_document).with(doc)
|
141
|
+
|
142
|
+
# call add to cloud_search
|
143
|
+
mock_index_with_data.add_to_cloud_search
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
context "#cloud_search_data" do
|
148
|
+
|
149
|
+
it "should define an empty cloud_search_data method" do
|
150
|
+
MockIndexClass.new.cloud_search_data.should eql({})
|
151
|
+
end
|
152
|
+
|
153
|
+
end
|
154
|
+
|
155
|
+
context "#cloud_search_document" do
|
156
|
+
|
157
|
+
it "should provide a wrapper for the document that is indexed " do
|
158
|
+
|
159
|
+
t = Time.now
|
160
|
+
|
161
|
+
doc = AWSCloudSearch::Document.new
|
162
|
+
doc.expects(:id=).with(8989)
|
163
|
+
doc.expects(:lang=).with("en")
|
164
|
+
doc.expects(:version=).with(t.to_i)
|
165
|
+
doc.expects(:add_field).with("key", "val")
|
166
|
+
doc.expects(:add_field).with("type", "MockIndexWithData")
|
167
|
+
AWSCloudSearch::Document.stubs(:new => doc)
|
168
|
+
|
169
|
+
mock_index_with_data = MockIndexWithData.new
|
170
|
+
mock_index_with_data.stubs(
|
171
|
+
:cloud_search_data => {:key => "val"},
|
172
|
+
:id => 8989,
|
173
|
+
:updated_at => t.to_i
|
174
|
+
)
|
175
|
+
mock_index_with_data.cloud_search_document.should eql(doc)
|
176
|
+
end
|
177
|
+
|
178
|
+
end
|
179
|
+
|
180
|
+
context "#delete_from_cloud_search" do
|
181
|
+
|
182
|
+
it "should remvoe a document from CloudSearch" do
|
183
|
+
doc = AWSCloudSearch::Document.new
|
184
|
+
|
185
|
+
mock_index_with_data = MockIndexWithData.new
|
186
|
+
mock_index_with_data.stubs(:cloud_search_document => doc)
|
187
|
+
|
188
|
+
MockIndexWithData.expects(:cloud_search_delete_document).with(doc)
|
189
|
+
|
190
|
+
# call add to cloud_search
|
191
|
+
mock_index_with_data.delete_from_cloud_search
|
192
|
+
end
|
193
|
+
|
194
|
+
end
|
195
|
+
|
196
|
+
|
197
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Tinia::Search do
|
4
|
+
|
5
|
+
before(:all) do
|
6
|
+
|
7
|
+
conn = ActiveRecord::Base.connection
|
8
|
+
conn.create_table(:mock_classes, :force => true) do |t|
|
9
|
+
t.string("name")
|
10
|
+
t.timestamps
|
11
|
+
end
|
12
|
+
|
13
|
+
MockClass = Class.new(ActiveRecord::Base) do
|
14
|
+
indexed_with_cloud_search do |config|
|
15
|
+
config.cloud_search_domain = "mock-class"
|
16
|
+
end
|
17
|
+
|
18
|
+
named_scope :name_like, lambda{|n|
|
19
|
+
{:conditions => ["name LIKE ?", n]}
|
20
|
+
}
|
21
|
+
|
22
|
+
end
|
23
|
+
|
24
|
+
end
|
25
|
+
|
26
|
+
context "#cloud_search" do
|
27
|
+
|
28
|
+
before(:each) do
|
29
|
+
AWSCloudSearch::SearchRequest.stubs(:new => search_request)
|
30
|
+
|
31
|
+
MockClass.cloud_search_connection
|
32
|
+
.expects(:search)
|
33
|
+
.with(search_request)
|
34
|
+
.returns(stub({
|
35
|
+
:hits => [
|
36
|
+
{"id" => 1},
|
37
|
+
{"id" => 2}
|
38
|
+
],
|
39
|
+
:found => 300,
|
40
|
+
:start => 0
|
41
|
+
}))
|
42
|
+
end
|
43
|
+
|
44
|
+
let(:search_request) do
|
45
|
+
search_request = AWSCloudSearch::SearchRequest.new
|
46
|
+
search_request.expects(:bq=).with("(and 'my query' type:'MockClass')")
|
47
|
+
search_request
|
48
|
+
end
|
49
|
+
|
50
|
+
it "should proxy its search request to cloud_search and return
|
51
|
+
an Arel-like object" do
|
52
|
+
|
53
|
+
proxy = MockClass.cloud_search("my query")
|
54
|
+
proxy.proxy_options[:conditions].should eql(
|
55
|
+
["mock_classes.id IN (?)", [1, 2]]
|
56
|
+
)
|
57
|
+
end
|
58
|
+
|
59
|
+
it "should be chainable, maintaining its meta_data" do
|
60
|
+
proxy = MockClass.cloud_search("my query").name_like("name")
|
61
|
+
proxy.current_page.should eql(1)
|
62
|
+
proxy.offset.should eql(0)
|
63
|
+
end
|
64
|
+
|
65
|
+
context "#current_page" do
|
66
|
+
|
67
|
+
it "should default to 1" do
|
68
|
+
proxy = MockClass.cloud_search("my query")
|
69
|
+
proxy.current_page.should eql(1)
|
70
|
+
end
|
71
|
+
|
72
|
+
it "should be able to be set" do
|
73
|
+
search_request.expects(:start=).with(80)
|
74
|
+
proxy = MockClass.cloud_search("my query", :page => 5)
|
75
|
+
proxy.current_page.should eql(5)
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
79
|
+
|
80
|
+
context "#offset" do
|
81
|
+
|
82
|
+
it "should be able to compute its offset" do
|
83
|
+
proxy = MockClass.cloud_search("my query", :page => 5)
|
84
|
+
proxy.offset.should eql(80)
|
85
|
+
end
|
86
|
+
|
87
|
+
end
|
88
|
+
|
89
|
+
context "#per_page" do
|
90
|
+
|
91
|
+
it "should default to 20" do
|
92
|
+
proxy = MockClass.cloud_search("my query")
|
93
|
+
proxy.per_page.should eql(20)
|
94
|
+
end
|
95
|
+
|
96
|
+
it "should be able to be set" do
|
97
|
+
search_request.expects(:size=).with(50)
|
98
|
+
proxy = MockClass.cloud_search("my query", :per_page => 50)
|
99
|
+
proxy.per_page.should eql(50)
|
100
|
+
end
|
101
|
+
|
102
|
+
end
|
103
|
+
|
104
|
+
context "#total_entries" do
|
105
|
+
|
106
|
+
it "should get it from its search_response" do
|
107
|
+
proxy = MockClass.cloud_search("my query")
|
108
|
+
proxy.total_entries.should eql(300)
|
109
|
+
end
|
110
|
+
|
111
|
+
end
|
112
|
+
|
113
|
+
context "#total_pages" do
|
114
|
+
|
115
|
+
it "should be the ceiling of its total_entries divided
|
116
|
+
by per_page" do
|
117
|
+
proxy = MockClass.cloud_search("my query", :per_page => 7)
|
118
|
+
proxy.total_pages.should eql(43)
|
119
|
+
end
|
120
|
+
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
end
|