elastictastic 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. data/LICENSE +19 -0
  2. data/README.md +326 -0
  3. data/lib/elastictastic/association.rb +21 -0
  4. data/lib/elastictastic/bulk_persistence_strategy.rb +70 -0
  5. data/lib/elastictastic/callbacks.rb +30 -0
  6. data/lib/elastictastic/child_collection_proxy.rb +56 -0
  7. data/lib/elastictastic/client.rb +101 -0
  8. data/lib/elastictastic/configuration.rb +35 -0
  9. data/lib/elastictastic/dirty.rb +130 -0
  10. data/lib/elastictastic/discrete_persistence_strategy.rb +52 -0
  11. data/lib/elastictastic/document.rb +98 -0
  12. data/lib/elastictastic/errors.rb +7 -0
  13. data/lib/elastictastic/field.rb +38 -0
  14. data/lib/elastictastic/index.rb +19 -0
  15. data/lib/elastictastic/mass_assignment_security.rb +15 -0
  16. data/lib/elastictastic/middleware.rb +119 -0
  17. data/lib/elastictastic/nested_document.rb +29 -0
  18. data/lib/elastictastic/new_relic_instrumentation.rb +26 -0
  19. data/lib/elastictastic/observer.rb +3 -0
  20. data/lib/elastictastic/observing.rb +21 -0
  21. data/lib/elastictastic/parent_child.rb +115 -0
  22. data/lib/elastictastic/persistence.rb +67 -0
  23. data/lib/elastictastic/properties.rb +236 -0
  24. data/lib/elastictastic/railtie.rb +35 -0
  25. data/lib/elastictastic/resource.rb +4 -0
  26. data/lib/elastictastic/scope.rb +283 -0
  27. data/lib/elastictastic/scope_builder.rb +32 -0
  28. data/lib/elastictastic/scoped.rb +20 -0
  29. data/lib/elastictastic/search.rb +180 -0
  30. data/lib/elastictastic/server_error.rb +15 -0
  31. data/lib/elastictastic/test_helpers.rb +172 -0
  32. data/lib/elastictastic/util.rb +63 -0
  33. data/lib/elastictastic/validations.rb +45 -0
  34. data/lib/elastictastic/version.rb +3 -0
  35. data/lib/elastictastic.rb +82 -0
  36. data/spec/environment.rb +6 -0
  37. data/spec/examples/active_model_lint_spec.rb +20 -0
  38. data/spec/examples/bulk_persistence_strategy_spec.rb +233 -0
  39. data/spec/examples/callbacks_spec.rb +96 -0
  40. data/spec/examples/dirty_spec.rb +238 -0
  41. data/spec/examples/document_spec.rb +600 -0
  42. data/spec/examples/mass_assignment_security_spec.rb +13 -0
  43. data/spec/examples/middleware_spec.rb +92 -0
  44. data/spec/examples/observing_spec.rb +141 -0
  45. data/spec/examples/parent_child_spec.rb +308 -0
  46. data/spec/examples/properties_spec.rb +92 -0
  47. data/spec/examples/scope_spec.rb +491 -0
  48. data/spec/examples/search_spec.rb +382 -0
  49. data/spec/examples/spec_helper.rb +15 -0
  50. data/spec/examples/validation_spec.rb +65 -0
  51. data/spec/models/author.rb +9 -0
  52. data/spec/models/blog.rb +5 -0
  53. data/spec/models/comment.rb +5 -0
  54. data/spec/models/post.rb +41 -0
  55. data/spec/models/post_observer.rb +11 -0
  56. data/spec/support/fakeweb_request_history.rb +13 -0
  57. metadata +227 -0
