agnostic_backend 0.9.0

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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +1 -0
  3. data/.rspec +1 -0
  4. data/.ruby-gemset +1 -0
  5. data/.ruby-version +1 -0
  6. data/.travis.yml +7 -0
  7. data/CODE_OF_CONDUCT.md +13 -0
  8. data/Gemfile +4 -0
  9. data/Gemfile.lock +52 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +345 -0
  12. data/Rakefile +6 -0
  13. data/agnostic_backend.gemspec +34 -0
  14. data/bin/console +14 -0
  15. data/bin/setup +7 -0
  16. data/doc/indexable.md +292 -0
  17. data/doc/queryable.md +0 -0
  18. data/lib/agnostic_backend/cloudsearch/index.rb +99 -0
  19. data/lib/agnostic_backend/cloudsearch/index_field.rb +103 -0
  20. data/lib/agnostic_backend/cloudsearch/indexer.rb +84 -0
  21. data/lib/agnostic_backend/cloudsearch/remote_index_field.rb +32 -0
  22. data/lib/agnostic_backend/index.rb +24 -0
  23. data/lib/agnostic_backend/indexable/config.rb +24 -0
  24. data/lib/agnostic_backend/indexable/content_manager.rb +51 -0
  25. data/lib/agnostic_backend/indexable/field.rb +26 -0
  26. data/lib/agnostic_backend/indexable/field_type.rb +48 -0
  27. data/lib/agnostic_backend/indexable/indexable.rb +125 -0
  28. data/lib/agnostic_backend/indexer.rb +48 -0
  29. data/lib/agnostic_backend/queryable/attribute.rb +22 -0
  30. data/lib/agnostic_backend/queryable/cloudsearch/executor.rb +100 -0
  31. data/lib/agnostic_backend/queryable/cloudsearch/query.rb +29 -0
  32. data/lib/agnostic_backend/queryable/cloudsearch/query_builder.rb +13 -0
  33. data/lib/agnostic_backend/queryable/cloudsearch/result_set.rb +27 -0
  34. data/lib/agnostic_backend/queryable/cloudsearch/visitor.rb +127 -0
  35. data/lib/agnostic_backend/queryable/criteria/binary.rb +47 -0
  36. data/lib/agnostic_backend/queryable/criteria/criterion.rb +13 -0
  37. data/lib/agnostic_backend/queryable/criteria/ternary.rb +36 -0
  38. data/lib/agnostic_backend/queryable/criteria_builder.rb +83 -0
  39. data/lib/agnostic_backend/queryable/executor.rb +43 -0
  40. data/lib/agnostic_backend/queryable/expressions/expression.rb +65 -0
  41. data/lib/agnostic_backend/queryable/operations/n_ary.rb +18 -0
  42. data/lib/agnostic_backend/queryable/operations/operation.rb +13 -0
  43. data/lib/agnostic_backend/queryable/operations/unary.rb +35 -0
  44. data/lib/agnostic_backend/queryable/query.rb +27 -0
  45. data/lib/agnostic_backend/queryable/query_builder.rb +98 -0
  46. data/lib/agnostic_backend/queryable/result_set.rb +42 -0
  47. data/lib/agnostic_backend/queryable/tree_node.rb +48 -0
  48. data/lib/agnostic_backend/queryable/validator.rb +174 -0
  49. data/lib/agnostic_backend/queryable/value.rb +26 -0
  50. data/lib/agnostic_backend/queryable/visitor.rb +124 -0
  51. data/lib/agnostic_backend/rspec/matchers.rb +69 -0
  52. data/lib/agnostic_backend/utilities.rb +207 -0
  53. data/lib/agnostic_backend/version.rb +3 -0
  54. data/lib/agnostic_backend.rb +49 -0
  55. metadata +199 -0
