agnostic_backend 0.9.4 → 0.9.8
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.
- 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
|