@@ -0,0 +1,130 @@
1
+ module Elastictastic
2
+ module Dirty
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include ActiveModel::Dirty
7
+ end
8
+
9
+ module ClassMethods
10
+ def define_field(field_name, options, &block)
11
+ super
12
+ define_dirty_accessors(field_name)
13
+ end
14
+
15
+ def define_embed(embed_name, options)
16
+ super
17
+ define_dirty_accessors(embed_name)
18
+ end
19
+
20
+ private
21
+
22
+ #
23
+ # We have to rewrite ActiveModel functionality here because in Rails 3.0,
24
+ # #define_attribute_methods has to be called exactly one time, and there's
25
+ # no place for us to do that. This appears to be fixed in ActiveModel 3.1
26
+ #
27
+ def define_dirty_accessors(attribute)
28
+ attribute = attribute.to_s
29
+ module_eval <<-RUBY, __FILE__, __LINE__+1
30
+ def #{attribute}_changed?
31
+ attribute_changed?(#{attribute.inspect})
32
+ end
33
+
34
+ def #{attribute}_change
35
+ attribute_change(#{attribute.inspect})
36
+ end
37
+
38
+ def #{attribute}_will_change!
39
+ attribute_will_change!(#{attribute.inspect})
40
+ end
41
+
42
+ def #{attribute}_was
43
+ attribute_was(#{attribute.inspect})
44
+ end
45
+
46
+ def reset_#{attribute}!
47
+ reset_attribute!(#{attribute})
48
+ end
49
+ RUBY
50
+ end
51
+ end
52
+
53
+ module InstanceMethods
54
+ def write_attribute(field, value)
55
+ attribute_will_change!(field)
56
+ super
57
+ end
58
+
59
+ def write_embed(field, value)
60
+ attribute_will_change!(field)
61
+ if Array === value
62
+ value.each do |el|
63
+ el.nesting_document = self
64
+ el.nesting_association = field
65
+ end
66
+ super(field, NestedCollectionProxy.new(self, field, value))
67
+ else
68
+ value.nesting_document = self
69
+ value.nesting_association = field
70
+ super
71
+ end
72
+ end
73
+
74
+ def save
75
+ super
76
+ clean_attributes!
77
+ end
78
+
79
+ def elasticsearch_doc=(doc)
80
+ super
81
+ clean_attributes!
82
+ end
83
+
84
+ protected
85
+
86
+ def clean_attributes!
87
+ changed_attributes.clear
88
+ @embeds.each_pair do |name, embedded|
89
+ Util.call_or_map(embedded) { |doc| doc.clean_attributes! }
90
+ end
91
+ end
92
+ end
93
+
94
+ module NestedDocumentMethods
95
+ attr_writer :nesting_document, :nesting_association
96
+
97
+ def attribute_will_change!(field)
98
+ super
99
+ if @nesting_document
100
+ @nesting_document.__send__("attribute_will_change!", @nesting_association)
101
+ end
102
+ end
103
+ end
104
+
105
+ class NestedCollectionProxy < Array
106
+ def initialize(owner, embed_name, collection = [])
107
+ @owner, @embed_name = owner, embed_name
108
+ super(collection)
109
+ end
110
+
111
+ [
112
+ :<<, :[]=, :collect!, :compact!, :delete, :delete_at, :delete_if,
113
+ :flatten!, :insert, :keep_if, :map!, :push, :reject!, :replace,
114
+ :reverse!, :rotate!, :select!, :shuffle!, :slice!, :sort!, :sort_by!,
115
+ :uniq!
116
+ ].each do |destructive_method|
117
+ module_eval <<-RUBY, __FILE__, __LINE__+1
118
+ def #{destructive_method}(*args)
119
+ @owner.__send__("\#{@embed_name}_will_change!")
120
+ super
121
+ end
122
+ RUBY
123
+ end
124
+
125
+ def clone
126
+ NestedCollectionProxy.new(@owner, @embed_name, map { |el| el.clone })
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,52 @@
1
+ require 'singleton'
2
+
3
+ module Elastictastic
4
+ class DiscretePersistenceStrategy
5
+ include Singleton
6
+
7
+ attr_accessor :auto_refresh
8
+
9
+ def create(doc)
10
+ response = Elastictastic.client.create(
11
+ doc.index,
12
+ doc.class.type,
13
+ doc.id,
14
+ doc.elasticsearch_doc,
15
+ params_for(doc)
16
+ )
17
+ doc.id = response['_id']
18
+ doc.persisted!
19
+ end
20
+
21
+ def update(doc)
22
+ Elastictastic.client.update(
23
+ doc.index,
24
+ doc.class.type,
25
+ doc.id,
26
+ doc.elasticsearch_doc,
27
+ params_for(doc)
28
+ )
29
+ doc.persisted!
30
+ end
31
+
32
+ def destroy(doc)
33
+ response = Elastictastic.client.delete(
34
+ doc.index.name,
35
+ doc.class.type,
36
+ doc.id,
37
+ params_for(doc)
38
+ )
39
+ doc.transient!
40
+ response['found']
41
+ end
42
+
43
+ private
44
+
45
+ def params_for(doc)
46
+ {}.tap do |params|
47
+ params[:refresh] = true if Elastictastic.config.auto_refresh
48
+ params[:parent] = doc._parent_id if doc._parent_id
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,98 @@
1
+ module Elastictastic
2
+ module Document
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ extend Scoped
7
+ include Properties
8
+ include Persistence
9
+ include ParentChild
10
+ include Callbacks
11
+ include Observing
12
+ include Dirty
13
+ include MassAssignmentSecurity
14
+ include Validations
15
+
16
+ extend ActiveModel::Naming
17
+ include ActiveModel::Conversion
18
+ end
19
+
20
+ module ClassMethods
21
+ delegate :find, :destroy_all, :sync_mapping, :inspect, :find_each,
22
+ :find_in_batches, :first, :count, :empty?, :any?, :all,
23
+ :query, :filter, :from, :size, :sort, :highlight, :fields,
24
+ :script_fields, :preference, :facets, :to => :current_scope
25
+
26
+ def mapping
27
+ { type => { 'properties' => properties }}
28
+ end
29
+
30
+ def type
31
+ name.underscore
32
+ end
33
+
34
+ def in_index(name_or_index)
35
+ Scope.new(Elastictastic::Index(name_or_index), self)
36
+ end
37
+
38
+ def scoped(params)
39
+ current_scope.scoped(params)
40
+ end
41
+
42
+ private
43
+
44
+ def default_scope
45
+ in_index(Index.default)
46
+ end
47
+ end
48
+
49
+ module InstanceMethods
50
+ attr_reader :id
51
+
52
+ def initialize(attributes = {})
53
+ self.class.current_scope.initialize_instance(self)
54
+ end
55
+
56
+ def elasticsearch_hit=(hit) #:nodoc:
57
+ @id = hit['_id']
58
+ @index = Index.new(hit['_index'])
59
+ persisted!
60
+
61
+ doc = {}
62
+ doc.merge!(hit['_source']) if hit['_source']
63
+ fields = hit['fields']
64
+ if fields
65
+ unflattened_fields =
66
+ Util.unflatten_hash(fields.reject { |k, v| v.nil? })
67
+ if unflattened_fields.has_key?('_source')
68
+ doc.merge!(unflattened_fields.delete('_source'))
69
+ end
70
+ doc.merge!(unflattened_fields)
71
+ end
72
+ self.elasticsearch_doc=(doc)
73
+ end
74
+
75
+ def id=(id)
76
+ assert_transient!
77
+ @id = id
78
+ end
79
+
80
+ def index
81
+ return @index if defined? @index
82
+ @index = Index.default
83
+ end
84
+
85
+ def ==(other)
86
+ index == other.index && id == other.id
87
+ end
88
+
89
+ def inspect
90
+ inspected = "#<#{self.class.name} id: #{id}, index: #{index.name}"
91
+ attributes.each_pair do |attr, value|
92
+ inspected << ", #{attr}: #{value.inspect}"
93
+ end
94
+ inspected << ">"
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,7 @@
1
+ module Elastictastic
2
+ CancelBulkOperation = Class.new(StandardError)
3
+ IllegalModificationError = Class.new(StandardError)
4
+ OperationNotAllowed = Class.new(StandardError)
5
+ NoServerAvailable = Class.new(StandardError)
6
+ RecordInvalid = Class.new(StandardError)
7
+ end
@@ -0,0 +1,38 @@
1
+ module Elastictastic
2
+ class Field < BasicObject
3
+ private_class_method :new
4
+
5
+ def self.process(field_name, default_options, &block)
6
+ {}.tap do |properties|
7
+ new(field_name, default_options, properties, &block)
8
+ end
9
+ end
10
+
11
+ def self.with_defaults(options)
12
+ options = Util.deep_stringify(options)
13
+ { 'type' => 'string' }.merge(options).tap do |field_properties|
14
+ if field_properties['type'].to_s == 'date'
15
+ field_properties['format'] = 'date_time_no_millis'
16
+ end
17
+ end
18
+ end
19
+
20
+ def initialize(field_name, default_options, properties, &block)
21
+ @field_name = field_name
22
+ @properties = properties
23
+ if block
24
+ @properties['type'] = 'multi_field'
25
+ @properties['fields'] =
26
+ { field_name.to_s => Field.with_defaults(default_options) }
27
+ instance_eval(&block)
28
+ else
29
+ @properties.merge!(Field.with_defaults(default_options))
30
+ end
31
+ end
32
+
33
+ def field(field_name, options = {})
34
+ @properties['fields'][field_name.to_s] =
35
+ Field.with_defaults(options)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,19 @@
1
+ module Elastictastic
2
+ class Index
3
+ class <<self
4
+ def default
5
+ new(Elastictastic.config.default_index)
6
+ end
7
+ end
8
+
9
+ attr_reader :name
10
+
11
+ def initialize(name)
12
+ @name = name
13
+ end
14
+
15
+ def to_s
16
+ @name
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ module Elastictastic
2
+ module MassAssignmentSecurity
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include ActiveModel::MassAssignmentSecurity
7
+ end
8
+
9
+ module InstanceMethods
10
+ def attributes=(attributes)
11
+ super(sanitize_for_mass_assignment(attributes))
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,119 @@
1
+ require 'faraday'
2
+
3
+ module Elastictastic
4
+ module Middleware
5
+ class JsonEncodeBody < Faraday::Middleware
6
+ def call(env)
7
+ case env[:body]
8
+ when String, nil
9
+ # nothing
10
+ else env[:body] = env[:body].to_json
11
+ end
12
+ @app.call(env)
13
+ end
14
+ end
15
+
16
+ class JsonDecodeResponse < Faraday::Middleware
17
+ def call(env)
18
+ @app.call(env).on_complete do
19
+ env[:body] &&= JSON.parse(env[:body])
20
+ end
21
+ end
22
+ end
23
+
24
+ class RaiseServerErrors < Faraday::Middleware
25
+ ERROR_PATTERN = /^([A-Z][A-Za-z]*)(?::\s*)?(.*)$/
26
+
27
+ def call(env)
28
+ @app.call(env).on_complete do
29
+ body = env[:body]
30
+ if body['error']
31
+ raise_error(body['error'], body['status'])
32
+ elsif body['_shards'] && body['_shards']['failures']
33
+ raise_error(
34
+ body['_shards']['failures'].first['reason'], body['status'])
35
+ end
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def raise_error(server_message, status)
42
+ match = ERROR_PATTERN.match(server_message)
43
+ if match
44
+ clazz = Elastictastic::ServerError.const_get(match[1])
45
+ error = clazz.new(match[2])
46
+ error.status = status
47
+ Kernel.raise error
48
+ else
49
+ Kernel.raise Elastictastic::ServerError::ServerError, server_message
50
+ end
51
+ end
52
+ end
53
+
54
+ class LogRequests < Faraday::Middleware
55
+ def initialize(app, logger)
56
+ super(app)
57
+ @logger = logger
58
+ end
59
+
60
+ def call(env)
61
+ now = Time.now
62
+ body = env[:body]
63
+ @app.call(env).on_complete do
64
+ method = env[:method].to_s.upcase
65
+ time = ((Time.now - now) * 1000).to_i
66
+ message = "ElasticSearch #{method} (#{time}ms) #{env[:url].path}"
67
+ message << ' ' << body if body
68
+ @logger.debug(message)
69
+ end
70
+ end
71
+ end
72
+
73
+ class Rotor < Faraday::Middleware
74
+ def initialize(app, *hosts)
75
+ first = nil
76
+ hosts.each do |host|
77
+ node = Node.new(app, host)
78
+ first ||= node
79
+ @head.next = node if @head
80
+ @head = node
81
+ end
82
+ @head.next = first
83
+ end
84
+
85
+ def call(env)
86
+ last = @head
87
+ begin
88
+ @head = @head.next
89
+ @head.call(env)
90
+ rescue Faraday::Error::ConnectionFailed => e
91
+ raise NoServerAvailable if @head == last
92
+ retry
93
+ end
94
+ end
95
+
96
+ class Node < Faraday::Middleware
97
+ attr_accessor :next
98
+
99
+ def initialize(app, url)
100
+ super(app)
101
+ # Create a connection instance so we can use its #build_url method.
102
+ # Kinda lame -- seems like it would make more sense for Faraday to
103
+ # just implement a middleware for injecting a host/path prefix.
104
+ @connection = Faraday::Connection.new(:url => url)
105
+ end
106
+
107
+ def call(env)
108
+ original_url = env[:url]
109
+ begin
110
+ env[:url] = @connection.build_url(original_url)
111
+ @app.call(env)
112
+ ensure
113
+ env[:url] = original_url
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,29 @@
1
+ module Elastictastic
2
+ module NestedDocument
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include Properties
7
+ include Dirty
8
+ include Dirty::NestedDocumentMethods
9
+ include MassAssignmentSecurity
10
+ include Validations
11
+ end
12
+
13
+ module InstanceMethods
14
+ def initialize_copy(original)
15
+ self.write_attributes(original.read_attributes.dup)
16
+ end
17
+
18
+ def inspect
19
+ inspected = "#<#{self.class.name}"
20
+ if attributes.any?
21
+ inspected << ' ' << attributes.each_pair.map do |attr, value|
22
+ "#{attr}: #{value.inspect}"
23
+ end.join(', ')
24
+ end
25
+ inspected << '>'
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,26 @@
1
+ begin
2
+ require 'new_relic/agent/method_tracer'
3
+ rescue LoadError => e
4
+ raise LoadError, "Can't use NewRelic instrumentation without NewRelic gem"
5
+ end
6
+
7
+ module Elastictastic
8
+ module NewRelicInstrumentation
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ include NewRelic::Agent::MethodTracer
13
+
14
+ add_method_tracer :create, 'ElasticSearch/#{args[1].classify}#create'
15
+ add_method_tracer :delete, 'ElasticSearch/#{args[1].classify + "#" if args[1]}/delete'
16
+ add_method_tracer :get, 'ElasticSearch/#{args[1].classify}#get'
17
+ add_method_tracer :mget, 'ElasticSearch/mget'
18
+ add_method_tracer :put_mapping, 'ElasticSearch/#{args[1].classify}#put_mapping'
19
+ add_method_tracer :scroll, 'ElasticSearch/scroll'
20
+ add_method_tracer :search, 'ElasticSearch/#{args[1].classify}#search'
21
+ add_method_tracer :update, 'ElasticSearch/#{args[1].classify}#update'
22
+ end
23
+ end
24
+ end
25
+
26
+ Elastictastic::Client.module_eval { include Elastictastic::NewRelicInstrumentation }
@@ -0,0 +1,3 @@
1
+ module Elastictastic
2
+ Observer = Class.new(ActiveModel::Observer)
3
+ end
@@ -0,0 +1,21 @@
1
+ module Elastictastic
2
+ module Observing
3
+ extend ActiveSupport::Concern
4
+ extend ActiveModel::Observing::ClassMethods
5
+
6
+ included do
7
+ include ActiveModel::Observing
8
+ end
9
+
10
+ module InstanceMethods
11
+ Callbacks::HOOKS.each do |method|
12
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
13
+ def #{method}(*args)
14
+ notify_observers(:before_#{method})
15
+ super.tap { notify_observers(:after_#{method}) }
16
+ end
17
+ RUBY
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,115 @@
1
+ module Elastictastic
2
+ module ParentChild
3
+ extend ActiveSupport::Concern
4
+
5
+ module ClassMethods
6
+ attr_reader :parent_association
7
+
8
+ def belongs_to(parent_name, options = {})
9
+ @parent_association = Association.new(parent_name, options)
10
+
11
+ module_eval(<<-RUBY, __FILE__, __LINE__+1)
12
+ def #{parent_name}
13
+ _parent
14
+ end
15
+ RUBY
16
+ end
17
+
18
+ def has_many(children_name, options = {})
19
+ children_name = children_name.to_s
20
+ child_associations[children_name] = Association.new(children_name, options)
21
+
22
+ module_eval(<<-RUBY, __FILE__, __LINE__ + 1)
23
+ def #{children_name}
24
+ read_child(#{children_name.inspect})
25
+ end
26
+ RUBY
27
+ end
28
+
29
+ def child_association(name)
30
+ child_associations[name.to_s]
31
+ end
32
+
33
+ def child_associations
34
+ @child_associations ||= {}
35
+ end
36
+
37
+ def mapping
38
+ super.tap do |mapping|
39
+ mapping[type]['_parent'] = { 'type' => @parent_association.clazz.type } if @parent_association
40
+ end
41
+ end
42
+ end
43
+
44
+ module InstanceMethods
45
+
46
+ def initialize(attributes = {})
47
+ super
48
+ @children = Hash.new do |hash, child_association_name|
49
+ hash[child_association_name] = Elastictastic::ChildCollectionProxy.new(
50
+ self.class.child_association(child_association_name.to_s),
51
+ self
52
+ )
53
+ end
54
+ end
55
+
56
+ def elasticsearch_doc=(doc)
57
+ @parent_id = doc.delete('_parent')
58
+ super
59
+ end
60
+
61
+ def _parent #:nodoc:
62
+ return @parent if defined? @parent
63
+ @parent =
64
+ if @parent_id
65
+ self.class.parent_association.clazz.find(@parent_id)
66
+ end
67
+ end
68
+
69
+ def _parent_id #:nodoc:
70
+ if @parent
71
+ @parent_id = @parent.id
72
+ elsif @parent_id
73
+ @parent_id
74
+ end
75
+ end
76
+
77
+ def parent_collection=(parent_collection)
78
+ if @parent_collection
79
+ raise Elastictastic::IllegalModificationError,
80
+ "Document is already a child of #{_parent}"
81
+ end
82
+ if persisted?
83
+ raise Elastictastic::IllegalModificationError,
84
+ "Can't change parent of persisted object"
85
+ end
86
+ @parent_collection = parent_collection
87
+ @parent = parent_collection.parent
88
+ end
89
+
90
+ def save
91
+ super
92
+ self.class.child_associations.each_pair do |name, association|
93
+ association.extract(self).transient_children.each do |child|
94
+ child.save unless child.pending_save?
95
+ end
96
+ end
97
+ end
98
+
99
+ def persisted!
100
+ was_persisted = @persisted
101
+ super
102
+ if @parent_collection && !was_persisted
103
+ @parent_collection.persisted!(self)
104
+ end
105
+ end
106
+
107
+ protected
108
+
109
+ def read_child(field_name)
110
+ @children[field_name.to_s]
111
+ end
112
+
113
+ end
114
+ end
115
+ end