tire 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/.gitignore +9 -0
- data/Gemfile +4 -0
- data/MIT-LICENSE +20 -0
- data/README.markdown +435 -0
- data/Rakefile +75 -0
- data/examples/dsl.rb +73 -0
- data/examples/rails-application-template.rb +144 -0
- data/examples/tire-dsl.rb +617 -0
- data/lib/tire.rb +35 -0
- data/lib/tire/client.rb +40 -0
- data/lib/tire/configuration.rb +29 -0
- data/lib/tire/dsl.rb +33 -0
- data/lib/tire/index.rb +209 -0
- data/lib/tire/logger.rb +60 -0
- data/lib/tire/model/callbacks.rb +23 -0
- data/lib/tire/model/import.rb +18 -0
- data/lib/tire/model/indexing.rb +50 -0
- data/lib/tire/model/naming.rb +30 -0
- data/lib/tire/model/persistence.rb +34 -0
- data/lib/tire/model/persistence/attributes.rb +60 -0
- data/lib/tire/model/persistence/finders.rb +61 -0
- data/lib/tire/model/persistence/storage.rb +75 -0
- data/lib/tire/model/search.rb +97 -0
- data/lib/tire/results/collection.rb +56 -0
- data/lib/tire/results/item.rb +39 -0
- data/lib/tire/results/pagination.rb +30 -0
- data/lib/tire/rubyext/hash.rb +3 -0
- data/lib/tire/rubyext/symbol.rb +11 -0
- data/lib/tire/search.rb +117 -0
- data/lib/tire/search/facet.rb +41 -0
- data/lib/tire/search/filter.rb +28 -0
- data/lib/tire/search/highlight.rb +37 -0
- data/lib/tire/search/query.rb +42 -0
- data/lib/tire/search/sort.rb +29 -0
- data/lib/tire/tasks.rb +88 -0
- data/lib/tire/version.rb +3 -0
- data/test/fixtures/articles/1.json +1 -0
- data/test/fixtures/articles/2.json +1 -0
- data/test/fixtures/articles/3.json +1 -0
- data/test/fixtures/articles/4.json +1 -0
- data/test/fixtures/articles/5.json +1 -0
- data/test/integration/active_model_searchable_test.rb +80 -0
- data/test/integration/active_record_searchable_test.rb +193 -0
- data/test/integration/facets_test.rb +65 -0
- data/test/integration/filters_test.rb +46 -0
- data/test/integration/highlight_test.rb +52 -0
- data/test/integration/index_mapping_test.rb +44 -0
- data/test/integration/index_store_test.rb +68 -0
- data/test/integration/persistent_model_test.rb +35 -0
- data/test/integration/query_string_test.rb +43 -0
- data/test/integration/results_test.rb +28 -0
- data/test/integration/sort_test.rb +36 -0
- data/test/models/active_model_article.rb +31 -0
- data/test/models/active_model_article_with_callbacks.rb +49 -0
- data/test/models/active_model_article_with_custom_index_name.rb +5 -0
- data/test/models/active_record_article.rb +12 -0
- data/test/models/article.rb +15 -0
- data/test/models/persistent_article.rb +11 -0
- data/test/models/persistent_articles_with_custom_index_name.rb +10 -0
- data/test/models/supermodel_article.rb +22 -0
- data/test/models/validated_model.rb +11 -0
- data/test/test_helper.rb +52 -0
- data/test/unit/active_model_lint_test.rb +17 -0
- data/test/unit/client_test.rb +43 -0
- data/test/unit/configuration_test.rb +71 -0
- data/test/unit/index_test.rb +390 -0
- data/test/unit/logger_test.rb +114 -0
- data/test/unit/model_callbacks_test.rb +90 -0
- data/test/unit/model_import_test.rb +71 -0
- data/test/unit/model_persistence_test.rb +400 -0
- data/test/unit/model_search_test.rb +289 -0
- data/test/unit/results_collection_test.rb +131 -0
- data/test/unit/results_item_test.rb +59 -0
- data/test/unit/rubyext_hash_test.rb +19 -0
- data/test/unit/search_facet_test.rb +69 -0
- data/test/unit/search_filter_test.rb +36 -0
- data/test/unit/search_highlight_test.rb +46 -0
- data/test/unit/search_query_test.rb +55 -0
- data/test/unit/search_sort_test.rb +50 -0
- data/test/unit/search_test.rb +204 -0
- data/test/unit/tire_test.rb +55 -0
- data/tire.gemspec +54 -0
- metadata +372 -0
data/lib/tire.rb
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'rest_client'
|
2
|
+
require 'yajl/json_gem'
|
3
|
+
require 'active_model'
|
4
|
+
|
5
|
+
require 'tire/rubyext/hash'
|
6
|
+
require 'tire/rubyext/symbol'
|
7
|
+
require 'tire/logger'
|
8
|
+
require 'tire/configuration'
|
9
|
+
require 'tire/client'
|
10
|
+
require 'tire/client'
|
11
|
+
require 'tire/search'
|
12
|
+
require 'tire/search/query'
|
13
|
+
require 'tire/search/sort'
|
14
|
+
require 'tire/search/facet'
|
15
|
+
require 'tire/search/filter'
|
16
|
+
require 'tire/search/highlight'
|
17
|
+
require 'tire/results/pagination'
|
18
|
+
require 'tire/results/collection'
|
19
|
+
require 'tire/results/item'
|
20
|
+
require 'tire/index'
|
21
|
+
require 'tire/dsl'
|
22
|
+
require 'tire/model/naming'
|
23
|
+
require 'tire/model/callbacks'
|
24
|
+
require 'tire/model/search'
|
25
|
+
require 'tire/model/indexing'
|
26
|
+
require 'tire/model/import'
|
27
|
+
require 'tire/model/persistence/finders'
|
28
|
+
require 'tire/model/persistence/attributes'
|
29
|
+
require 'tire/model/persistence/storage'
|
30
|
+
require 'tire/model/persistence'
|
31
|
+
require 'tire/tasks'
|
32
|
+
|
33
|
+
module Tire
|
34
|
+
extend DSL
|
35
|
+
end
|
data/lib/tire/client.rb
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
module Tire
|
2
|
+
|
3
|
+
module Client
|
4
|
+
|
5
|
+
class Base
|
6
|
+
def get(url)
|
7
|
+
raise_no_method_error
|
8
|
+
end
|
9
|
+
def post(url, data)
|
10
|
+
raise_no_method_error
|
11
|
+
end
|
12
|
+
def put(url, data)
|
13
|
+
raise NoMethodError, "Implement this method in your client class"
|
14
|
+
end
|
15
|
+
def delete(url)
|
16
|
+
raise_no_method_error
|
17
|
+
end
|
18
|
+
def raise_no_method_error
|
19
|
+
raise NoMethodError, "Implement this method in your client class"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
class RestClient < Base
|
24
|
+
def self.get(url)
|
25
|
+
::RestClient.get url
|
26
|
+
end
|
27
|
+
def self.post(url, data)
|
28
|
+
::RestClient.post url, data
|
29
|
+
end
|
30
|
+
def self.put(url, data)
|
31
|
+
::RestClient.put url, data
|
32
|
+
end
|
33
|
+
def self.delete(url)
|
34
|
+
::RestClient.delete url rescue nil
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Tire
|
2
|
+
|
3
|
+
class Configuration
|
4
|
+
|
5
|
+
def self.url(value=nil)
|
6
|
+
@url = value || @url || "http://localhost:9200"
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.client(klass=nil)
|
10
|
+
@client = klass || @client || Client::RestClient
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.wrapper(klass=nil)
|
14
|
+
@wrapper = klass || @wrapper || Results::Item
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.logger(device=nil, options={})
|
18
|
+
return @logger = Logger.new(device, options) if device
|
19
|
+
@logger || nil
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.reset(*properties)
|
23
|
+
reset_variables = properties.empty? ? instance_variables : instance_variables & properties.map { |p| "@#{p}" }
|
24
|
+
reset_variables.each { |v| instance_variable_set(v, nil) }
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
data/lib/tire/dsl.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
module Tire
|
2
|
+
module DSL
|
3
|
+
|
4
|
+
def configure(&block)
|
5
|
+
Configuration.class_eval(&block)
|
6
|
+
end
|
7
|
+
|
8
|
+
def search(indices, options={}, &block)
|
9
|
+
if block_given?
|
10
|
+
Search::Search.new(indices, options, &block).perform
|
11
|
+
else
|
12
|
+
payload = case options
|
13
|
+
when Hash then options.to_json
|
14
|
+
when String then options
|
15
|
+
else raise ArgumentError, "Please pass a Ruby Hash or String with JSON"
|
16
|
+
end
|
17
|
+
|
18
|
+
response = Configuration.client.post( "#{Configuration.url}/#{indices}/_search", payload)
|
19
|
+
json = Yajl::Parser.parse(response.body)
|
20
|
+
results = Results::Collection.new(json, options)
|
21
|
+
end
|
22
|
+
rescue Exception => error
|
23
|
+
STDERR.puts "[REQUEST FAILED] #{error.class} #{error.http_body rescue nil}\n"
|
24
|
+
raise
|
25
|
+
ensure
|
26
|
+
end
|
27
|
+
|
28
|
+
def index(name, &block)
|
29
|
+
Index.new(name, &block)
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
end
|
data/lib/tire/index.rb
ADDED
@@ -0,0 +1,209 @@
|
|
1
|
+
module Tire
|
2
|
+
class Index
|
3
|
+
|
4
|
+
attr_reader :name
|
5
|
+
|
6
|
+
def initialize(name, &block)
|
7
|
+
@name = name
|
8
|
+
instance_eval(&block) if block_given?
|
9
|
+
end
|
10
|
+
|
11
|
+
def exists?
|
12
|
+
!!Configuration.client.get("#{Configuration.url}/#{@name}/_status")
|
13
|
+
rescue Exception => error
|
14
|
+
false
|
15
|
+
end
|
16
|
+
|
17
|
+
def delete
|
18
|
+
# FIXME: RestClient does not return response for DELETE requests?
|
19
|
+
@response = Configuration.client.delete "#{Configuration.url}/#{@name}"
|
20
|
+
return @response.body =~ /error/ ? false : true
|
21
|
+
rescue Exception => error
|
22
|
+
false
|
23
|
+
ensure
|
24
|
+
curl = %Q|curl -X DELETE "#{Configuration.url}/#{@name}"|
|
25
|
+
logged(error, 'DELETE', curl)
|
26
|
+
end
|
27
|
+
|
28
|
+
def create(options={})
|
29
|
+
@options = options
|
30
|
+
@response = Configuration.client.post "#{Configuration.url}/#{@name}", Yajl::Encoder.encode(options)
|
31
|
+
rescue Exception => error
|
32
|
+
false
|
33
|
+
ensure
|
34
|
+
curl = %Q|curl -X POST "#{Configuration.url}/#{@name}" -d '#{Yajl::Encoder.encode(options, :pretty => true)}'|
|
35
|
+
logged(error, 'CREATE', curl)
|
36
|
+
end
|
37
|
+
|
38
|
+
def mapping
|
39
|
+
@response = Configuration.client.get("#{Configuration.url}/#{@name}/_mapping")
|
40
|
+
JSON.parse(@response.body)[@name]
|
41
|
+
end
|
42
|
+
|
43
|
+
def store(*args)
|
44
|
+
# TODO: Infer type from the document (hash property, method)
|
45
|
+
|
46
|
+
if args.size > 1
|
47
|
+
(type, document = args)
|
48
|
+
else
|
49
|
+
(document = args.pop; type = :document)
|
50
|
+
end
|
51
|
+
|
52
|
+
old_verbose, $VERBOSE = $VERBOSE, nil # Silence Object#id deprecation warnings
|
53
|
+
id = case true
|
54
|
+
when document.is_a?(Hash) then document[:id] || document['id']
|
55
|
+
when document.respond_to?(:id) && document.id != document.object_id then document.id
|
56
|
+
end
|
57
|
+
$VERBOSE = old_verbose
|
58
|
+
|
59
|
+
document = case true
|
60
|
+
when document.is_a?(String) then document
|
61
|
+
when document.respond_to?(:to_indexed_json) then document.to_indexed_json
|
62
|
+
else raise ArgumentError, "Please pass a JSON string or object with a 'to_indexed_json' method"
|
63
|
+
end
|
64
|
+
|
65
|
+
url = id ? "#{Configuration.url}/#{@name}/#{type}/#{id}" : "#{Configuration.url}/#{@name}/#{type}/"
|
66
|
+
|
67
|
+
@response = Configuration.client.post url, document
|
68
|
+
JSON.parse(@response.body)
|
69
|
+
|
70
|
+
rescue Exception => error
|
71
|
+
raise
|
72
|
+
ensure
|
73
|
+
curl = %Q|curl -X POST "#{url}" -d '#{document}'|
|
74
|
+
logged(error, "/#{@name}/#{type}/", curl)
|
75
|
+
end
|
76
|
+
|
77
|
+
def bulk_store documents
|
78
|
+
create unless exists?
|
79
|
+
|
80
|
+
payload = documents.map do |document|
|
81
|
+
old_verbose, $VERBOSE = $VERBOSE, nil # Silence Object#id deprecation warnings
|
82
|
+
id = case
|
83
|
+
when document.is_a?(Hash) then document[:id] || document['id']
|
84
|
+
when document.respond_to?(:id) && document.id != document.object_id then document.id
|
85
|
+
# TODO: Raise error when no id present
|
86
|
+
end
|
87
|
+
$VERBOSE = old_verbose
|
88
|
+
|
89
|
+
type = case
|
90
|
+
when document.is_a?(Hash) then document[:type] || document['type']
|
91
|
+
when document.respond_to?(:document_type) then document.document_type
|
92
|
+
end || 'document'
|
93
|
+
|
94
|
+
output = []
|
95
|
+
output << %Q|{"index":{"_index":"#{@name}","_type":"#{type}","_id":"#{id}"}}|
|
96
|
+
output << document.to_indexed_json
|
97
|
+
output.join("\n")
|
98
|
+
end
|
99
|
+
payload << ""
|
100
|
+
|
101
|
+
tries = 5
|
102
|
+
count = 0
|
103
|
+
|
104
|
+
begin
|
105
|
+
# STDERR.puts "Posting payload..."
|
106
|
+
# STDERR.puts payload.join("\n")
|
107
|
+
Configuration.client.post("#{Configuration.url}/_bulk", payload.join("\n"))
|
108
|
+
rescue Exception => error
|
109
|
+
if count < tries
|
110
|
+
count += 1
|
111
|
+
STDERR.puts "[ERROR] #{error.message}:#{error.http_body rescue nil}, retrying (#{count})..."
|
112
|
+
retry
|
113
|
+
else
|
114
|
+
STDERR.puts "[ERROR] Too many exceptions occured, giving up..."
|
115
|
+
STDERR.puts "Response: #{error.http_body rescue nil}"
|
116
|
+
raise
|
117
|
+
end
|
118
|
+
ensure
|
119
|
+
curl = %Q|curl -X POST "#{Configuration.url}/_bulk" -d '{... data omitted ...}'|
|
120
|
+
logged(error, 'BULK', curl)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def import(klass_or_collection, method=nil, options={})
|
125
|
+
# p [klass_or_collection, method, options]
|
126
|
+
|
127
|
+
case
|
128
|
+
|
129
|
+
when method
|
130
|
+
options = {:page => 1, :per_page => 1000}.merge options
|
131
|
+
while documents = klass_or_collection.send(method.to_sym, options.merge(:page => options[:page])) \
|
132
|
+
and not documents.empty?
|
133
|
+
documents = yield documents if block_given?
|
134
|
+
|
135
|
+
bulk_store documents
|
136
|
+
options[:page] += 1
|
137
|
+
end
|
138
|
+
|
139
|
+
when klass_or_collection.respond_to?(:map)
|
140
|
+
documents = block_given? ? yield(klass_or_collection) : klass_or_collection
|
141
|
+
bulk_store documents
|
142
|
+
else
|
143
|
+
raise ArgumentError, "Please pass either a collection of objects, "+
|
144
|
+
"or method for fetching records, or Enumerable compatible class"
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def remove(*args)
|
149
|
+
# TODO: Infer type from the document (hash property, method)
|
150
|
+
|
151
|
+
if args.size > 1
|
152
|
+
(type, document = args)
|
153
|
+
else
|
154
|
+
(document = args.pop; type = :document)
|
155
|
+
end
|
156
|
+
|
157
|
+
old_verbose, $VERBOSE = $VERBOSE, nil # Silence Object#id deprecation warnings
|
158
|
+
id = case true
|
159
|
+
when document.is_a?(Hash) then document[:id] || document['id']
|
160
|
+
when document.respond_to?(:id) && document.id != document.object_id then document.id
|
161
|
+
else document
|
162
|
+
end
|
163
|
+
$VERBOSE = old_verbose
|
164
|
+
|
165
|
+
result = Configuration.client.delete "#{Configuration.url}/#{@name}/#{type}/#{id}"
|
166
|
+
JSON.parse(result) if result
|
167
|
+
end
|
168
|
+
|
169
|
+
def retrieve(type, id)
|
170
|
+
@response = Configuration.client.get "#{Configuration.url}/#{@name}/#{type}/#{id}"
|
171
|
+
h = JSON.parse(@response.body)
|
172
|
+
if Configuration.wrapper == Hash then h
|
173
|
+
else
|
174
|
+
document = {}
|
175
|
+
document = h['_source'] ? document.update( h['_source'] ) : document.update( h['fields'] )
|
176
|
+
document.update('id' => h['_id'], '_version' => h['_version'])
|
177
|
+
Configuration.wrapper.new(document)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def refresh
|
182
|
+
@response = Configuration.client.post "#{Configuration.url}/#{@name}/_refresh", ''
|
183
|
+
rescue Exception => error
|
184
|
+
raise
|
185
|
+
ensure
|
186
|
+
curl = %Q|curl -X POST "#{Configuration.url}/#{@name}/_refresh"|
|
187
|
+
logged(error, '_refresh', curl)
|
188
|
+
end
|
189
|
+
|
190
|
+
def logged(error=nil, endpoint='/', curl='')
|
191
|
+
if Configuration.logger
|
192
|
+
|
193
|
+
Configuration.logger.log_request endpoint, @name, curl
|
194
|
+
|
195
|
+
code = @response ? @response.code : error.message rescue 200
|
196
|
+
|
197
|
+
if Configuration.logger.level.to_s == 'debug'
|
198
|
+
# FIXME: Depends on RestClient implementation
|
199
|
+
body = @response ? Yajl::Encoder.encode(@response.body, :pretty => true) : error.http_body rescue ''
|
200
|
+
else
|
201
|
+
body = ''
|
202
|
+
end
|
203
|
+
|
204
|
+
Configuration.logger.log_response code, nil, body
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
end
|
209
|
+
end
|
data/lib/tire/logger.rb
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
module Tire
|
2
|
+
class Logger
|
3
|
+
|
4
|
+
def initialize(device, options={})
|
5
|
+
@device = if device.respond_to?(:write)
|
6
|
+
device
|
7
|
+
else
|
8
|
+
File.open(device, 'a')
|
9
|
+
end
|
10
|
+
@device.sync = true
|
11
|
+
@options = options
|
12
|
+
at_exit { @device.close unless @device.closed? }
|
13
|
+
end
|
14
|
+
|
15
|
+
def level
|
16
|
+
@options[:level] || 'info'
|
17
|
+
end
|
18
|
+
|
19
|
+
def write(message)
|
20
|
+
@device.write message
|
21
|
+
end
|
22
|
+
|
23
|
+
def log_request(endpoint, params=nil, curl='')
|
24
|
+
# 2001-02-12 18:20:42:32 [_search] (articles,users)
|
25
|
+
#
|
26
|
+
# curl -X POST ....
|
27
|
+
#
|
28
|
+
content = "# #{time}"
|
29
|
+
content += " [#{endpoint}]"
|
30
|
+
content += " (#{params.inspect})" if params
|
31
|
+
content += "\n#\n"
|
32
|
+
content += curl
|
33
|
+
content += "\n\n"
|
34
|
+
write content
|
35
|
+
end
|
36
|
+
|
37
|
+
def log_response(status, took=nil, json='')
|
38
|
+
# 2001-02-12 18:20:42:32 [200] (4 msec)
|
39
|
+
#
|
40
|
+
# {
|
41
|
+
# "took" : 4,
|
42
|
+
# "hits" : [...]
|
43
|
+
# ...
|
44
|
+
# }
|
45
|
+
#
|
46
|
+
content = "# #{time}"
|
47
|
+
content += " [#{status}]"
|
48
|
+
content += " (#{took} msec)" if took
|
49
|
+
content += "\n#\n" unless json == ''
|
50
|
+
json.each_line { |line| content += "# #{line}" } unless json == ''
|
51
|
+
content += "\n\n"
|
52
|
+
write content
|
53
|
+
end
|
54
|
+
|
55
|
+
def time
|
56
|
+
Time.now.strftime('%Y-%m-%d %H:%M:%S:%L')
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Tire
|
2
|
+
module Model
|
3
|
+
|
4
|
+
module Callbacks
|
5
|
+
|
6
|
+
def self.included(base)
|
7
|
+
if base.respond_to?(:after_save) && base.respond_to?(:after_destroy)
|
8
|
+
base.send :after_save, :update_elastic_search_index
|
9
|
+
base.send :after_destroy, :update_elastic_search_index
|
10
|
+
end
|
11
|
+
|
12
|
+
if base.respond_to?(:before_destroy) && !base.respond_to?(:destroyed?)
|
13
|
+
base.class_eval do
|
14
|
+
before_destroy { @destroyed = true }
|
15
|
+
def destroyed?; !!@destroyed; end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Tire
|
2
|
+
module Model
|
3
|
+
|
4
|
+
module Import
|
5
|
+
|
6
|
+
module ClassMethods
|
7
|
+
|
8
|
+
def import options={}, &block
|
9
|
+
method = options.delete(:method) || 'paginate'
|
10
|
+
self.index.import self, method, options, &block
|
11
|
+
end
|
12
|
+
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
end
|