agnostic_backend 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
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