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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: f379b563c7fa6fc0d4fbbef69222931eaaa0efc0
4
- data.tar.gz: cedf4be31fc43e9576fdcb49586427fcffd67dc2
3
+ metadata.gz: a58464b8656e2798472a29b29e094aad2a3ad371
4
+ data.tar.gz: b719bf7b58574861ea9237a32db5b788e1813a1c
5
5
  SHA512:
6
- metadata.gz: 2dc5e38d7662a707d082e275a89450157dad30aa518f9c0bc19bf6dc097d2b44b93baf397d5598e12f94ccc7d6fd4b378404adc3d85ea814046fa99fbbe23b31
7
- data.tar.gz: d2ad6dbdae2102814a100a4588365d67ad0b8c04806c21145db590370b96b1cdc6a366d10ddc4ba0045961f786d71d954878bab3a7d4d27895f7d9510b80f4b5
6
+ metadata.gz: b4f4431dd6d69edd313476822b7a76cbf1f271969bb7c4a72ecc78f93143f7558c78243919598a9fbd821b1b608fb8334f5d1c2c472846bc32e5740d407c69a2
7
+ data.tar.gz: 19ef97db6ecb648f1b4ca63d4902bd5c51f1e084601e7f7c9560385656a1e454fa803c7e16e88d791ed44ab2d4d44e56e2c3098f6ee5b17c0a29e27ff6e545a3
data/.travis.yml CHANGED
@@ -8,3 +8,4 @@ script: bundle exec rspec spec
8
8
  branches:
9
9
  only:
10
10
  - master
11
+ - lp/elasticsearch
data/Gemfile CHANGED
@@ -2,3 +2,8 @@ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in agnostic_backend.gemspec
4
4
  gemspec
5
+
6
+
7
+ group :test do
8
+ gem 'webmock', '~> 1.20.4'
9
+ end
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
- `Indexable` provides indexing functionality by
14
- specifying a way to define which object attributes should be
15
- transformed in order to be eventually indexed to a remote backend
16
- store. `Queryable` provides search and retrieval functionality by
17
- specifying a generic query language that seamlessly maps to specific
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
- configuration and transformation functionality for remote backends (such as
23
- elasticsearch, AWS Cloudsearch etc).
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 backends
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 one concrete backend implementation that
265
- talks to [AWS Cloudsearch](https://aws.amazon.com/cloudsearch/). New
266
- backends can be implemented by subclassing `AgnosticBackend::Index`
267
- and `AgnosticBackend::Indexer` (more on that later).
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
 
@@ -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", "~> 3"
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"
@@ -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 parse_option(options, option_name)
91
- if options.has_key?(option_name)
92
- options[option_name]
93
- else
94
- raise "#{option_name} must be specified"
95
- end
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
- def initialize(index)
9
- @index = index
10
- end
11
+ MAX_PAYLOAD_SIZE_IN_BYTES = 4_500_000
11
12
 
12
- def publish(document)
13
- with_exponential_backoff Aws::CloudSearch::Errors::Throttling do
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 delete(*document_ids)
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: convert_to_json(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 = convert_document_into_array(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 convert_to_json(transformed_document)
76
- ActiveSupport::JSON.encode(transformed_document)
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
- def initialize(indexable_klass)
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 ConfigEntry < Struct.new(:index_class, :options);
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] = ConfigEntry.new index_class, options
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.index_class.try(:new, indexable_class, entry.options)
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
- index = indexable_class.create_index
106
- indexer = index.indexer
107
- indexer.put(self)
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
- document = indexable.generate_document
17
- return if document.blank?
18
- begin
19
- publish(transform(prepare(document)))
20
- true
21
- rescue => e
22
- false
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, This is an abstract
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
@@ -63,7 +63,6 @@ module AgnosticBackend
63
63
  def define_index_field(*args)
64
64
  DefineIndexField.new(*args)
65
65
  end
66
-
67
66
  end
68
67
  end
69
68
  end
@@ -1,3 +1,3 @@
1
1
  module AgnosticBackend
2
- VERSION = "0.9.4"
2
+ VERSION = "0.9.8"
3
3
  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
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-14 00:00:00.000000000 Z
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: '3'
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: '3'
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