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.
Files changed (83) hide show
  1. data/.gitignore +9 -0
  2. data/Gemfile +4 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.markdown +435 -0
  5. data/Rakefile +75 -0
  6. data/examples/dsl.rb +73 -0
  7. data/examples/rails-application-template.rb +144 -0
  8. data/examples/tire-dsl.rb +617 -0
  9. data/lib/tire.rb +35 -0
  10. data/lib/tire/client.rb +40 -0
  11. data/lib/tire/configuration.rb +29 -0
  12. data/lib/tire/dsl.rb +33 -0
  13. data/lib/tire/index.rb +209 -0
  14. data/lib/tire/logger.rb +60 -0
  15. data/lib/tire/model/callbacks.rb +23 -0
  16. data/lib/tire/model/import.rb +18 -0
  17. data/lib/tire/model/indexing.rb +50 -0
  18. data/lib/tire/model/naming.rb +30 -0
  19. data/lib/tire/model/persistence.rb +34 -0
  20. data/lib/tire/model/persistence/attributes.rb +60 -0
  21. data/lib/tire/model/persistence/finders.rb +61 -0
  22. data/lib/tire/model/persistence/storage.rb +75 -0
  23. data/lib/tire/model/search.rb +97 -0
  24. data/lib/tire/results/collection.rb +56 -0
  25. data/lib/tire/results/item.rb +39 -0
  26. data/lib/tire/results/pagination.rb +30 -0
  27. data/lib/tire/rubyext/hash.rb +3 -0
  28. data/lib/tire/rubyext/symbol.rb +11 -0
  29. data/lib/tire/search.rb +117 -0
  30. data/lib/tire/search/facet.rb +41 -0
  31. data/lib/tire/search/filter.rb +28 -0
  32. data/lib/tire/search/highlight.rb +37 -0
  33. data/lib/tire/search/query.rb +42 -0
  34. data/lib/tire/search/sort.rb +29 -0
  35. data/lib/tire/tasks.rb +88 -0
  36. data/lib/tire/version.rb +3 -0
  37. data/test/fixtures/articles/1.json +1 -0
  38. data/test/fixtures/articles/2.json +1 -0
  39. data/test/fixtures/articles/3.json +1 -0
  40. data/test/fixtures/articles/4.json +1 -0
  41. data/test/fixtures/articles/5.json +1 -0
  42. data/test/integration/active_model_searchable_test.rb +80 -0
  43. data/test/integration/active_record_searchable_test.rb +193 -0
  44. data/test/integration/facets_test.rb +65 -0
  45. data/test/integration/filters_test.rb +46 -0
  46. data/test/integration/highlight_test.rb +52 -0
  47. data/test/integration/index_mapping_test.rb +44 -0
  48. data/test/integration/index_store_test.rb +68 -0
  49. data/test/integration/persistent_model_test.rb +35 -0
  50. data/test/integration/query_string_test.rb +43 -0
  51. data/test/integration/results_test.rb +28 -0
  52. data/test/integration/sort_test.rb +36 -0
  53. data/test/models/active_model_article.rb +31 -0
  54. data/test/models/active_model_article_with_callbacks.rb +49 -0
  55. data/test/models/active_model_article_with_custom_index_name.rb +5 -0
  56. data/test/models/active_record_article.rb +12 -0
  57. data/test/models/article.rb +15 -0
  58. data/test/models/persistent_article.rb +11 -0
  59. data/test/models/persistent_articles_with_custom_index_name.rb +10 -0
  60. data/test/models/supermodel_article.rb +22 -0
  61. data/test/models/validated_model.rb +11 -0
  62. data/test/test_helper.rb +52 -0
  63. data/test/unit/active_model_lint_test.rb +17 -0
  64. data/test/unit/client_test.rb +43 -0
  65. data/test/unit/configuration_test.rb +71 -0
  66. data/test/unit/index_test.rb +390 -0
  67. data/test/unit/logger_test.rb +114 -0
  68. data/test/unit/model_callbacks_test.rb +90 -0
  69. data/test/unit/model_import_test.rb +71 -0
  70. data/test/unit/model_persistence_test.rb +400 -0
  71. data/test/unit/model_search_test.rb +289 -0
  72. data/test/unit/results_collection_test.rb +131 -0
  73. data/test/unit/results_item_test.rb +59 -0
  74. data/test/unit/rubyext_hash_test.rb +19 -0
  75. data/test/unit/search_facet_test.rb +69 -0
  76. data/test/unit/search_filter_test.rb +36 -0
  77. data/test/unit/search_highlight_test.rb +46 -0
  78. data/test/unit/search_query_test.rb +55 -0
  79. data/test/unit/search_sort_test.rb +50 -0
  80. data/test/unit/search_test.rb +204 -0
  81. data/test/unit/tire_test.rb +55 -0
  82. data/tire.gemspec +54 -0
  83. 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
@@ -0,0 +1,3 @@
1
+ class Hash
2
+ alias_method :to_indexed_json, :to_json
3
+ end
@@ -0,0 +1,11 @@
1
+ # ActiveModel::Serialization Ruby < 1.9.x compatibility
2
+
3
+ class Symbol
4
+ def <=> other
5
+ self.to_s <=> other.to_s
6
+ end unless method_defined?(:'<=>')
7
+
8
+ def capitalize
9
+ to_s.capitalize
10
+ end unless method_defined?(:capitalize)
11
+ end
@@ -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
@@ -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