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 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