elasticsearch-model 0.0.1 → 0.1.0.rc1
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 +3 -0
- data/LICENSE.txt +1 -1
- data/README.md +669 -8
- data/Rakefile +52 -0
- data/elasticsearch-model.gemspec +48 -17
- data/examples/activerecord_article.rb +77 -0
- data/examples/activerecord_associations.rb +153 -0
- data/examples/couchbase_article.rb +66 -0
- data/examples/datamapper_article.rb +71 -0
- data/examples/mongoid_article.rb +68 -0
- data/examples/ohm_article.rb +70 -0
- data/examples/riak_article.rb +52 -0
- data/gemfiles/3.gemfile +11 -0
- data/gemfiles/4.gemfile +11 -0
- data/lib/elasticsearch/model.rb +151 -1
- data/lib/elasticsearch/model/adapter.rb +145 -0
- data/lib/elasticsearch/model/adapters/active_record.rb +97 -0
- data/lib/elasticsearch/model/adapters/default.rb +44 -0
- data/lib/elasticsearch/model/adapters/mongoid.rb +90 -0
- data/lib/elasticsearch/model/callbacks.rb +35 -0
- data/lib/elasticsearch/model/client.rb +61 -0
- data/lib/elasticsearch/model/importing.rb +94 -0
- data/lib/elasticsearch/model/indexing.rb +332 -0
- data/lib/elasticsearch/model/naming.rb +101 -0
- data/lib/elasticsearch/model/proxy.rb +127 -0
- data/lib/elasticsearch/model/response.rb +70 -0
- data/lib/elasticsearch/model/response/base.rb +44 -0
- data/lib/elasticsearch/model/response/pagination.rb +96 -0
- data/lib/elasticsearch/model/response/records.rb +71 -0
- data/lib/elasticsearch/model/response/result.rb +50 -0
- data/lib/elasticsearch/model/response/results.rb +32 -0
- data/lib/elasticsearch/model/searching.rb +107 -0
- data/lib/elasticsearch/model/serializing.rb +35 -0
- data/lib/elasticsearch/model/support/forwardable.rb +44 -0
- data/lib/elasticsearch/model/version.rb +1 -1
- data/test/integration/active_record_associations_parent_child.rb +138 -0
- data/test/integration/active_record_associations_test.rb +306 -0
- data/test/integration/active_record_basic_test.rb +139 -0
- data/test/integration/active_record_import_test.rb +74 -0
- data/test/integration/active_record_namespaced_model_test.rb +49 -0
- data/test/integration/active_record_pagination_test.rb +109 -0
- data/test/integration/mongoid_basic_test.rb +178 -0
- data/test/test_helper.rb +57 -0
- data/test/unit/adapter_active_record_test.rb +93 -0
- data/test/unit/adapter_default_test.rb +31 -0
- data/test/unit/adapter_mongoid_test.rb +87 -0
- data/test/unit/adapter_test.rb +69 -0
- data/test/unit/callbacks_test.rb +30 -0
- data/test/unit/client_test.rb +27 -0
- data/test/unit/importing_test.rb +97 -0
- data/test/unit/indexing_test.rb +364 -0
- data/test/unit/module_test.rb +46 -0
- data/test/unit/naming_test.rb +76 -0
- data/test/unit/proxy_test.rb +88 -0
- data/test/unit/response_base_test.rb +40 -0
- data/test/unit/response_pagination_test.rb +159 -0
- data/test/unit/response_records_test.rb +87 -0
- data/test/unit/response_result_test.rb +52 -0
- data/test/unit/response_results_test.rb +31 -0
- data/test/unit/response_test.rb +57 -0
- data/test/unit/searching_search_request_test.rb +73 -0
- data/test/unit/searching_test.rb +39 -0
- data/test/unit/serializing_test.rb +17 -0
- metadata +418 -11
@@ -0,0 +1,50 @@
|
|
1
|
+
module Elasticsearch
|
2
|
+
module Model
|
3
|
+
module Response
|
4
|
+
|
5
|
+
# Encapsulates the "hit" returned from the Elasticsearch client
|
6
|
+
#
|
7
|
+
# Wraps the raw Hash with in a `Hashie::Mash` instance, providing
|
8
|
+
# access to the Hash properties by calling Ruby methods.
|
9
|
+
#
|
10
|
+
# @see https://github.com/intridea/hashie
|
11
|
+
#
|
12
|
+
class Result
|
13
|
+
|
14
|
+
# @param attributes [Hash] A Hash with document properties
|
15
|
+
#
|
16
|
+
def initialize(attributes={})
|
17
|
+
@result = Hashie::Mash.new(attributes)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Delegate methods to `@result` or `@result._source`
|
21
|
+
#
|
22
|
+
def method_missing(method_name, *arguments)
|
23
|
+
case
|
24
|
+
when @result.respond_to?(method_name.to_sym)
|
25
|
+
@result.__send__ method_name.to_sym, *arguments
|
26
|
+
when @result._source && @result._source.respond_to?(method_name.to_sym)
|
27
|
+
@result._source.__send__ method_name.to_sym, *arguments
|
28
|
+
else
|
29
|
+
super
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Respond to methods from `@result` or `@result._source`
|
34
|
+
#
|
35
|
+
def respond_to?(method_name, include_private = false)
|
36
|
+
@result.respond_to?(method_name.to_sym) || \
|
37
|
+
@result._source && @result._source.respond_to?(method_name.to_sym) || \
|
38
|
+
super
|
39
|
+
end
|
40
|
+
|
41
|
+
def as_json(options={})
|
42
|
+
@result.as_json(options)
|
43
|
+
end
|
44
|
+
|
45
|
+
# TODO: #to_s, #inspect, with support for Pry
|
46
|
+
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Elasticsearch
|
2
|
+
module Model
|
3
|
+
module Response
|
4
|
+
|
5
|
+
# Encapsulates the collection of documents returned from Elasticsearch
|
6
|
+
#
|
7
|
+
# Implements Enumerable and forwards its methods to the {#results} object.
|
8
|
+
#
|
9
|
+
class Results
|
10
|
+
include Base
|
11
|
+
include Enumerable
|
12
|
+
|
13
|
+
extend Support::Forwardable
|
14
|
+
forward :results, :each, :empty?, :size, :slice, :[], :to_a, :to_ary
|
15
|
+
|
16
|
+
# @see Base#initialize
|
17
|
+
#
|
18
|
+
def initialize(klass, response, options={})
|
19
|
+
super
|
20
|
+
end
|
21
|
+
|
22
|
+
# Returns the {Results} collection
|
23
|
+
#
|
24
|
+
def results
|
25
|
+
# TODO: Configurable custom wrapper
|
26
|
+
@results = response.response['hits']['hits'].map { |hit| Result.new(hit) }
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
module Elasticsearch
|
2
|
+
module Model
|
3
|
+
|
4
|
+
# Contains functionality related to searching.
|
5
|
+
#
|
6
|
+
module Searching
|
7
|
+
|
8
|
+
# Wraps a search request definition
|
9
|
+
#
|
10
|
+
class SearchRequest
|
11
|
+
attr_reader :klass, :definition
|
12
|
+
|
13
|
+
# @param klass [Class] The class of the model
|
14
|
+
# @param query_or_payload [String,Hash,Object] The search request definition
|
15
|
+
# (string, JSON, Hash, or object responding to `to_hash`)
|
16
|
+
# @param options [Hash] Optional parameters to be passed to the Elasticsearch client
|
17
|
+
#
|
18
|
+
def initialize(klass, query_or_payload, options={})
|
19
|
+
@klass = klass
|
20
|
+
|
21
|
+
__index_name = options[:index] || klass.index_name
|
22
|
+
__document_type = options[:type] || klass.document_type
|
23
|
+
|
24
|
+
case
|
25
|
+
# search query: ...
|
26
|
+
when query_or_payload.respond_to?(:to_hash)
|
27
|
+
body = query_or_payload.to_hash
|
28
|
+
|
29
|
+
# search '{ "query" : ... }'
|
30
|
+
when query_or_payload.is_a?(String) && query_or_payload =~ /^\s*{/
|
31
|
+
body = query_or_payload
|
32
|
+
|
33
|
+
# search '...'
|
34
|
+
else
|
35
|
+
q = query_or_payload
|
36
|
+
end
|
37
|
+
|
38
|
+
if body
|
39
|
+
@definition = { index: __index_name, type: __document_type, body: body }.update options
|
40
|
+
else
|
41
|
+
@definition = { index: __index_name, type: __document_type, q: q }.update options
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Performs the request and returns the response from client
|
46
|
+
#
|
47
|
+
# @return [Hash] The response from Elasticsearch
|
48
|
+
#
|
49
|
+
def execute!
|
50
|
+
klass.client.search(@definition)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
module ClassMethods
|
55
|
+
|
56
|
+
# Provides a `search` method for the model to easily search within an index/type
|
57
|
+
# corresponding to the model settings.
|
58
|
+
#
|
59
|
+
# @param query_or_payload [String,Hash,Object] The search request definition
|
60
|
+
# (string, JSON, Hash, or object responding to `to_hash`)
|
61
|
+
# @param options [Hash] Optional parameters to be passed to the Elasticsearch client
|
62
|
+
#
|
63
|
+
# @return [Elasticsearch::Model::Response::Response]
|
64
|
+
#
|
65
|
+
# @example Simple search in `Article`
|
66
|
+
#
|
67
|
+
# Article.search 'foo'
|
68
|
+
#
|
69
|
+
# @example Search using a search definition as a Hash
|
70
|
+
#
|
71
|
+
# response = Article.search \
|
72
|
+
# query: {
|
73
|
+
# match: {
|
74
|
+
# title: 'foo'
|
75
|
+
# }
|
76
|
+
# },
|
77
|
+
# highlight: {
|
78
|
+
# fields: {
|
79
|
+
# title: {}
|
80
|
+
# }
|
81
|
+
# }
|
82
|
+
#
|
83
|
+
# response.results.first.title
|
84
|
+
# # => "Foo"
|
85
|
+
#
|
86
|
+
# response.results.first.highlight.title
|
87
|
+
# # => ["<em>Foo</em>"]
|
88
|
+
#
|
89
|
+
# response.records.first.title
|
90
|
+
# # Article Load (0.2ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" IN (1, 3)
|
91
|
+
# # => "Foo"
|
92
|
+
#
|
93
|
+
# @example Search using a search definition as a JSON string
|
94
|
+
#
|
95
|
+
# Article.search '{"query" : { "match_all" : {} }}'
|
96
|
+
#
|
97
|
+
def search(query_or_payload, options={})
|
98
|
+
search = SearchRequest.new(self, query_or_payload, options={})
|
99
|
+
|
100
|
+
Response::Response.new(self, search)
|
101
|
+
end
|
102
|
+
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Elasticsearch
|
2
|
+
module Model
|
3
|
+
|
4
|
+
# Contains functionality for serializing model instances for the client
|
5
|
+
#
|
6
|
+
module Serializing
|
7
|
+
|
8
|
+
module ClassMethods
|
9
|
+
end
|
10
|
+
|
11
|
+
module InstanceMethods
|
12
|
+
|
13
|
+
# Serialize the record as a Hash, to be passed to the client.
|
14
|
+
#
|
15
|
+
# Re-define this method to customize the serialization.
|
16
|
+
#
|
17
|
+
# @return [Hash]
|
18
|
+
#
|
19
|
+
# @example Return the model instance as a Hash
|
20
|
+
#
|
21
|
+
# Article.first.__elasticsearch__.as_indexed_json
|
22
|
+
# => {"title"=>"Foo"}
|
23
|
+
#
|
24
|
+
# @see Elasticsearch::Model::Indexing
|
25
|
+
#
|
26
|
+
def as_indexed_json(options={})
|
27
|
+
# TODO: Play with the `MyModel.indexes` method -- reject non-mapped attributes, `:as` options, etc
|
28
|
+
self.as_json(options.merge root: false)
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
module Elasticsearch
|
2
|
+
module Model
|
3
|
+
module Support
|
4
|
+
|
5
|
+
# Lightweight wrapper around "forwardable.rb" interface,
|
6
|
+
# to allow easy implementation changes in the future.
|
7
|
+
#
|
8
|
+
# Cf. https://github.com/mongoid/origin/blob/master/lib/origin/forwardable.rb
|
9
|
+
#
|
10
|
+
module Forwardable
|
11
|
+
def self.extended(base)
|
12
|
+
base.__send__ :extend, ::Forwardable
|
13
|
+
base.__send__ :extend, ::SingleForwardable
|
14
|
+
end
|
15
|
+
|
16
|
+
# Forwards specific method(s) to the provided receiver
|
17
|
+
#
|
18
|
+
# @example Forward the `each` method to `results` object
|
19
|
+
#
|
20
|
+
# MyClass.forward(:results, :each)
|
21
|
+
#
|
22
|
+
# @example Forward the `include?` method to `ancestors` class method
|
23
|
+
#
|
24
|
+
# MyClass.forward(:'self.ancestors', :include?)
|
25
|
+
#
|
26
|
+
# @param [ Symbol ] receiver The name of the receiver method
|
27
|
+
# @param [ Symbol, Array ] methods The forwarded methods
|
28
|
+
#
|
29
|
+
# @api private
|
30
|
+
#
|
31
|
+
def forward(receiver, *methods)
|
32
|
+
methods = Array(methods).flatten
|
33
|
+
target = self.__send__ :eval, receiver.to_s rescue nil
|
34
|
+
|
35
|
+
if target
|
36
|
+
single_delegate methods => receiver
|
37
|
+
else
|
38
|
+
instance_delegate methods => receiver
|
39
|
+
end
|
40
|
+
end; module_function :forward
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class Question < ActiveRecord::Base
|
4
|
+
include Elasticsearch::Model
|
5
|
+
|
6
|
+
has_many :answers, dependent: :destroy
|
7
|
+
|
8
|
+
index_name 'questions_and_answers'
|
9
|
+
|
10
|
+
mapping do
|
11
|
+
indexes :title
|
12
|
+
indexes :text
|
13
|
+
indexes :author
|
14
|
+
end
|
15
|
+
|
16
|
+
after_commit lambda { __elasticsearch__.index_document }, on: :create
|
17
|
+
after_commit lambda { __elasticsearch__.update_document }, on: :update
|
18
|
+
after_commit lambda { __elasticsearch__.delete_document }, on: :destroy
|
19
|
+
end
|
20
|
+
|
21
|
+
class Answer < ActiveRecord::Base
|
22
|
+
include Elasticsearch::Model
|
23
|
+
|
24
|
+
belongs_to :question
|
25
|
+
|
26
|
+
index_name 'questions_and_answers'
|
27
|
+
|
28
|
+
mapping _parent: { type: 'question', required: true } do
|
29
|
+
indexes :text
|
30
|
+
indexes :author
|
31
|
+
end
|
32
|
+
|
33
|
+
after_commit lambda { __elasticsearch__.index_document(parent: question_id) }, on: :create
|
34
|
+
after_commit lambda { __elasticsearch__.update_document(parent: question_id) }, on: :update
|
35
|
+
after_commit lambda { __elasticsearch__.delete_document(parent: question_id) }, on: :destroy
|
36
|
+
end
|
37
|
+
|
38
|
+
module ParentChildSearchable
|
39
|
+
INDEX_NAME = 'questions_and_answers'
|
40
|
+
|
41
|
+
def create_index!(options={})
|
42
|
+
client = Question.__elasticsearch__.client
|
43
|
+
client.indices.delete index: INDEX_NAME rescue nil if options[:force]
|
44
|
+
|
45
|
+
settings = Question.settings.to_hash.merge Answer.settings.to_hash
|
46
|
+
mappings = Question.mappings.to_hash.merge Answer.mappings.to_hash
|
47
|
+
|
48
|
+
client.indices.create index: INDEX_NAME,
|
49
|
+
body: {
|
50
|
+
settings: settings.to_hash,
|
51
|
+
mappings: mappings.to_hash }
|
52
|
+
end
|
53
|
+
|
54
|
+
extend self
|
55
|
+
end
|
56
|
+
|
57
|
+
module Elasticsearch
|
58
|
+
module Model
|
59
|
+
class ActiveRecordAssociationsParentChildIntegrationTest < Elasticsearch::Test::IntegrationTestCase
|
60
|
+
|
61
|
+
context "ActiveRecord associations with parent/child modelling" do
|
62
|
+
setup do
|
63
|
+
ActiveRecord::Schema.define(version: 1) do
|
64
|
+
create_table :questions do |t|
|
65
|
+
t.string :title
|
66
|
+
t.text :text
|
67
|
+
t.string :author
|
68
|
+
t.timestamps
|
69
|
+
end
|
70
|
+
create_table :answers do |t|
|
71
|
+
t.text :text
|
72
|
+
t.string :author
|
73
|
+
t.references :question
|
74
|
+
t.timestamps
|
75
|
+
end and add_index(:answers, :question_id)
|
76
|
+
end
|
77
|
+
|
78
|
+
Question.delete_all
|
79
|
+
ParentChildSearchable.create_index! force: true
|
80
|
+
|
81
|
+
q_1 = Question.create! title: 'First Question', author: 'John'
|
82
|
+
q_2 = Question.create! title: 'Second Question', author: 'Jody'
|
83
|
+
|
84
|
+
q_1.answers.create! text: 'Lorem Ipsum', author: 'Adam'
|
85
|
+
q_1.answers.create! text: 'Dolor Sit', author: 'Ryan'
|
86
|
+
|
87
|
+
q_2.answers.create! text: 'Amet Et', author: 'John'
|
88
|
+
|
89
|
+
Question.__elasticsearch__.refresh_index!
|
90
|
+
end
|
91
|
+
|
92
|
+
should "find questions by matching answers" do
|
93
|
+
response = Question.search(
|
94
|
+
{ query: {
|
95
|
+
has_child: {
|
96
|
+
type: 'answer',
|
97
|
+
query: {
|
98
|
+
match: {
|
99
|
+
author: 'john'
|
100
|
+
}
|
101
|
+
}
|
102
|
+
}
|
103
|
+
}
|
104
|
+
})
|
105
|
+
|
106
|
+
assert_equal 'Second Question', response.records.first.title
|
107
|
+
end
|
108
|
+
|
109
|
+
should "find answers for matching questions" do
|
110
|
+
response = Answer.search(
|
111
|
+
{ query: {
|
112
|
+
has_parent: {
|
113
|
+
parent_type: 'question',
|
114
|
+
query: {
|
115
|
+
match: {
|
116
|
+
author: 'john'
|
117
|
+
}
|
118
|
+
}
|
119
|
+
}
|
120
|
+
}
|
121
|
+
})
|
122
|
+
|
123
|
+
assert_same_elements ['Adam', 'Ryan'], response.records.map(&:author)
|
124
|
+
end
|
125
|
+
|
126
|
+
should "delete answers when the question is deleted" do
|
127
|
+
Question.where(title: 'First Question').each(&:destroy)
|
128
|
+
Question.__elasticsearch__.refresh_index!
|
129
|
+
|
130
|
+
response = Answer.search query: { match_all: {} }
|
131
|
+
|
132
|
+
assert_equal 1, response.results.total
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,306 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
# ----- Models definition -------------------------------------------------------------------------
|
4
|
+
|
5
|
+
class Category < ActiveRecord::Base
|
6
|
+
has_and_belongs_to_many :posts
|
7
|
+
end
|
8
|
+
|
9
|
+
class Author < ActiveRecord::Base
|
10
|
+
has_many :authorships
|
11
|
+
|
12
|
+
def full_name
|
13
|
+
[first_name, last_name].compact.join(' ')
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
class Authorship < ActiveRecord::Base
|
18
|
+
belongs_to :author
|
19
|
+
belongs_to :post, touch: true
|
20
|
+
end
|
21
|
+
|
22
|
+
class Comment < ActiveRecord::Base
|
23
|
+
belongs_to :post, touch: true
|
24
|
+
end
|
25
|
+
|
26
|
+
class Post < ActiveRecord::Base
|
27
|
+
has_and_belongs_to_many :categories, after_add: [ lambda { |a,c| a.__elasticsearch__.index_document } ],
|
28
|
+
after_remove: [ lambda { |a,c| a.__elasticsearch__.index_document } ]
|
29
|
+
has_many :authorships
|
30
|
+
has_many :authors, through: :authorships
|
31
|
+
has_many :comments
|
32
|
+
end
|
33
|
+
|
34
|
+
# ----- Search integration via Concern module -----------------------------------------------------
|
35
|
+
|
36
|
+
module Searchable
|
37
|
+
extend ActiveSupport::Concern
|
38
|
+
|
39
|
+
included do
|
40
|
+
include Elasticsearch::Model
|
41
|
+
include Elasticsearch::Model::Callbacks
|
42
|
+
|
43
|
+
# Set up the mapping
|
44
|
+
#
|
45
|
+
settings index: { number_of_shards: 1, number_of_replicas: 0 } do
|
46
|
+
mapping do
|
47
|
+
indexes :title, analyzer: 'snowball'
|
48
|
+
indexes :created_at, type: 'date'
|
49
|
+
|
50
|
+
indexes :authors do
|
51
|
+
indexes :first_name
|
52
|
+
indexes :last_name
|
53
|
+
indexes :full_name, type: 'multi_field' do
|
54
|
+
indexes :full_name
|
55
|
+
indexes :raw, analyzer: 'keyword'
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
indexes :categories, analyzer: 'keyword'
|
60
|
+
|
61
|
+
indexes :comments, type: 'nested' do
|
62
|
+
indexes :text
|
63
|
+
indexes :author
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Customize the JSON serialization for Elasticsearch
|
69
|
+
#
|
70
|
+
def as_indexed_json(options={})
|
71
|
+
{
|
72
|
+
title: title,
|
73
|
+
text: text,
|
74
|
+
categories: categories.map(&:title),
|
75
|
+
authors: authors.as_json(methods: [:full_name], only: [:full_name, :first_name, :last_name]),
|
76
|
+
comments: comments.as_json(only: [:text, :author])
|
77
|
+
}
|
78
|
+
end
|
79
|
+
|
80
|
+
# Update document in the index after touch
|
81
|
+
#
|
82
|
+
after_touch() { __elasticsearch__.index_document }
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Include the search integration
|
87
|
+
#
|
88
|
+
Post.__send__ :include, Searchable
|
89
|
+
|
90
|
+
module Elasticsearch
|
91
|
+
module Model
|
92
|
+
class ActiveRecordAssociationsIntegrationTest < Elasticsearch::Test::IntegrationTestCase
|
93
|
+
|
94
|
+
context "ActiveRecord associations" do
|
95
|
+
setup do
|
96
|
+
|
97
|
+
# ----- Schema definition ---------------------------------------------------------------
|
98
|
+
|
99
|
+
ActiveRecord::Schema.define(version: 1) do
|
100
|
+
create_table :categories do |t|
|
101
|
+
t.string :title
|
102
|
+
t.timestamps
|
103
|
+
end
|
104
|
+
|
105
|
+
create_table :categories_posts, id: false do |t|
|
106
|
+
t.references :post, :category
|
107
|
+
end
|
108
|
+
|
109
|
+
create_table :authors do |t|
|
110
|
+
t.string :first_name, :last_name
|
111
|
+
t.timestamps
|
112
|
+
end
|
113
|
+
|
114
|
+
create_table :authorships do |t|
|
115
|
+
t.string :first_name, :last_name
|
116
|
+
t.references :post
|
117
|
+
t.references :author
|
118
|
+
t.timestamps
|
119
|
+
end
|
120
|
+
|
121
|
+
create_table :comments do |t|
|
122
|
+
t.string :text
|
123
|
+
t.string :author
|
124
|
+
t.references :post
|
125
|
+
t.timestamps
|
126
|
+
end and add_index(:comments, :post_id)
|
127
|
+
|
128
|
+
create_table :posts do |t|
|
129
|
+
t.string :title
|
130
|
+
t.text :text
|
131
|
+
t.boolean :published
|
132
|
+
t.timestamps
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
# ----- Reset the index -----------------------------------------------------------------
|
137
|
+
|
138
|
+
Post.delete_all
|
139
|
+
Post.__elasticsearch__.create_index! force: true
|
140
|
+
end
|
141
|
+
|
142
|
+
should "index and find a document" do
|
143
|
+
Post.create! title: 'Test'
|
144
|
+
Post.create! title: 'Testing Coding'
|
145
|
+
Post.create! title: 'Coding'
|
146
|
+
Post.__elasticsearch__.refresh_index!
|
147
|
+
|
148
|
+
response = Post.search('title:test')
|
149
|
+
|
150
|
+
assert_equal 2, response.results.size
|
151
|
+
assert_equal 2, response.records.size
|
152
|
+
|
153
|
+
assert_equal 'Test', response.results.first.title
|
154
|
+
assert_equal 'Test', response.records.first.title
|
155
|
+
end
|
156
|
+
|
157
|
+
should "reindex a document after categories are changed" do
|
158
|
+
# Create categories
|
159
|
+
category_a = Category.where(title: "One").first_or_create!
|
160
|
+
category_b = Category.where(title: "Two").first_or_create!
|
161
|
+
|
162
|
+
# Create post
|
163
|
+
post = Post.create! title: "First Post", text: "This is the first post..."
|
164
|
+
|
165
|
+
# Assign categories
|
166
|
+
post.categories = [category_a, category_b]
|
167
|
+
|
168
|
+
Post.__elasticsearch__.refresh_index!
|
169
|
+
|
170
|
+
query = { query: {
|
171
|
+
filtered: {
|
172
|
+
query: {
|
173
|
+
multi_match: {
|
174
|
+
fields: ['title'],
|
175
|
+
query: 'first'
|
176
|
+
}
|
177
|
+
},
|
178
|
+
filter: {
|
179
|
+
terms: {
|
180
|
+
categories: ['One']
|
181
|
+
}
|
182
|
+
}
|
183
|
+
}
|
184
|
+
}
|
185
|
+
}
|
186
|
+
|
187
|
+
response = Post.search query
|
188
|
+
|
189
|
+
assert_equal 1, response.results.size
|
190
|
+
assert_equal 1, response.records.size
|
191
|
+
|
192
|
+
# Remove category "One"
|
193
|
+
post.categories = [category_b]
|
194
|
+
|
195
|
+
Post.__elasticsearch__.refresh_index!
|
196
|
+
response = Post.search query
|
197
|
+
|
198
|
+
assert_equal 0, response.results.size
|
199
|
+
assert_equal 0, response.records.size
|
200
|
+
end
|
201
|
+
|
202
|
+
should "reindex a document after authors are changed" do
|
203
|
+
# Create authors
|
204
|
+
author_a = Author.where(first_name: "John", last_name: "Smith").first_or_create!
|
205
|
+
author_b = Author.where(first_name: "Mary", last_name: "Smith").first_or_create!
|
206
|
+
author_c = Author.where(first_name: "Kobe", last_name: "Griss").first_or_create!
|
207
|
+
|
208
|
+
# Create posts
|
209
|
+
post_1 = Post.create! title: "First Post", text: "This is the first post..."
|
210
|
+
post_2 = Post.create! title: "Second Post", text: "This is the second post..."
|
211
|
+
post_3 = Post.create! title: "Third Post", text: "This is the third post..."
|
212
|
+
|
213
|
+
# Assign authors
|
214
|
+
post_1.authors = [author_a, author_b]
|
215
|
+
post_2.authors = [author_a]
|
216
|
+
post_3.authors = [author_c]
|
217
|
+
|
218
|
+
Post.__elasticsearch__.refresh_index!
|
219
|
+
|
220
|
+
response = Post.search 'authors.full_name:john'
|
221
|
+
|
222
|
+
assert_equal 2, response.results.size
|
223
|
+
assert_equal 2, response.records.size
|
224
|
+
|
225
|
+
post_3.authors << author_a
|
226
|
+
|
227
|
+
Post.__elasticsearch__.refresh_index!
|
228
|
+
|
229
|
+
response = Post.search 'authors.full_name:john'
|
230
|
+
|
231
|
+
assert_equal 3, response.results.size
|
232
|
+
assert_equal 3, response.records.size
|
233
|
+
end if defined?(::ActiveRecord) && ::ActiveRecord::VERSION::MAJOR >= 4
|
234
|
+
|
235
|
+
should "reindex a document after comments are added" do
|
236
|
+
# Create posts
|
237
|
+
post_1 = Post.create! title: "First Post", text: "This is the first post..."
|
238
|
+
post_2 = Post.create! title: "Second Post", text: "This is the second post..."
|
239
|
+
|
240
|
+
# Add comments
|
241
|
+
post_1.comments.create! author: 'John', text: 'Excellent'
|
242
|
+
post_1.comments.create! author: 'Abby', text: 'Good'
|
243
|
+
|
244
|
+
post_2.comments.create! author: 'John', text: 'Terrible'
|
245
|
+
|
246
|
+
Post.__elasticsearch__.refresh_index!
|
247
|
+
|
248
|
+
response = Post.search 'comments.author:john AND comments.text:good'
|
249
|
+
assert_equal 0, response.results.size
|
250
|
+
|
251
|
+
# Add comment
|
252
|
+
post_1.comments.create! author: 'John', text: 'Or rather just good...'
|
253
|
+
|
254
|
+
Post.__elasticsearch__.refresh_index!
|
255
|
+
|
256
|
+
response = Post.search 'comments.author:john AND comments.text:good'
|
257
|
+
assert_equal 0, response.results.size
|
258
|
+
|
259
|
+
response = Post.search \
|
260
|
+
query: {
|
261
|
+
nested: {
|
262
|
+
path: 'comments',
|
263
|
+
query: {
|
264
|
+
bool: {
|
265
|
+
must: [
|
266
|
+
{ match: { 'comments.author' => 'john' } },
|
267
|
+
{ match: { 'comments.text' => 'good' } }
|
268
|
+
]
|
269
|
+
}
|
270
|
+
}
|
271
|
+
}
|
272
|
+
}
|
273
|
+
|
274
|
+
assert_equal 1, response.results.size
|
275
|
+
end if defined?(::ActiveRecord) && ::ActiveRecord::VERSION::MAJOR >= 4
|
276
|
+
|
277
|
+
should "reindex a document after Post#touch" do
|
278
|
+
# Create categories
|
279
|
+
category_a = Category.where(title: "One").first_or_create!
|
280
|
+
|
281
|
+
# Create post
|
282
|
+
post = Post.create! title: "First Post", text: "This is the first post..."
|
283
|
+
|
284
|
+
# Assign category
|
285
|
+
post.categories << category_a
|
286
|
+
|
287
|
+
Post.__elasticsearch__.refresh_index!
|
288
|
+
|
289
|
+
assert_equal 1, Post.search('categories:One').size
|
290
|
+
|
291
|
+
# Update category
|
292
|
+
category_a.update_attribute :title, "Updated"
|
293
|
+
|
294
|
+
# Trigger touch on posts in category
|
295
|
+
category_a.posts.each { |p| p.touch }
|
296
|
+
|
297
|
+
Post.__elasticsearch__.refresh_index!
|
298
|
+
|
299
|
+
assert_equal 0, Post.search('categories:One').size
|
300
|
+
assert_equal 1, Post.search('categories:Updated').size
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
end
|
305
|
+
end
|
306
|
+
end
|