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
@@ -0,0 +1,39 @@
|
|
1
|
+
module Tire
|
2
|
+
module Results
|
3
|
+
|
4
|
+
class Item < Hash
|
5
|
+
|
6
|
+
# Create new instance, recursively converting all Hashes to Item
|
7
|
+
# and leaving everything else alone.
|
8
|
+
#
|
9
|
+
def initialize(args={})
|
10
|
+
raise ArgumentError, "Please pass a Hash-like object" unless args.respond_to?(:each_pair)
|
11
|
+
args.each_pair do |key, value|
|
12
|
+
self[key.to_sym] = value.respond_to?(:to_hash) ? self.class.new(value) : value
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
# Delegate method to a key in underlying hash, if present,
|
17
|
+
# otherwise return +nil+.
|
18
|
+
#
|
19
|
+
def method_missing(method_name, *arguments)
|
20
|
+
self.has_key?(method_name.to_sym) ? self[method_name.to_sym] : nil
|
21
|
+
end
|
22
|
+
|
23
|
+
# Get ID
|
24
|
+
#
|
25
|
+
def id
|
26
|
+
self[:id]
|
27
|
+
end
|
28
|
+
|
29
|
+
def inspect
|
30
|
+
s = []; self.each { |k,v| s << "#{k}: #{v.inspect}" }
|
31
|
+
%Q|<Item #{s.join(', ')}>|
|
32
|
+
end
|
33
|
+
|
34
|
+
alias_method :to_indexed_json, :to_json
|
35
|
+
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Tire
|
2
|
+
module Results
|
3
|
+
|
4
|
+
module Pagination
|
5
|
+
|
6
|
+
def total_entries
|
7
|
+
@total
|
8
|
+
end
|
9
|
+
|
10
|
+
def total_pages
|
11
|
+
result = @total.to_f / (@options[:per_page] ? @options[:per_page].to_i : 10 )
|
12
|
+
result < 1 ? 1 : result.round
|
13
|
+
end
|
14
|
+
|
15
|
+
def current_page
|
16
|
+
@options[:page].to_i
|
17
|
+
end
|
18
|
+
|
19
|
+
def previous_page
|
20
|
+
@options[:page].to_i - 1
|
21
|
+
end
|
22
|
+
|
23
|
+
def next_page
|
24
|
+
@options[:page].to_i + 1
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
end
|
data/lib/tire/search.rb
ADDED
@@ -0,0 +1,117 @@
|
|
1
|
+
module Tire
|
2
|
+
module Search
|
3
|
+
|
4
|
+
class Search
|
5
|
+
|
6
|
+
attr_reader :indices, :url, :results, :response, :json, :query, :facets, :filters
|
7
|
+
|
8
|
+
def initialize(*indices, &block)
|
9
|
+
@options = indices.pop if indices.last.is_a?(Hash)
|
10
|
+
@indices = indices
|
11
|
+
raise ArgumentError, 'Please pass index or indices to search' if @indices.empty?
|
12
|
+
if @options
|
13
|
+
Configuration.wrapper @options[:wrapper] if @options[:wrapper]
|
14
|
+
end
|
15
|
+
instance_eval(&block) if block_given?
|
16
|
+
end
|
17
|
+
|
18
|
+
def query(&block)
|
19
|
+
@query = Query.new
|
20
|
+
block.arity < 1 ? @query.instance_eval(&block) : block.call(@query)
|
21
|
+
self
|
22
|
+
end
|
23
|
+
|
24
|
+
def sort(&block)
|
25
|
+
@sort = Sort.new(&block)
|
26
|
+
self
|
27
|
+
end
|
28
|
+
|
29
|
+
def facet(name, options={}, &block)
|
30
|
+
@facets ||= {}
|
31
|
+
@facets.update Facet.new(name, options, &block).to_hash
|
32
|
+
self
|
33
|
+
end
|
34
|
+
|
35
|
+
def filter(type, *options)
|
36
|
+
@filters ||= []
|
37
|
+
@filters << Filter.new(type, *options).to_hash
|
38
|
+
self
|
39
|
+
end
|
40
|
+
|
41
|
+
def highlight(*args)
|
42
|
+
unless args.empty?
|
43
|
+
@highlight = Highlight.new(*args)
|
44
|
+
self
|
45
|
+
else
|
46
|
+
@highlight
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def from(value)
|
51
|
+
@from = value
|
52
|
+
self
|
53
|
+
end
|
54
|
+
|
55
|
+
def size(value)
|
56
|
+
@size = value
|
57
|
+
self
|
58
|
+
end
|
59
|
+
|
60
|
+
def fields(fields=[])
|
61
|
+
@fields = fields
|
62
|
+
self
|
63
|
+
end
|
64
|
+
|
65
|
+
def perform
|
66
|
+
@url = "#{Configuration.url}/#{indices.join(',')}/_search"
|
67
|
+
@response = Configuration.client.post(@url, self.to_json)
|
68
|
+
@json = Yajl::Parser.parse(@response.body)
|
69
|
+
@results = Results::Collection.new(@json, @options)
|
70
|
+
self
|
71
|
+
rescue Exception => error
|
72
|
+
STDERR.puts "[REQUEST FAILED] #{self.to_curl}\n"
|
73
|
+
raise
|
74
|
+
ensure
|
75
|
+
logged(error)
|
76
|
+
end
|
77
|
+
|
78
|
+
def to_curl
|
79
|
+
%Q|curl -X POST "#{Configuration.url}/#{indices.join(',')}/_search?pretty=true" -d '#{self.to_json}'|
|
80
|
+
end
|
81
|
+
|
82
|
+
def to_json
|
83
|
+
request = {}
|
84
|
+
request.update( { :query => @query } )
|
85
|
+
request.update( { :sort => @sort } ) if @sort
|
86
|
+
request.update( { :facets => @facets } ) if @facets
|
87
|
+
@filters.each { |filter| request.update( { :filter => filter } ) } if @filters
|
88
|
+
request.update( { :highlight => @highlight } ) if @highlight
|
89
|
+
request.update( { :size => @size } ) if @size
|
90
|
+
request.update( { :from => @from } ) if @from
|
91
|
+
request.update( { :fields => @fields } ) if @fields
|
92
|
+
Yajl::Encoder.encode(request)
|
93
|
+
end
|
94
|
+
|
95
|
+
def logged(error=nil)
|
96
|
+
if Configuration.logger
|
97
|
+
|
98
|
+
Configuration.logger.log_request '_search', indices, to_curl
|
99
|
+
|
100
|
+
code = @response ? @response.code : error.message
|
101
|
+
took = @json['took'] rescue nil
|
102
|
+
|
103
|
+
if Configuration.logger.level.to_s == 'debug'
|
104
|
+
# FIXME: Depends on RestClient implementation
|
105
|
+
body = @response ? Yajl::Encoder.encode(@json, :pretty => true) : body = error.http_body
|
106
|
+
else
|
107
|
+
body = ''
|
108
|
+
end
|
109
|
+
|
110
|
+
Configuration.logger.log_response code, took, body
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
end
|
115
|
+
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Tire
|
2
|
+
module Search
|
3
|
+
|
4
|
+
#--
|
5
|
+
# TODO: Implement all elastic search facets (geo, histogram, range, etc)
|
6
|
+
# http://elasticsearch.org/guide/reference/api/search/facets/
|
7
|
+
#++
|
8
|
+
|
9
|
+
class Facet
|
10
|
+
|
11
|
+
def initialize(name, options={}, &block)
|
12
|
+
@name = name
|
13
|
+
@options = options
|
14
|
+
self.instance_eval(&block) if block_given?
|
15
|
+
end
|
16
|
+
|
17
|
+
def terms(field, options={})
|
18
|
+
size = options.delete(:size) || 10
|
19
|
+
all_terms = options.delete(:all_terms) || false
|
20
|
+
@value = { :terms => { :field => field, :size => size, :all_terms => all_terms } }.update(options)
|
21
|
+
self
|
22
|
+
end
|
23
|
+
|
24
|
+
def date(field, options={})
|
25
|
+
interval = options.delete(:interval) || 'day'
|
26
|
+
@value = { :date_histogram => { :field => field, :interval => interval } }.update(options)
|
27
|
+
self
|
28
|
+
end
|
29
|
+
|
30
|
+
def to_json
|
31
|
+
to_hash.to_json
|
32
|
+
end
|
33
|
+
|
34
|
+
def to_hash
|
35
|
+
@value.update @options
|
36
|
+
{ @name => @value }
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Tire
|
2
|
+
module Search
|
3
|
+
|
4
|
+
# http://www.elasticsearch.org/guide/reference/api/search/filter.html
|
5
|
+
# http://www.elasticsearch.org/guide/reference/query-dsl/
|
6
|
+
#
|
7
|
+
class Filter
|
8
|
+
|
9
|
+
def initialize(type, *options)
|
10
|
+
value = if options.size < 2
|
11
|
+
options.first || {}
|
12
|
+
else
|
13
|
+
options # An +or+ filter encodes multiple filters as an array
|
14
|
+
end
|
15
|
+
@hash = { type => value }
|
16
|
+
end
|
17
|
+
|
18
|
+
def to_json
|
19
|
+
to_hash.to_json
|
20
|
+
end
|
21
|
+
|
22
|
+
def to_hash
|
23
|
+
@hash
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Tire
|
2
|
+
module Search
|
3
|
+
|
4
|
+
# http://www.elasticsearch.org/guide/reference/api/search/highlighting.html
|
5
|
+
#
|
6
|
+
class Highlight
|
7
|
+
|
8
|
+
def initialize(*args)
|
9
|
+
@options = (args.last.is_a?(Hash) && args.last.delete(:options)) || {}
|
10
|
+
extract_highlight_tags
|
11
|
+
@fields = args.inject({}) do |result, field|
|
12
|
+
field.is_a?(Hash) ? result.update(field) : result[field.to_sym] = {}; result
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_json
|
17
|
+
to_hash.to_json
|
18
|
+
end
|
19
|
+
|
20
|
+
def to_hash
|
21
|
+
{ :fields => @fields }.update @options
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def extract_highlight_tags
|
27
|
+
if tag = @options.delete(:tag)
|
28
|
+
@options.update \
|
29
|
+
:pre_tags => [tag],
|
30
|
+
:post_tags => [tag.to_s.gsub(/^<([a-z]+).*/, '</\1>')]
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Tire
|
2
|
+
module Search
|
3
|
+
|
4
|
+
class Query
|
5
|
+
def initialize(&block)
|
6
|
+
self.instance_eval(&block) if block_given?
|
7
|
+
end
|
8
|
+
|
9
|
+
def term(field, value)
|
10
|
+
@value = { :term => { field => value } }
|
11
|
+
end
|
12
|
+
|
13
|
+
def terms(field, value, options={})
|
14
|
+
@value = { :terms => { field => value } }
|
15
|
+
@value[:terms].update( { :minimum_match => options[:minimum_match] } ) if options[:minimum_match]
|
16
|
+
@value
|
17
|
+
end
|
18
|
+
|
19
|
+
def string(value, options={})
|
20
|
+
@value = { :query_string => { :query => value } }
|
21
|
+
@value[:query_string].update( { :default_field => options[:default_field] } ) if options[:default_field]
|
22
|
+
# TODO: https://github.com/elasticsearch/elasticsearch/wiki/Query-String-Query
|
23
|
+
@value
|
24
|
+
end
|
25
|
+
|
26
|
+
def all
|
27
|
+
@value = { :match_all => {} }
|
28
|
+
@value
|
29
|
+
end
|
30
|
+
|
31
|
+
def ids(values, type)
|
32
|
+
@value = { :ids => { :values => values, :type => type } }
|
33
|
+
end
|
34
|
+
|
35
|
+
def to_json
|
36
|
+
@value.to_json
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Tire
|
2
|
+
module Search
|
3
|
+
|
4
|
+
class Sort
|
5
|
+
def initialize(&block)
|
6
|
+
@value = []
|
7
|
+
self.instance_eval(&block) if block_given?
|
8
|
+
end
|
9
|
+
|
10
|
+
def field(name, direction=nil)
|
11
|
+
@value << ( direction ? { name => direction } : name )
|
12
|
+
self
|
13
|
+
end
|
14
|
+
|
15
|
+
def method_missing(id, *args, &block)
|
16
|
+
case arg = args.shift
|
17
|
+
when String, Symbol, Hash then @value << { id => arg }
|
18
|
+
else @value << id
|
19
|
+
end
|
20
|
+
self
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_json
|
24
|
+
@value.to_json
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
29
|
+
end
|
data/lib/tire/tasks.rb
ADDED
@@ -0,0 +1,88 @@
|
|
1
|
+
require 'rake'
|
2
|
+
require 'benchmark'
|
3
|
+
|
4
|
+
namespace :tire do
|
5
|
+
|
6
|
+
usage = <<-DESC
|
7
|
+
Import data from your model using paginate: rake environment tire:import CLASS='MyModel'
|
8
|
+
|
9
|
+
Pass params for the `paginate` method:
|
10
|
+
$ rake environment tire:import CLASS='Article' PARAMS='{:page => 1}'
|
11
|
+
|
12
|
+
Force rebuilding the index (delete and create):
|
13
|
+
$ rake environment tire:import CLASS='Article' PARAMS='{:page => 1}' FORCE=1
|
14
|
+
|
15
|
+
Set target index name:
|
16
|
+
$ rake environment tire:import CLASS='Article' INDEX='articles-new'
|
17
|
+
|
18
|
+
DESC
|
19
|
+
|
20
|
+
desc usage.split("\n").first.to_s
|
21
|
+
task :import do
|
22
|
+
|
23
|
+
def elapsed_to_human(elapsed)
|
24
|
+
hour = 60*60
|
25
|
+
day = hour*24
|
26
|
+
|
27
|
+
case elapsed
|
28
|
+
when 0..59
|
29
|
+
"#{sprintf("%1.5f", elapsed)} seconds"
|
30
|
+
when 60..hour-1
|
31
|
+
"#{elapsed/60} minutes and #{elapsed % 60} seconds"
|
32
|
+
when hour..day
|
33
|
+
"#{elapsed/hour} hours and #{elapsed % hour} minutes"
|
34
|
+
else
|
35
|
+
"#{elapsed/hour} hours"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
if ENV['CLASS'].to_s == ''
|
40
|
+
puts '='*80, 'USAGE', '='*80, usage.gsub(/ /, '')
|
41
|
+
exit(1)
|
42
|
+
end
|
43
|
+
|
44
|
+
klass = eval(ENV['CLASS'].to_s)
|
45
|
+
params = eval(ENV['PARAMS'].to_s) || {}
|
46
|
+
|
47
|
+
index = Tire::Index.new( ENV['INDEX'] || klass.index.name )
|
48
|
+
|
49
|
+
if ENV['FORCE']
|
50
|
+
puts "[IMPORT] Deleting index '#{index.name}'"
|
51
|
+
index.delete
|
52
|
+
end
|
53
|
+
|
54
|
+
unless index.exists?
|
55
|
+
puts "[IMPORT] Creating index '#{index.name}' with mapping:",
|
56
|
+
Yajl::Encoder.encode(klass.mapping_to_hash, :pretty => true)
|
57
|
+
index.create :mappings => klass.mapping_to_hash
|
58
|
+
end
|
59
|
+
|
60
|
+
STDOUT.sync = true
|
61
|
+
puts "[IMPORT] Starting import for the '#{ENV['CLASS']}' class"
|
62
|
+
tty_cols = 80
|
63
|
+
total = klass.count rescue nil
|
64
|
+
offset = (total.to_s.size*2)+8
|
65
|
+
done = 0
|
66
|
+
|
67
|
+
STDOUT.puts '-'*tty_cols
|
68
|
+
elapsed = Benchmark.realtime do
|
69
|
+
index.import(klass, 'paginate', params) do |documents|
|
70
|
+
|
71
|
+
if total
|
72
|
+
done += documents.size
|
73
|
+
# I CAN HAZ PROGREZ BAR LIEK HOMEBRU!
|
74
|
+
percent = ( (done.to_f / total) * 100 ).to_i
|
75
|
+
glyphs = ( percent * ( (tty_cols-offset).to_f/100 ) ).to_i
|
76
|
+
STDOUT.print( "#" * glyphs )
|
77
|
+
STDOUT.print( "\r"*tty_cols+"#{done}/#{total} | \e[1m#{percent}%\e[0m " )
|
78
|
+
end
|
79
|
+
|
80
|
+
# Don't forget to return the documents collection back!
|
81
|
+
documents
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
puts "", '='*80, "Import finished in #{elapsed_to_human(elapsed)}"
|
86
|
+
|
87
|
+
end
|
88
|
+
end
|