agnostic_backend 0.9.4 → 0.9.8
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +1 -0
- data/Gemfile +5 -0
- data/README.md +25 -15
- data/agnostic_backend.gemspec +2 -1
- data/lib/agnostic_backend.rb +4 -0
- data/lib/agnostic_backend/cloudsearch/index.rb +9 -17
- data/lib/agnostic_backend/cloudsearch/indexer.rb +24 -22
- data/lib/agnostic_backend/elasticsearch/client.rb +50 -0
- data/lib/agnostic_backend/elasticsearch/index.rb +67 -0
- data/lib/agnostic_backend/elasticsearch/index_field.rb +45 -0
- data/lib/agnostic_backend/elasticsearch/indexer.rb +62 -0
- data/lib/agnostic_backend/elasticsearch/remote_index_field.rb +31 -0
- data/lib/agnostic_backend/index.rb +25 -2
- data/lib/agnostic_backend/indexable/config.rb +41 -4
- data/lib/agnostic_backend/indexable/indexable.rb +9 -3
- data/lib/agnostic_backend/indexer.rb +28 -14
- data/lib/agnostic_backend/queryable/elasticsearch/executor.rb +40 -0
- data/lib/agnostic_backend/queryable/elasticsearch/query.rb +29 -0
- data/lib/agnostic_backend/queryable/elasticsearch/query_builder.rb +13 -0
- data/lib/agnostic_backend/queryable/elasticsearch/result_set.rb +27 -0
- data/lib/agnostic_backend/queryable/elasticsearch/visitor.rb +195 -0
- data/lib/agnostic_backend/rspec/matchers.rb +0 -1
- data/lib/agnostic_backend/version.rb +1 -1
- metadata +30 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a58464b8656e2798472a29b29e094aad2a3ad371
|
4
|
+
data.tar.gz: b719bf7b58574861ea9237a32db5b788e1813a1c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b4f4431dd6d69edd313476822b7a76cbf1f271969bb7c4a72ecc78f93143f7558c78243919598a9fbd821b1b608fb8334f5d1c2c472846bc32e5740d407c69a2
|
7
|
+
data.tar.gz: 19ef97db6ecb648f1b4ca63d4902bd5c51f1e084601e7f7c9560385656a1e454fa803c7e16e88d791ed44ab2d4d44e56e2c3098f6ee5b17c0a29e27ff6e545a3
|
data/.travis.yml
CHANGED
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -9,18 +9,17 @@ Version](https://badge.fury.io/rb/agnostic_backend.svg)](https://badge.fury.io/r
|
|
9
9
|
`agnostic_backend` is a gem that adds indexing and searching
|
10
10
|
capabilities to Ruby objects for various backends.
|
11
11
|
|
12
|
-
It includes two modules: `Indexable` and `Queryable`.
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
backend languages.
|
12
|
+
It includes two modules: `Indexable` and `Queryable`. `Indexable`
|
13
|
+
provides indexing functionality by specifying a way to define which
|
14
|
+
object attributes should be transformed in order to be eventually
|
15
|
+
indexed to a remote backend store. `Queryable` provides search and
|
16
|
+
retrieval functionality by specifying a generic query language that
|
17
|
+
seamlessly maps to specific backend languages.
|
19
18
|
|
20
19
|
In addition to these two modules, `agnostic_backend` supplies
|
21
|
-
additional classes (`Indexer` and `Index`) to support
|
22
|
-
|
23
|
-
|
20
|
+
additional classes (`Indexer` and `Index`) to support configuration
|
21
|
+
and transformation functionality for remote backends (such as
|
22
|
+
Elasticsearch, AWS Cloudsearch etc).
|
24
23
|
|
25
24
|
Although the motivation and use case for the gem relates to
|
26
25
|
`ActiveRecord` models, no assumption is made as to the classes to
|
@@ -28,9 +27,17 @@ which `Indexable` and `Queryable` can be included. The objective is to
|
|
28
27
|
maximize the flexibility of clients with respect to the use cases they
|
29
28
|
need to address.
|
30
29
|
|
31
|
-
## Supported
|
30
|
+
## Supported Backends
|
31
|
+
|
32
|
+
`agnostic_backend` currently includes implementations for the
|
33
|
+
following backends:
|
32
34
|
|
33
35
|
* [AWS Cloudsearch](https://aws.amazon.com/cloudsearch/)
|
36
|
+
* [elasticsearch](https://www.elastic.co/products/elasticsearch) [experimental]
|
37
|
+
|
38
|
+
The gem also supports the indexing of a document to multiple backends
|
39
|
+
(multicast indexing) in a seamless manner, namely by means of extra
|
40
|
+
configuration (rather than extra code) from the client's part.
|
34
41
|
|
35
42
|
## Installation
|
36
43
|
|
@@ -261,10 +268,13 @@ For more information about `Queryable` check out
|
|
261
268
|
|
262
269
|
### Backends
|
263
270
|
|
264
|
-
Currently, the gem includes
|
265
|
-
talks to [AWS Cloudsearch](https://aws.amazon.com/cloudsearch/)
|
266
|
-
|
267
|
-
|
271
|
+
Currently, the gem includes two concrete backend implementations: one
|
272
|
+
that talks to [AWS Cloudsearch](https://aws.amazon.com/cloudsearch/)
|
273
|
+
and one that talks to [elasticsearch](https://www.elastic.co/products/elasticsearch).
|
274
|
+
|
275
|
+
New backends can be implemented by subclassing
|
276
|
+
`AgnosticBackend::Index` and `AgnosticBackend::Indexer` (more on that
|
277
|
+
later).
|
268
278
|
|
269
279
|
#### The Index
|
270
280
|
|
data/agnostic_backend.gemspec
CHANGED
@@ -23,8 +23,9 @@ Gem::Specification.new do |spec|
|
|
23
23
|
spec.require_paths = ["lib"]
|
24
24
|
spec.required_ruby_version = '>= 2.1.0' # for mandatory method keyword arguments
|
25
25
|
|
26
|
-
spec.add_runtime_dependency "activesupport"
|
26
|
+
spec.add_runtime_dependency "activesupport"
|
27
27
|
spec.add_runtime_dependency "aws-sdk", "~> 2"
|
28
|
+
spec.add_runtime_dependency "faraday"
|
28
29
|
|
29
30
|
spec.add_development_dependency "bundler"
|
30
31
|
spec.add_development_dependency "rake", "~> 10"
|
data/lib/agnostic_backend.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
require 'agnostic_backend/version'
|
2
|
+
require 'active_support'
|
2
3
|
require 'active_support/core_ext'
|
3
4
|
|
4
5
|
require 'agnostic_backend/utilities'
|
@@ -45,6 +46,9 @@ require 'agnostic_backend/cloudsearch/index_field'
|
|
45
46
|
require 'agnostic_backend/cloudsearch/indexer'
|
46
47
|
require 'agnostic_backend/cloudsearch/remote_index_field'
|
47
48
|
|
49
|
+
Dir[File.dirname(__FILE__) + '/agnostic_backend/elasticsearch/*.rb'].each {|file| require file }
|
50
|
+
Dir[File.dirname(__FILE__) + '/agnostic_backend/queryable/elasticsearch/*.rb'].each {|file| require file }
|
51
|
+
|
48
52
|
module AgnosticBackend
|
49
53
|
|
50
54
|
end
|
@@ -9,16 +9,6 @@ module AgnosticBackend
|
|
9
9
|
:access_key_id,
|
10
10
|
:secret_access_key
|
11
11
|
|
12
|
-
def initialize(indexable_klass, **options)
|
13
|
-
super(indexable_klass)
|
14
|
-
@region = parse_option(options, :region)
|
15
|
-
@domain_name = parse_option(options, :domain_name)
|
16
|
-
@document_endpoint = parse_option(options, :document_endpoint)
|
17
|
-
@search_endpoint = parse_option(options, :search_endpoint)
|
18
|
-
@access_key_id = parse_option(options, :access_key_id)
|
19
|
-
@secret_access_key = parse_option(options, :secret_access_key)
|
20
|
-
end
|
21
|
-
|
22
12
|
def indexer
|
23
13
|
AgnosticBackend::Cloudsearch::Indexer.new(self)
|
24
14
|
end
|
@@ -87,13 +77,15 @@ module AgnosticBackend
|
|
87
77
|
end
|
88
78
|
end
|
89
79
|
|
90
|
-
def
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
80
|
+
def parse_options
|
81
|
+
@region = parse_option(:region)
|
82
|
+
@domain_name = parse_option(:domain_name)
|
83
|
+
@document_endpoint = parse_option(:document_endpoint)
|
84
|
+
@search_endpoint = parse_option(:search_endpoint)
|
85
|
+
@access_key_id = parse_option(:access_key_id)
|
86
|
+
@secret_access_key = parse_option(:secret_access_key)
|
96
87
|
end
|
88
|
+
|
97
89
|
end
|
98
90
|
end
|
99
|
-
end
|
91
|
+
end
|
@@ -2,44 +2,50 @@ require 'aws-sdk'
|
|
2
2
|
|
3
3
|
module AgnosticBackend
|
4
4
|
module Cloudsearch
|
5
|
+
|
6
|
+
class PayloadLimitExceededError < StandardError ; end
|
7
|
+
|
5
8
|
class Indexer < AgnosticBackend::Indexer
|
6
9
|
include AgnosticBackend::Utilities
|
7
10
|
|
8
|
-
|
9
|
-
@index = index
|
10
|
-
end
|
11
|
+
MAX_PAYLOAD_SIZE_IN_BYTES = 4_500_000
|
11
12
|
|
12
|
-
def
|
13
|
-
|
14
|
-
client.upload_documents(
|
15
|
-
documents: document,
|
16
|
-
content_type:'application/json'
|
17
|
-
)
|
18
|
-
end
|
13
|
+
def delete(document_id)
|
14
|
+
delete_all([document_id])
|
19
15
|
end
|
20
16
|
|
21
|
-
def
|
17
|
+
def delete_all(document_ids)
|
22
18
|
documents = document_ids.map do |document_id|
|
23
19
|
{"type" => 'delete',
|
24
20
|
"id" => document_id}
|
25
21
|
end
|
22
|
+
publish_all(documents)
|
23
|
+
end
|
26
24
|
|
25
|
+
private
|
26
|
+
|
27
|
+
def publish(document)
|
28
|
+
publish_all([document])
|
29
|
+
end
|
30
|
+
|
31
|
+
def publish_all(documents)
|
32
|
+
return if documents.empty?
|
33
|
+
payload = ActiveSupport::JSON.encode(documents)
|
34
|
+
raise PayloadLimitExceededError.new if payload_too_heavy? payload
|
27
35
|
with_exponential_backoff Aws::CloudSearch::Errors::Throttling do
|
28
36
|
client.upload_documents(
|
29
|
-
documents:
|
37
|
+
documents: payload,
|
30
38
|
content_type:'application/json'
|
31
|
-
|
32
39
|
)
|
33
40
|
end
|
34
41
|
end
|
35
42
|
|
36
|
-
private
|
37
|
-
|
38
43
|
def client
|
39
44
|
index.cloudsearch_domain_client
|
40
45
|
end
|
41
46
|
|
42
47
|
def prepare(document)
|
48
|
+
raise IndexingError.new "Document does not have an ID field" unless document["id"].present?
|
43
49
|
document
|
44
50
|
end
|
45
51
|
|
@@ -51,8 +57,7 @@ module AgnosticBackend
|
|
51
57
|
document = convert_bool_values_to_string_in document
|
52
58
|
document = date_format document
|
53
59
|
document = add_metadata_to document
|
54
|
-
document
|
55
|
-
convert_to_json document
|
60
|
+
document
|
56
61
|
|
57
62
|
end
|
58
63
|
|
@@ -72,13 +77,10 @@ module AgnosticBackend
|
|
72
77
|
}
|
73
78
|
end
|
74
79
|
|
75
|
-
def
|
76
|
-
|
80
|
+
def payload_too_heavy?(payload)
|
81
|
+
payload.bytesize > MAX_PAYLOAD_SIZE_IN_BYTES
|
77
82
|
end
|
78
83
|
|
79
|
-
def convert_document_into_array(document)
|
80
|
-
[document]
|
81
|
-
end
|
82
84
|
end
|
83
85
|
end
|
84
86
|
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'faraday'
|
2
|
+
|
3
|
+
module AgnosticBackend
|
4
|
+
module Elasticsearch
|
5
|
+
|
6
|
+
class Client
|
7
|
+
attr_reader :endpoint
|
8
|
+
|
9
|
+
def initialize(endpoint:)
|
10
|
+
@endpoint = endpoint
|
11
|
+
@connection = Faraday::Connection.new(url: endpoint)
|
12
|
+
end
|
13
|
+
|
14
|
+
# returns an array of RemoteIndexFields (or nil)
|
15
|
+
def describe_index_fields(index_name, type)
|
16
|
+
response = send_request(:get, path: "#{index_name}/_mapping/#{type}")
|
17
|
+
if response.success?
|
18
|
+
body = ActiveSupport::JSON.decode(response.body) if response.body.present?
|
19
|
+
return if body.blank?
|
20
|
+
fields = body[index_name.to_s]["mappings"][type.to_s]["properties"]
|
21
|
+
|
22
|
+
fields.map do |field_name, properties|
|
23
|
+
properties = Hash[ properties.map{|k,v| [k.to_sym, v]} ]
|
24
|
+
type = properties.delete(:type)
|
25
|
+
AgnosticBackend::Elasticsearch::RemoteIndexField.new field_name, type, **properties
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# sends an HTTP request to the ES server
|
31
|
+
# @body is taken to be either
|
32
|
+
# (a) a Hash (in which case it will be encoded to JSON), or
|
33
|
+
# (b) a string (in which case it will be assumed to contain JSON data)
|
34
|
+
# returns a Faraday::Response instance
|
35
|
+
def send_request(http_method, path: "", body: nil)
|
36
|
+
body = ActiveSupport::JSON.encode(body) if body.is_a? Hash
|
37
|
+
@connection.run_request(http_method.downcase.to_sym,
|
38
|
+
path.to_s,
|
39
|
+
body,
|
40
|
+
default_headers)
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def default_headers
|
46
|
+
{'Content-Type' => 'application/json'}
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
module AgnosticBackend
|
2
|
+
module Elasticsearch
|
3
|
+
class Index < AgnosticBackend::Index
|
4
|
+
|
5
|
+
attr_reader :index_name,
|
6
|
+
:type,
|
7
|
+
:endpoint,
|
8
|
+
:enable_all
|
9
|
+
|
10
|
+
def indexer
|
11
|
+
AgnosticBackend::Elasticsearch::Indexer.new(self)
|
12
|
+
end
|
13
|
+
|
14
|
+
def query_builder
|
15
|
+
AgnosticBackend::Queryable::Elasticsearch::QueryBuilder.new(self)
|
16
|
+
end
|
17
|
+
|
18
|
+
def schema
|
19
|
+
@schema ||= @indexable_klass.schema { |ftype| ftype }
|
20
|
+
end
|
21
|
+
|
22
|
+
def client
|
23
|
+
@client ||= AgnosticBackend::Elasticsearch::Client.new(endpoint: endpoint)
|
24
|
+
end
|
25
|
+
|
26
|
+
def configure
|
27
|
+
body = mappings(indexer.flatten(schema))
|
28
|
+
client.send_request(:put, path: "#{index_name}/_mapping/#{type}", body: body)
|
29
|
+
end
|
30
|
+
|
31
|
+
def create
|
32
|
+
client.send_request(:put, path: index_name)
|
33
|
+
end
|
34
|
+
|
35
|
+
def destroy!
|
36
|
+
client.send_request(:delete, path: index_name)
|
37
|
+
end
|
38
|
+
|
39
|
+
def exists?
|
40
|
+
response = client.send_request(:head, path: index_name)
|
41
|
+
response.success?
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def mappings(flat_schema)
|
47
|
+
{
|
48
|
+
"_all" => { "enabled" => enable_all },
|
49
|
+
"properties" => index_fields(flat_schema).map{|field| field.definition}.reduce({}, &:merge)
|
50
|
+
}
|
51
|
+
end
|
52
|
+
|
53
|
+
def index_fields(flat_schema)
|
54
|
+
flat_schema.map do |field_name, field_type|
|
55
|
+
AgnosticBackend::Elasticsearch::IndexField.new(field_name, field_type)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def parse_options
|
60
|
+
@index_name = parse_option(:index_name)
|
61
|
+
@type = parse_option(:type)
|
62
|
+
@endpoint = parse_option(:endpoint)
|
63
|
+
@enable_all = parse_option(:enable_all, optional: true, default: false)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module AgnosticBackend
|
2
|
+
module Elasticsearch
|
3
|
+
class IndexField
|
4
|
+
|
5
|
+
TYPE_MAPPINGS = {
|
6
|
+
AgnosticBackend::Indexable::FieldType::STRING => "string",
|
7
|
+
AgnosticBackend::Indexable::FieldType::STRING_ARRAY => "string",
|
8
|
+
AgnosticBackend::Indexable::FieldType::DATE => "date",
|
9
|
+
AgnosticBackend::Indexable::FieldType::INTEGER => "integer",
|
10
|
+
AgnosticBackend::Indexable::FieldType::DOUBLE => "double",
|
11
|
+
AgnosticBackend::Indexable::FieldType::BOOLEAN => "boolean",
|
12
|
+
AgnosticBackend::Indexable::FieldType::TEXT => "string",
|
13
|
+
AgnosticBackend::Indexable::FieldType::TEXT_ARRAY => "string",
|
14
|
+
}.freeze
|
15
|
+
|
16
|
+
attr_reader :name, :type
|
17
|
+
|
18
|
+
def initialize(name, type)
|
19
|
+
@name = name
|
20
|
+
@type = type
|
21
|
+
end
|
22
|
+
|
23
|
+
def analyzed?
|
24
|
+
(type.type == AgnosticBackend::Indexable::FieldType::TEXT) ||
|
25
|
+
(type.type == AgnosticBackend::Indexable::FieldType::TEXT_ARRAY)
|
26
|
+
end
|
27
|
+
|
28
|
+
def elasticsearch_type
|
29
|
+
@elasticsearch_type ||= TYPE_MAPPINGS[type.type]
|
30
|
+
end
|
31
|
+
|
32
|
+
def definition
|
33
|
+
{
|
34
|
+
name.to_s => {
|
35
|
+
"type" => elasticsearch_type
|
36
|
+
}.merge(analyzed_property)
|
37
|
+
}
|
38
|
+
end
|
39
|
+
|
40
|
+
def analyzed_property
|
41
|
+
analyzed? ? {} : { "index" => "not_analyzed" }
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module AgnosticBackend
|
2
|
+
module Elasticsearch
|
3
|
+
class Indexer < AgnosticBackend::Indexer
|
4
|
+
include AgnosticBackend::Utilities
|
5
|
+
|
6
|
+
private
|
7
|
+
|
8
|
+
def client
|
9
|
+
index.client
|
10
|
+
end
|
11
|
+
|
12
|
+
def publish(document)
|
13
|
+
client.send_request(:put,
|
14
|
+
path: "/#{index.index_name}/#{index.type}/#{document["id"]}",
|
15
|
+
body: document)
|
16
|
+
end
|
17
|
+
|
18
|
+
def publish_all(documents)
|
19
|
+
return if documents.empty?
|
20
|
+
response = client.send_request(:post,
|
21
|
+
path: "/#{index.index_name}/#{index.type}/_bulk",
|
22
|
+
body: convert_to_bulk_upload_string(documents))
|
23
|
+
body = ActiveSupport::JSON.decode(response.body) rescue {}
|
24
|
+
# if at least one indexing attempt fails, raise the red flag
|
25
|
+
raise IndexingError.new, "Error in bulk upload" if body["errors"]
|
26
|
+
response
|
27
|
+
end
|
28
|
+
|
29
|
+
|
30
|
+
def prepare(document)
|
31
|
+
raise IndexingError.new, "Document does not have an ID field" unless document["id"].present?
|
32
|
+
document
|
33
|
+
end
|
34
|
+
|
35
|
+
def transform(document)
|
36
|
+
return {} if document.empty?
|
37
|
+
|
38
|
+
document = flatten document
|
39
|
+
document = reject_blank_values_from document
|
40
|
+
document = format_dates_in document
|
41
|
+
document
|
42
|
+
end
|
43
|
+
|
44
|
+
def format_dates_in(document)
|
45
|
+
document.each do |k, v|
|
46
|
+
if v.is_a?(Time)
|
47
|
+
document[k] = v.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def convert_to_bulk_upload_string(documents)
|
53
|
+
documents.map do |document|
|
54
|
+
next if document.empty?
|
55
|
+
header = { "index" => {"_id" => document["id"]}}.to_json
|
56
|
+
document = ActiveSupport::JSON.encode transform(prepare(document))
|
57
|
+
"#{header}\n#{document}\n"
|
58
|
+
end.compact.join("\n")
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module AgnosticBackend
|
2
|
+
module Elasticsearch
|
3
|
+
|
4
|
+
class RemoteIndexField
|
5
|
+
|
6
|
+
attr_reader :name, :type
|
7
|
+
|
8
|
+
def initialize(name, type, **args)
|
9
|
+
@name = name
|
10
|
+
@type = to_local type
|
11
|
+
@options = args
|
12
|
+
end
|
13
|
+
|
14
|
+
def method_missing(method_name)
|
15
|
+
if @options.has_key? method_name
|
16
|
+
@options[method_name]
|
17
|
+
else
|
18
|
+
super
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def to_local(remote_type)
|
25
|
+
AgnosticBackend::Elasticsearch::IndexField::TYPE_MAPPINGS.find{|ltype, rtype| remote_type == rtype}.try(:first)
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
end
|
@@ -1,8 +1,17 @@
|
|
1
1
|
module AgnosticBackend
|
2
2
|
class Index
|
3
3
|
|
4
|
-
|
4
|
+
attr_reader :options
|
5
|
+
|
6
|
+
def initialize(indexable_klass, primary: true, **options)
|
5
7
|
@indexable_klass = indexable_klass
|
8
|
+
@primary = primary
|
9
|
+
@options = options
|
10
|
+
parse_options
|
11
|
+
end
|
12
|
+
|
13
|
+
def primary?
|
14
|
+
@primary
|
6
15
|
end
|
7
16
|
|
8
17
|
def name
|
@@ -20,5 +29,19 @@ module AgnosticBackend
|
|
20
29
|
def configure(new_schema = nil)
|
21
30
|
raise NotImplementedError
|
22
31
|
end
|
32
|
+
|
33
|
+
def parse_options
|
34
|
+
raise NotImplementedError
|
35
|
+
end
|
36
|
+
|
37
|
+
def parse_option(option_name, optional: false, default: nil)
|
38
|
+
if options.has_key?(option_name)
|
39
|
+
options[option_name]
|
40
|
+
elsif optional
|
41
|
+
default
|
42
|
+
else
|
43
|
+
raise "#{option_name} must be specified"
|
44
|
+
end
|
45
|
+
end
|
23
46
|
end
|
24
|
-
end
|
47
|
+
end
|
@@ -3,20 +3,57 @@ module AgnosticBackend
|
|
3
3
|
|
4
4
|
class Config
|
5
5
|
|
6
|
-
class
|
6
|
+
class Entry
|
7
|
+
|
8
|
+
attr_reader :index_class,
|
9
|
+
:options
|
10
|
+
|
11
|
+
def initialize(index_class:, indexable_class:, primary: true, **options)
|
12
|
+
@index_class = index_class
|
13
|
+
@indexable_class = indexable_class
|
14
|
+
@primary = primary
|
15
|
+
@options = options
|
16
|
+
end
|
17
|
+
|
18
|
+
def primary?
|
19
|
+
@primary
|
20
|
+
end
|
21
|
+
|
22
|
+
def create_index
|
23
|
+
@index_class.new(@indexable_class, primary: @primary, **@options)
|
24
|
+
end
|
7
25
|
end
|
8
26
|
|
27
|
+
|
9
28
|
def self.indices
|
10
29
|
@indices ||= {}
|
11
30
|
end
|
12
31
|
|
13
32
|
def self.configure_index(indexable_class, index_class, **options)
|
14
|
-
indices[indexable_class.name] =
|
33
|
+
indices[indexable_class.name] = [Entry.new(index_class: index_class,
|
34
|
+
indexable_class: indexable_class,
|
35
|
+
primary: true,
|
36
|
+
**options)]
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.configure_secondary_index(indexable_class, index_class, **options)
|
40
|
+
unless indices.has_key? indexable_class.name
|
41
|
+
raise "No primary index exists for class #{indexable_class.name}"
|
42
|
+
end
|
43
|
+
indices[indexable_class.name] << Entry.new(index_class: index_class,
|
44
|
+
indexable_class: indexable_class,
|
45
|
+
primary: false,
|
46
|
+
**options)
|
15
47
|
end
|
16
48
|
|
17
49
|
def self.create_index_for(indexable_class)
|
18
|
-
entry = indices[indexable_class.name]
|
19
|
-
entry.
|
50
|
+
entry = indices[indexable_class.name].find(&:primary?)
|
51
|
+
entry.try(:create_index)
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.create_indices_for(indexable_class, include_primary: true)
|
55
|
+
all = indices[indexable_class.name].map {|entry| entry.try(:create_index)}.compact
|
56
|
+
include_primary ? all : all.reject(&:primary?)
|
20
57
|
end
|
21
58
|
|
22
59
|
end
|
@@ -22,6 +22,11 @@ module AgnosticBackend
|
|
22
22
|
AgnosticBackend::Indexable::Config.create_index_for(self)
|
23
23
|
end
|
24
24
|
|
25
|
+
def create_indices(include_primary: true)
|
26
|
+
AgnosticBackend::Indexable::Config.create_indices_for(self,
|
27
|
+
include_primary: include_primary)
|
28
|
+
end
|
29
|
+
|
25
30
|
# establishes the convention for determining the index name from the class name
|
26
31
|
def index_name(source=nil)
|
27
32
|
(source.nil? ? name : source.to_s).split('::').last.underscore.pluralize
|
@@ -102,9 +107,10 @@ module AgnosticBackend
|
|
102
107
|
self.class :
|
103
108
|
AgnosticBackend::Indexable.indexable_class(index_name)
|
104
109
|
|
105
|
-
|
106
|
-
|
107
|
-
|
110
|
+
indexable_class.create_indices.map do |index|
|
111
|
+
indexer = index.indexer
|
112
|
+
indexer.put(self)
|
113
|
+
end
|
108
114
|
end
|
109
115
|
|
110
116
|
def index_object(index_name)
|
@@ -1,4 +1,7 @@
|
|
1
1
|
module AgnosticBackend
|
2
|
+
|
3
|
+
class IndexingError < StandardError; end
|
4
|
+
|
2
5
|
class Indexer
|
3
6
|
|
4
7
|
attr_reader :index
|
@@ -8,32 +11,43 @@ module AgnosticBackend
|
|
8
11
|
end
|
9
12
|
|
10
13
|
# Sends the specified document to the remote backend.
|
11
|
-
# This is a template method.
|
12
14
|
# @param [Indexable] an Indexable object
|
13
|
-
# @returns [boolean] true if success, false if failure
|
14
|
-
# returns nil if no indexing attempt is made (e.g. generated document is empty)
|
15
15
|
def put(indexable)
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
16
|
+
put_all([indexable])
|
17
|
+
end
|
18
|
+
|
19
|
+
# Sends the specified documents to the remote backend
|
20
|
+
# using bulk upload (if supported by the backend)
|
21
|
+
# @param [Indexable] an Indexable object
|
22
|
+
def put_all(indexables)
|
23
|
+
documents = indexables.map do |indexable|
|
24
|
+
transform(prepare(indexable.generate_document))
|
23
25
|
end
|
26
|
+
documents.reject!(&:empty?)
|
27
|
+
publish_all(documents) unless documents.empty?
|
24
28
|
end
|
25
29
|
|
26
|
-
# Deletes the specified document from the index
|
27
|
-
# method which concrete index classes must implement in order to provide
|
28
|
-
# its functionality.
|
30
|
+
# Deletes the specified document from the index
|
29
31
|
# @param [document_id] the document id of the indexed document
|
30
32
|
def delete(document_id)
|
33
|
+
delete_all([document_id])
|
34
|
+
end
|
35
|
+
|
36
|
+
# Deletes the specified documents from the index.
|
37
|
+
# This is an abstract method which concrete index classes
|
38
|
+
# must implement in order to provide its functionality.
|
39
|
+
# @param [document_ids] an array of document ids
|
40
|
+
def delete_all(document_ids)
|
31
41
|
raise NotImplementedError
|
32
42
|
end
|
33
43
|
|
34
44
|
private
|
35
45
|
|
36
46
|
def publish(document)
|
47
|
+
publish_all([document])
|
48
|
+
end
|
49
|
+
|
50
|
+
def publish_all(documents)
|
37
51
|
raise NotImplementedError
|
38
52
|
end
|
39
53
|
|
@@ -45,4 +59,4 @@ module AgnosticBackend
|
|
45
59
|
raise NotImplementedError
|
46
60
|
end
|
47
61
|
end
|
48
|
-
end
|
62
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module AgnosticBackend
|
2
|
+
module Queryable
|
3
|
+
module Elasticsearch
|
4
|
+
class Executor < AgnosticBackend::Queryable::Executor
|
5
|
+
include AgnosticBackend::Utilities
|
6
|
+
|
7
|
+
def execute
|
8
|
+
if scroll_cursor.present?
|
9
|
+
response = client.send_request(:post, path: "_search/scroll", body: params)
|
10
|
+
else
|
11
|
+
response = client.send_request(:post, path: "#{index.index_name}/#{index.type}/_search", body: params)
|
12
|
+
end
|
13
|
+
ResultSet.new(ActiveSupport::JSON.decode(response.body), query)
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_s
|
17
|
+
params
|
18
|
+
end
|
19
|
+
|
20
|
+
def params
|
21
|
+
scroll_cursor.present? ? scroll_cursor : query.accept(visitor)
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def client
|
27
|
+
index.client
|
28
|
+
end
|
29
|
+
|
30
|
+
def index
|
31
|
+
query.context.index
|
32
|
+
end
|
33
|
+
|
34
|
+
def scroll_cursor
|
35
|
+
scroll_cursor_expression.accept(visitor) if scroll_cursor_expression
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module AgnosticBackend
|
2
|
+
module Queryable
|
3
|
+
module Elasticsearch
|
4
|
+
class Query < AgnosticBackend::Queryable::Query
|
5
|
+
|
6
|
+
def initialize(base)
|
7
|
+
super
|
8
|
+
@executor = Executor.new(self, Visitor.new)
|
9
|
+
end
|
10
|
+
|
11
|
+
def execute
|
12
|
+
@executor.execute if valid?
|
13
|
+
end
|
14
|
+
|
15
|
+
def execute!
|
16
|
+
if valid?
|
17
|
+
@executor.execute
|
18
|
+
else
|
19
|
+
raise StandardError, errors
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_s
|
24
|
+
@executor.to_s
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module AgnosticBackend
|
2
|
+
module Queryable
|
3
|
+
module Elasticsearch
|
4
|
+
class QueryBuilder < AgnosticBackend::Queryable::QueryBuilder
|
5
|
+
private
|
6
|
+
|
7
|
+
def create_query(context)
|
8
|
+
AgnosticBackend::Queryable::Elasticsearch::Query.new(context)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module AgnosticBackend
|
2
|
+
module Queryable
|
3
|
+
module Elasticsearch
|
4
|
+
class ResultSet < AgnosticBackend::Queryable::ResultSet
|
5
|
+
include AgnosticBackend::Utilities
|
6
|
+
|
7
|
+
def total_count
|
8
|
+
raw_results["hits"]["total"]
|
9
|
+
end
|
10
|
+
|
11
|
+
def scroll_cursor
|
12
|
+
raw_results["_scroll_id"]
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def filtered_results
|
18
|
+
raw_results["hits"]["hits"].map{|h| h["fields"]}
|
19
|
+
end
|
20
|
+
|
21
|
+
def transform(result)
|
22
|
+
transform_nested_values(unflatten(result), Proc.new{|value| value.size > 1 ? value.split.join('|') : value.first})
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,195 @@
|
|
1
|
+
module AgnosticBackend
|
2
|
+
module Queryable
|
3
|
+
module Elasticsearch
|
4
|
+
class Visitor < AgnosticBackend::Queryable::Visitor
|
5
|
+
|
6
|
+
private
|
7
|
+
|
8
|
+
def visit_criteria_equal(subject)
|
9
|
+
{ "term" => { visit(subject.attribute) => visit(subject.value) } }
|
10
|
+
end
|
11
|
+
|
12
|
+
def visit_criteria_not_equal(subject)
|
13
|
+
{ "must_not" => visit_criteria_equal(subject) }
|
14
|
+
end
|
15
|
+
|
16
|
+
def visit_criteria_greater(subject)
|
17
|
+
{
|
18
|
+
"range" => {
|
19
|
+
visit(subject.attribute) => {
|
20
|
+
"gt" => visit(subject.value)
|
21
|
+
}
|
22
|
+
}
|
23
|
+
}
|
24
|
+
end
|
25
|
+
|
26
|
+
def visit_criteria_less(subject)
|
27
|
+
{
|
28
|
+
"range" => {
|
29
|
+
visit(subject.attribute) => {
|
30
|
+
"lt" => visit(subject.value)
|
31
|
+
}
|
32
|
+
}
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
def visit_criteria_greater_equal(subject)
|
37
|
+
{
|
38
|
+
"range" => {
|
39
|
+
visit(subject.attribute) => {
|
40
|
+
"gte" => visit(subject.value)
|
41
|
+
}
|
42
|
+
}
|
43
|
+
}
|
44
|
+
end
|
45
|
+
|
46
|
+
def visit_criteria_less_equal(subject)
|
47
|
+
{
|
48
|
+
"range" => {
|
49
|
+
visit(subject.attribute) => {
|
50
|
+
"lte" => visit(subject.value)
|
51
|
+
}
|
52
|
+
}
|
53
|
+
}
|
54
|
+
end
|
55
|
+
|
56
|
+
def visit_criteria_greater_and_less(subject)
|
57
|
+
{
|
58
|
+
"range" => {
|
59
|
+
visit(subject.attribute) => {
|
60
|
+
"gt" => visit(subject.left_value),
|
61
|
+
"lt" => visit(subject.right_value)
|
62
|
+
}
|
63
|
+
}
|
64
|
+
}
|
65
|
+
end
|
66
|
+
|
67
|
+
def visit_criteria_greater_equal_and_less(subject)
|
68
|
+
{
|
69
|
+
"range" => {
|
70
|
+
visit(subject.attribute) => {
|
71
|
+
"gte" => visit(subject.left_value),
|
72
|
+
"lt" => visit(subject.right_value)
|
73
|
+
}
|
74
|
+
}
|
75
|
+
}
|
76
|
+
end
|
77
|
+
|
78
|
+
def visit_criteria_greater_and_less_equal(subject)
|
79
|
+
{
|
80
|
+
"range" => {
|
81
|
+
visit(subject.attribute) => {
|
82
|
+
"gt" => visit(subject.left_value),
|
83
|
+
"lte" => visit(subject.right_value)
|
84
|
+
}
|
85
|
+
}
|
86
|
+
}
|
87
|
+
end
|
88
|
+
|
89
|
+
def visit_criteria_greater_equal_and_less_equal(subject)
|
90
|
+
{
|
91
|
+
"range" => {
|
92
|
+
visit(subject.attribute) => {
|
93
|
+
"gte" => visit(subject.left_value),
|
94
|
+
"lte" => visit(subject.right_value)
|
95
|
+
}
|
96
|
+
}
|
97
|
+
}
|
98
|
+
end
|
99
|
+
|
100
|
+
def visit_criteria_contains(subject)
|
101
|
+
{ "wildcard" => { visit(subject.attribute) => '*' + visit(subject.value) + '*'} }
|
102
|
+
end
|
103
|
+
|
104
|
+
def visit_criteria_starts(subject)
|
105
|
+
{ "wildcard" => { visit(subject.attribute) => '*' + visit(subject.value) } }
|
106
|
+
end
|
107
|
+
|
108
|
+
def visit_operations_not(subject)
|
109
|
+
{"bool" => { "must_not" => subject.operands.map{|o| visit(o)} } }
|
110
|
+
end
|
111
|
+
|
112
|
+
def visit_operations_and(subject)
|
113
|
+
{"bool" => { "must" => subject.operands.map{|o| visit(o)} } }
|
114
|
+
end
|
115
|
+
|
116
|
+
def visit_operations_or(subject)
|
117
|
+
{"bool" => { "should" => subject.operands.map{|o| visit(o)} } }
|
118
|
+
end
|
119
|
+
|
120
|
+
def visit_operations_ascending(subject)
|
121
|
+
{ visit(subject.attribute) => {"order" => "asc" } }
|
122
|
+
end
|
123
|
+
|
124
|
+
def visit_operations_descending(subject)
|
125
|
+
{ visit(subject.attribute) => {"order" => "desc" } }
|
126
|
+
end
|
127
|
+
|
128
|
+
def visit_query(subject)
|
129
|
+
result = {}
|
130
|
+
subject.children.each do |c|
|
131
|
+
result.merge!(visit(c)) do |key, v1, v2|
|
132
|
+
v1 + v2
|
133
|
+
end
|
134
|
+
end
|
135
|
+
result
|
136
|
+
end
|
137
|
+
|
138
|
+
def visit_expressions_where(subject)
|
139
|
+
{ "filter" => visit(subject.criterion) }
|
140
|
+
end
|
141
|
+
|
142
|
+
def visit_expressions_select(subject)
|
143
|
+
{ "fields" => subject.projections.map{|c| visit(c)} } #return=
|
144
|
+
end
|
145
|
+
|
146
|
+
def visit_expressions_order(subject)
|
147
|
+
{ "sort" => subject.qualifiers.map{|o| visit(o)} }
|
148
|
+
end
|
149
|
+
|
150
|
+
def visit_expressions_limit(subject)
|
151
|
+
{ "size" => visit(subject.limit) }
|
152
|
+
end
|
153
|
+
|
154
|
+
def visit_expressions_offset(subject)
|
155
|
+
{ "from" => visit(subject.offset) }
|
156
|
+
end
|
157
|
+
|
158
|
+
def visit_expressions_scroll_cursor(subject)
|
159
|
+
result = {
|
160
|
+
"scroll" => "1m"
|
161
|
+
}
|
162
|
+
|
163
|
+
result.merge!(
|
164
|
+
{
|
165
|
+
"scroll_id" => visit(subject.scroll_cursor)
|
166
|
+
}
|
167
|
+
) if visit(subject.scroll_cursor) != 'initial'
|
168
|
+
|
169
|
+
result
|
170
|
+
end
|
171
|
+
|
172
|
+
def visit_attribute(subject)
|
173
|
+
subject.name.split('.').join('__')
|
174
|
+
end
|
175
|
+
|
176
|
+
def visit_value(subject)
|
177
|
+
case subject.type
|
178
|
+
when :integer
|
179
|
+
subject.value
|
180
|
+
when :date
|
181
|
+
"#{subject.value.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}"
|
182
|
+
when :double
|
183
|
+
subject.value
|
184
|
+
when :boolean
|
185
|
+
subject.value
|
186
|
+
when :string,:string_array,:text,:text_array
|
187
|
+
subject.value
|
188
|
+
else
|
189
|
+
subject.value
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: agnostic_backend
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.9.
|
4
|
+
version: 0.9.8
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Iasonas Gavriilidis
|
@@ -10,22 +10,22 @@ authors:
|
|
10
10
|
autorequire:
|
11
11
|
bindir: exe
|
12
12
|
cert_chain: []
|
13
|
-
date: 2016-06-
|
13
|
+
date: 2016-06-22 00:00:00.000000000 Z
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
16
16
|
name: activesupport
|
17
17
|
requirement: !ruby/object:Gem::Requirement
|
18
18
|
requirements:
|
19
|
-
- - "
|
19
|
+
- - ">="
|
20
20
|
- !ruby/object:Gem::Version
|
21
|
-
version: '
|
21
|
+
version: '0'
|
22
22
|
type: :runtime
|
23
23
|
prerelease: false
|
24
24
|
version_requirements: !ruby/object:Gem::Requirement
|
25
25
|
requirements:
|
26
|
-
- - "
|
26
|
+
- - ">="
|
27
27
|
- !ruby/object:Gem::Version
|
28
|
-
version: '
|
28
|
+
version: '0'
|
29
29
|
- !ruby/object:Gem::Dependency
|
30
30
|
name: aws-sdk
|
31
31
|
requirement: !ruby/object:Gem::Requirement
|
@@ -40,6 +40,20 @@ dependencies:
|
|
40
40
|
- - "~>"
|
41
41
|
- !ruby/object:Gem::Version
|
42
42
|
version: '2'
|
43
|
+
- !ruby/object:Gem::Dependency
|
44
|
+
name: faraday
|
45
|
+
requirement: !ruby/object:Gem::Requirement
|
46
|
+
requirements:
|
47
|
+
- - ">="
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
version: '0'
|
50
|
+
type: :runtime
|
51
|
+
prerelease: false
|
52
|
+
version_requirements: !ruby/object:Gem::Requirement
|
53
|
+
requirements:
|
54
|
+
- - ">="
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: '0'
|
43
57
|
- !ruby/object:Gem::Dependency
|
44
58
|
name: bundler
|
45
59
|
requirement: !ruby/object:Gem::Requirement
|
@@ -139,6 +153,11 @@ files:
|
|
139
153
|
- lib/agnostic_backend/cloudsearch/index_field.rb
|
140
154
|
- lib/agnostic_backend/cloudsearch/indexer.rb
|
141
155
|
- lib/agnostic_backend/cloudsearch/remote_index_field.rb
|
156
|
+
- lib/agnostic_backend/elasticsearch/client.rb
|
157
|
+
- lib/agnostic_backend/elasticsearch/index.rb
|
158
|
+
- lib/agnostic_backend/elasticsearch/index_field.rb
|
159
|
+
- lib/agnostic_backend/elasticsearch/indexer.rb
|
160
|
+
- lib/agnostic_backend/elasticsearch/remote_index_field.rb
|
142
161
|
- lib/agnostic_backend/index.rb
|
143
162
|
- lib/agnostic_backend/indexable/config.rb
|
144
163
|
- lib/agnostic_backend/indexable/content_manager.rb
|
@@ -157,6 +176,11 @@ files:
|
|
157
176
|
- lib/agnostic_backend/queryable/criteria/criterion.rb
|
158
177
|
- lib/agnostic_backend/queryable/criteria/ternary.rb
|
159
178
|
- lib/agnostic_backend/queryable/criteria_builder.rb
|
179
|
+
- lib/agnostic_backend/queryable/elasticsearch/executor.rb
|
180
|
+
- lib/agnostic_backend/queryable/elasticsearch/query.rb
|
181
|
+
- lib/agnostic_backend/queryable/elasticsearch/query_builder.rb
|
182
|
+
- lib/agnostic_backend/queryable/elasticsearch/result_set.rb
|
183
|
+
- lib/agnostic_backend/queryable/elasticsearch/visitor.rb
|
160
184
|
- lib/agnostic_backend/queryable/executor.rb
|
161
185
|
- lib/agnostic_backend/queryable/expressions/expression.rb
|
162
186
|
- lib/agnostic_backend/queryable/operations/n_ary.rb
|