@@ -0,0 +1,51 @@
1
+ module AgnosticBackend
2
+ module Indexable
3
+
4
+ class ContentManager
5
+
6
+ def add_definitions &block
7
+ return unless block_given?
8
+ instance_eval &block
9
+ end
10
+
11
+ def contents
12
+ @contents ||= {}
13
+ end
14
+
15
+ def method_missing(sym, *args, **kwargs)
16
+ if FieldType.exists? sym
17
+ kwargs[:type] = sym
18
+ field(*args, **kwargs)
19
+ else
20
+ super
21
+ end
22
+ end
23
+
24
+ def respond_to?(sym, include_private=false)
25
+ FieldType.exists?(sym) || super
26
+ end
27
+
28
+ def field(field_name, value: nil, type:, from: nil, **options)
29
+ contents[field_name.to_s] = Field.new(value.present? ? value : field_name, type,
30
+ from: from, **options)
31
+ end
32
+
33
+ def extract_contents_from(object, index_name)
34
+ kv_pairs = contents.map do |field_name, field|
35
+ field_value = field.evaluate(context: object)
36
+ if field.type.nested?
37
+ if field_value.respond_to? :generate_document
38
+ field_value = field_value.generate_document(for_index: index_name)
39
+ elsif field_value.present?
40
+ field_name = nil
41
+ end
42
+ end
43
+ [field_name, field_value]
44
+ end
45
+ kv_pairs.reject! { |attr_name, _| attr_name.nil? }
46
+ Hash[kv_pairs]
47
+ end
48
+
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,26 @@
1
+ module AgnosticBackend
2
+ module Indexable
3
+
4
+ class Field
5
+
6
+ attr_accessor :value, :type, :from
7
+
8
+ def initialize(value, type, from: nil, **options)
9
+ if type == FieldType::STRUCT && from.nil?
10
+ raise "A nested type requires the specification of a target class using the `from` argument"
11
+ end
12
+ @value = value.respond_to?(:call) ? value : value.to_sym
13
+ @from = (from.is_a?(Enumerable) ? from : [from]) unless from.nil?
14
+ @type = FieldType.new(type, **options)
15
+ end
16
+
17
+ def evaluate(context:)
18
+ value.respond_to?(:call) ?
19
+ context.instance_eval(&value) :
20
+ context.send(value)
21
+ end
22
+
23
+ end
24
+
25
+ end
26
+ end
@@ -0,0 +1,48 @@
1
+ module AgnosticBackend
2
+ module Indexable
3
+ class FieldType
4
+ INTEGER = :integer
5
+ DOUBLE = :double
6
+ STRING = :string # literal string (i.e. should be matched exactly)
7
+ STRING_ARRAY = :string_array
8
+ TEXT = :text
9
+ TEXT_ARRAY = :text_array
10
+ DATE = :date # datetime
11
+ BOOLEAN = :boolean
12
+ STRUCT = :struct # a nested structure containing other values
13
+
14
+ def self.all
15
+ constants.map { |constant| const_get(constant) }
16
+ end
17
+
18
+ def self.exists?(type)
19
+ all.include? type
20
+ end
21
+
22
+ attr_reader :type, :options
23
+
24
+ def initialize(type, **options)
25
+ raise "Type #{type} not supported" unless FieldType.exists? type
26
+ @type = type
27
+ @options = options
28
+ end
29
+
30
+ def nested?
31
+ type == STRUCT
32
+ end
33
+
34
+ def matches?(type)
35
+ self.type == type
36
+ end
37
+
38
+ def get_option(option_name)
39
+ @options[option_name.to_sym]
40
+ end
41
+
42
+ def has_option(option_name)
43
+ @options.has_key? option_name.to_sym
44
+ end
45
+
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,125 @@
1
+ module AgnosticBackend
2
+ module Indexable
3
+
4
+ class << self
5
+ attr_reader :includers
6
+
7
+ def indexable_class(index_name)
8
+ includers.find { |klass| klass.index_name == index_name }
9
+ end
10
+ end
11
+
12
+ def self.included(base)
13
+ @includers ||= []
14
+ @includers << base unless @includers.include? base
15
+ base.send :include, InstanceMethods
16
+ base.send :extend, ClassMethods
17
+ end
18
+
19
+ module ClassMethods
20
+
21
+ def create_index
22
+ AgnosticBackend::Indexable::Config.create_index_for(self)
23
+ end
24
+
25
+ # establishes the convention for determining the index name from the class name
26
+ def index_name(source=nil)
27
+ (source.nil? ? name : source.to_s).split('::').last.underscore.pluralize
28
+ end
29
+
30
+ def _index_content_managers
31
+ @__index_content_managers ||= {}
32
+ end
33
+
34
+ def index_content_manager(index_name)
35
+ _index_content_managers[index_name.to_s]
36
+ end
37
+
38
+ def _index_root_notifiers
39
+ @__index_root_notifiers ||= {}
40
+ end
41
+
42
+ def index_root_notifier(index_name)
43
+ _index_root_notifiers[index_name.to_s]
44
+ end
45
+
46
+ def schema(for_index: nil, &block)
47
+ index_name = for_index.nil? ? self.index_name : for_index
48
+ manager = index_content_manager(index_name)
49
+ raise "Index #{index_name} has not been defined for #{name}" if manager.nil?
50
+ kv_pairs = manager.contents.map do |field_name, field|
51
+ schema =
52
+ if field.type.nested?
53
+ field.from.map { |klass| klass.schema(for_index: index_name, &block) }.reduce(&:merge)
54
+ elsif block_given?
55
+ yield field.type
56
+ else
57
+ field.type.type
58
+ end
59
+ [field_name, schema]
60
+ end
61
+ Hash[kv_pairs]
62
+ end
63
+
64
+ # specifies which fields should be indexed for a given index_name
65
+ # also sets up the manager for the specified index_name
66
+ def define_index_fields(owner: nil, &block)
67
+ return unless block_given?
68
+ _index_content_managers[index_name(owner)] ||= ContentManager.new
69
+ _index_content_managers[index_name(owner)].add_definitions &block
70
+ unless instance_methods(false).include? :_index_content_managers
71
+ define_method(:_index_content_managers) { self.class._index_content_managers }
72
+ end
73
+ end
74
+
75
+ # specifies who should be notified when this object is saved
76
+ def define_index_notifier(target: nil, &block)
77
+ return unless block_given?
78
+ _index_root_notifiers[index_name(target)] = block
79
+ unless instance_methods(false).include? :_index_root_notifiers
80
+ define_method(:_index_root_notifiers) { self.class._index_root_notifiers }
81
+ end
82
+ end
83
+ end
84
+
85
+ module InstanceMethods
86
+
87
+ def index_name(source=nil)
88
+ self.class.index_name(source)
89
+ end
90
+
91
+ def generate_document(for_index: nil)
92
+ index_name = for_index.nil? ? self.index_name : for_index.to_s
93
+ return unless respond_to? :_index_content_managers
94
+ manager = _index_content_managers[index_name.to_s]
95
+ raise "Index #{index_name} does not exist" if manager.nil?
96
+ manager.extract_contents_from self, index_name
97
+ end
98
+
99
+ def put_to_index(index_name=nil)
100
+ indexable_class = index_name.nil? ?
101
+ self.class :
102
+ AgnosticBackend::Indexable.indexable_class(index_name)
103
+
104
+ index = indexable_class.create_index
105
+ indexer = index.indexer
106
+ indexer.put(self)
107
+ end
108
+
109
+ def index_object(index_name)
110
+ put_to_index(index_name)
111
+ end
112
+
113
+ private
114
+
115
+ def trigger_index_notification
116
+ return unless respond_to? :_index_root_notifiers
117
+ _index_root_notifiers.each do |index_name, block|
118
+ obj = instance_eval &block
119
+ obj = [obj] unless obj.is_a? Enumerable
120
+ obj.each { |o| o.index_object(index_name) if o.present? }
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,48 @@
1
+ module AgnosticBackend
2
+ class Indexer
3
+
4
+ attr_reader :index
5
+
6
+ def initialize(index)
7
+ @index = index
8
+ end
9
+
10
+ # Sends the specified document to the remote backend.
11
+ # This is a template method.
12
+ # @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
+ 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
23
+ end
24
+ end
25
+
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.
29
+ # @param [document_id] the document id of the indexed document
30
+ def delete(document_id)
31
+ raise NotImplementedError
32
+ end
33
+
34
+ private
35
+
36
+ def publish(document)
37
+ raise NotImplementedError
38
+ end
39
+
40
+ def transform(document)
41
+ raise NotImplementedError
42
+ end
43
+
44
+ def prepare(document)
45
+ raise NotImplementedError
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,22 @@
1
+ module AgnosticBackend
2
+ module Queryable
3
+ class Attribute < TreeNode
4
+ include AgnosticBackend::Utilities
5
+
6
+ attr_reader :name, :parent
7
+
8
+ def initialize(name, parent:, context:)
9
+ super([], context)
10
+ @name, @parent = name, parent
11
+ end
12
+
13
+ def ==(o)
14
+ super && o.name == name
15
+ end
16
+
17
+ def type
18
+ value_for_key(context.index.schema, name).try(:type)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,100 @@
1
+ require 'aws-sdk'
2
+
3
+ module AgnosticBackend
4
+ module Queryable
5
+ module Cloudsearch
6
+ class Executor < AgnosticBackend::Queryable::Executor
7
+ include AgnosticBackend::Utilities
8
+
9
+ def execute
10
+ with_exponential_backoff Aws::CloudSearch::Errors::Throttling do
11
+ response = client.search(params)
12
+ ResultSet.new(response, query)
13
+ end
14
+ end
15
+
16
+ def to_s
17
+ result = ''
18
+ result += "search?q=#{query_expression}" if query_expression
19
+ result += " return=#{return_expression}" if return_expression
20
+ result += " sort=#{sort}" if sort
21
+ result += " size=#{size}" if size
22
+ result += " offset=#{start}" if start
23
+ result += " cursor=#{scroll_cursor}" if scroll_cursor
24
+ result
25
+ end
26
+
27
+ def params
28
+ {
29
+ cursor: scroll_cursor,
30
+ expr: expr,
31
+ facet: facet,
32
+ filter_query: filter_query,
33
+ highlight: highlight,
34
+ partial: partial,
35
+ query: query_expression,
36
+ query_options: query_options,
37
+ query_parser: query_parser,
38
+ return: return_expression,
39
+ size: size,
40
+ sort: sort,
41
+ start: start
42
+ }
43
+ end
44
+
45
+ private
46
+
47
+ def client
48
+ query.context.index.cloudsearch_domain_client
49
+ end
50
+
51
+ def filter_query
52
+ end
53
+
54
+ def query_expression
55
+ where_expression ? where_expression.accept(visitor) : 'matchall'
56
+ end
57
+
58
+ def scroll_cursor
59
+ scroll_cursor_expression.accept(visitor) if scroll_cursor_expression
60
+ end
61
+
62
+ def start
63
+ offset_expression.accept(visitor) if offset_expression
64
+ end
65
+
66
+ def expr
67
+ end
68
+
69
+ def facet
70
+ end
71
+
72
+ def highlight
73
+ end
74
+
75
+ def partial
76
+ false
77
+ end
78
+
79
+ def query_options
80
+ end
81
+
82
+ def query_parser
83
+ 'structured'
84
+ end
85
+
86
+ def return_expression
87
+ select_expression.accept(visitor) if select_expression
88
+ end
89
+
90
+ def size
91
+ limit_expression.accept(visitor) if limit_expression
92
+ end
93
+
94
+ def sort
95
+ order_expression.accept(visitor) if order_expression
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,29 @@
1
+ module AgnosticBackend
2
+ module Queryable
3
+ module Cloudsearch
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 Cloudsearch
4
+ class QueryBuilder < AgnosticBackend::Queryable::QueryBuilder
5
+ private
6
+
7
+ def create_query(context)
8
+ AgnosticBackend::Queryable::Cloudsearch::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 Cloudsearch
4
+ class ResultSet < AgnosticBackend::Queryable::ResultSet
5
+ include AgnosticBackend::Utilities
6
+
7
+ def total_count
8
+ raw_results.hits.found
9
+ end
10
+
11
+ def cursor
12
+ raw_results.hits.cursor
13
+ end
14
+
15
+ private
16
+
17
+ def filtered_results
18
+ raw_results.hits.hit.map(&: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,127 @@
1
+ module AgnosticBackend
2
+ module Queryable
3
+ module Cloudsearch
4
+ class Visitor < AgnosticBackend::Queryable::Visitor
5
+
6
+ private
7
+
8
+ def visit_criteria_equal(subject)
9
+ "(term field=#{visit(subject.attribute)} #{visit(subject.value)})"
10
+ end
11
+
12
+ def visit_criteria_not_equal(subject)
13
+ "(not term field=#{visit(subject.attribute)} #{visit(subject.value)})"
14
+ end
15
+
16
+ def visit_criteria_greater(subject)
17
+ "(range field=#{visit(subject.attribute)} {#{visit(subject.value)},})"
18
+ end
19
+
20
+ def visit_criteria_less(subject)
21
+ "(range field=#{visit(subject.attribute)} {,#{visit(subject.value)}})"
22
+ end
23
+
24
+ def visit_criteria_greater_equal(subject)
25
+ "(range field=#{visit(subject.attribute)} [#{visit(subject.value)},})"
26
+ end
27
+
28
+ def visit_criteria_less_equal(subject)
29
+ "(range field=#{visit(subject.attribute)} {,#{visit(subject.value)}])"
30
+ end
31
+
32
+ def visit_criteria_greater_and_less(subject)
33
+ "(range field=#{visit(subject.attribute)} {#{visit(subject.left_value)},#{visit(subject.right_value)}})"
34
+ end
35
+
36
+ def visit_criteria_greater_equal_and_less(subject)
37
+ "(range field=#{visit(subject.attribute)} [#{visit(subject.left_value)},#{visit(subject.right_value)}})"
38
+ end
39
+
40
+ def visit_criteria_greater_and_less_equal(subject)
41
+ "(range field=#{visit(subject.attribute)} {#{visit(subject.left_value)},#{visit(subject.right_value)}])"
42
+ end
43
+
44
+ def visit_criteria_greater_equal_and_less_equal(subject)
45
+ "(range field=#{visit(subject.attribute)} [#{visit(subject.left_value)},#{visit(subject.right_value)}])"
46
+ end
47
+
48
+ def visit_criteria_contains(subject)
49
+ "(phrase field=#{visit(subject.attribute)} #{visit(subject.value)})"
50
+ end
51
+
52
+ def visit_criteria_starts(subject)
53
+ "(prefix field=#{visit(subject.attribute)} #{visit(subject.value)})"
54
+ end
55
+
56
+ def visit_operations_not(subject)
57
+ "(not #{visit(subject.operand)})"
58
+ end
59
+
60
+ def visit_operations_and(subject)
61
+ "(and #{subject.operands.map{|o| visit(o)}.join(' ')})"
62
+ end
63
+
64
+ def visit_operations_or(subject)
65
+ "(or #{subject.operands.map{|o| visit(o)}.join(' ')})"
66
+ end
67
+
68
+ def visit_operations_ascending(subject)
69
+ "#{visit(subject.attribute)} asc"
70
+ end
71
+
72
+ def visit_operations_descending(subject)
73
+ "#{visit(subject.attribute)} desc"
74
+ end
75
+
76
+ def visit_query(subject)
77
+ "#{subject.children.map{|c| visit(c)}.join(' ')}"
78
+ end
79
+
80
+ def visit_expressions_where(subject)
81
+ visit(subject.criterion) #search?q=
82
+ end
83
+
84
+ def visit_expressions_select(subject)
85
+ "#{subject.projections.map{|c| visit(c)}.join(',')}" #return=
86
+ end
87
+
88
+ def visit_expressions_order(subject)
89
+ "#{subject.qualifiers.map{|c| visit(c)}.join(',')}" #sort=
90
+ end
91
+
92
+ def visit_expressions_limit(subject)
93
+ visit(subject.limit) #size=
94
+ end
95
+
96
+ def visit_expressions_offset(subject)
97
+ visit(subject.offset) #offset=
98
+ end
99
+
100
+ def visit_expressions_scroll_cursor(subject)
101
+ visit(subject.scroll_cursor) #cursor=
102
+ end
103
+
104
+ def visit_attribute(subject)
105
+ subject.name.split('.').join('__')
106
+ end
107
+
108
+ def visit_value(subject)
109
+ case subject.type
110
+ when :integer
111
+ subject.value
112
+ when :date
113
+ "'#{subject.value.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}'"
114
+ when :double
115
+ subject.value
116
+ when :boolean
117
+ "'#{subject.value}'"
118
+ when :string,:string_array,:text,:text_array
119
+ "'#{subject.value}'"
120
+ else
121
+ subject.value
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,47 @@
1
+ module AgnosticBackend
2
+ module Queryable
3
+ module Criteria
4
+ class Binary < Criterion
5
+ attr_reader :attribute, :value
6
+
7
+ def initialize(attribute:, value:, context: nil)
8
+ @attribute, @value = attribute, value
9
+ super([attribute, value], context)
10
+ end
11
+ end
12
+
13
+ class Relational < Binary
14
+
15
+ def initialize(attribute:, value:, context: nil)
16
+ attribute = attribute_component(attribute: attribute, context: context)
17
+ value = value_component(value: value, context: context, type: attribute.type)
18
+ super(attribute: attribute, value: value, context: context)
19
+ end
20
+ end
21
+
22
+ class Equal < Relational;
23
+ end
24
+
25
+ class NotEqual < Relational;
26
+ end
27
+
28
+ class Greater < Relational;
29
+ end
30
+
31
+ class Less < Relational;
32
+ end
33
+
34
+ class GreaterEqual < Relational;
35
+ end
36
+
37
+ class LessEqual < Relational;
38
+ end
39
+
40
+ class Contains < Relational;
41
+ end
42
+
43
+ class Starts < Relational;
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,13 @@
1
+ module AgnosticBackend
2
+ module Queryable
3
+ module Criteria
4
+ class Criterion < TreeNode
5
+ def initialize(components = [], context = nil)
6
+ super
7
+ end
8
+
9
+ alias_method :components, :children
10
+ end
11
+ end
12
+ end
13
+ end