stretchy-model 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/.yardopts +1 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +84 -0
  6. data/Gemfile +10 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +146 -0
  9. data/Rakefile +4 -0
  10. data/containers/Dockerfile.elasticsearch +7 -0
  11. data/containers/Dockerfile.opensearch +19 -0
  12. data/docker-compose.yml +52 -0
  13. data/lib/active_model/type/array.rb +13 -0
  14. data/lib/active_model/type/hash.rb +15 -0
  15. data/lib/rails/instrumentation/publishers.rb +29 -0
  16. data/lib/rails/instrumentation/railtie.rb +29 -0
  17. data/lib/stretchy/associations/associated_validator.rb +17 -0
  18. data/lib/stretchy/associations/elastic_relation.rb +38 -0
  19. data/lib/stretchy/associations.rb +161 -0
  20. data/lib/stretchy/common.rb +33 -0
  21. data/lib/stretchy/delegation/delegate_cache.rb +131 -0
  22. data/lib/stretchy/delegation/gateway_delegation.rb +43 -0
  23. data/lib/stretchy/indexing/bulk.rb +48 -0
  24. data/lib/stretchy/model/callbacks.rb +31 -0
  25. data/lib/stretchy/model/serialization.rb +20 -0
  26. data/lib/stretchy/null_relation.rb +53 -0
  27. data/lib/stretchy/persistence.rb +43 -0
  28. data/lib/stretchy/querying.rb +20 -0
  29. data/lib/stretchy/record.rb +57 -0
  30. data/lib/stretchy/refreshable.rb +15 -0
  31. data/lib/stretchy/relation.rb +169 -0
  32. data/lib/stretchy/relations/finder_methods.rb +39 -0
  33. data/lib/stretchy/relations/merger.rb +179 -0
  34. data/lib/stretchy/relations/query_builder.rb +265 -0
  35. data/lib/stretchy/relations/query_methods.rb +578 -0
  36. data/lib/stretchy/relations/search_option_methods.rb +34 -0
  37. data/lib/stretchy/relations/spawn_methods.rb +60 -0
  38. data/lib/stretchy/repository.rb +10 -0
  39. data/lib/stretchy/scoping/default.rb +134 -0
  40. data/lib/stretchy/scoping/named.rb +68 -0
  41. data/lib/stretchy/scoping/scope_registry.rb +34 -0
  42. data/lib/stretchy/scoping.rb +28 -0
  43. data/lib/stretchy/shared_scopes.rb +34 -0
  44. data/lib/stretchy/utils.rb +69 -0
  45. data/lib/stretchy/version.rb +5 -0
  46. data/lib/stretchy.rb +38 -0
  47. data/sig/stretchy.rbs +4 -0
  48. data/stretchy.logo.png +0 -0
  49. metadata +247 -0
@@ -0,0 +1,33 @@
1
+ module Stretchy
2
+ module Common
3
+ extend ActiveSupport::Concern
4
+
5
+ def inspect
6
+ "#<#{self.class.name} #{attributes.map { |k,v| "#{k}: #{v.blank? ? 'nil' : v}" }.join(', ')}>"
7
+ end
8
+
9
+
10
+ class_methods do
11
+
12
+ # Set the default sort key to be used in sort operations
13
+ #
14
+ def default_sort_key(field = nil)
15
+ @default_sort_key = field unless field.nil?
16
+ @default_sort_key
17
+ end
18
+
19
+ def default_size(size = 10000)
20
+ @default_size = size
21
+ end
22
+
23
+ private
24
+
25
+ # Return a Relation instance to chain queries
26
+ #
27
+ def relation
28
+ Relation.create(self, {})
29
+ end
30
+
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,131 @@
1
+ # TODO: Break out modules into separate files
2
+ module Stretchy
3
+ module Delegation # :nodoc:
4
+ module DelegateCache
5
+ def relation_delegate_class(klass) # :nodoc:
6
+ @relation_delegate_cache[klass]
7
+ end
8
+
9
+ def initialize_relation_delegate_cache # :nodoc:
10
+ @relation_delegate_cache = cache = {}
11
+ [
12
+ Stretchy::Relation,
13
+ ].each do |klass|
14
+ delegate = Class.new(klass) {
15
+ include ClassSpecificRelation
16
+ }
17
+ const_set klass.name.gsub("::", "_"), delegate
18
+ cache[klass] = delegate
19
+ end
20
+ end
21
+
22
+ def self.extended(child_class)
23
+ child_class.initialize_relation_delegate_cache
24
+ super
25
+ end
26
+ end
27
+
28
+ extend ActiveSupport::Concern
29
+
30
+ # This module creates compiled delegation methods dynamically at runtime, which makes
31
+ # subsequent calls to that method faster by avoiding method_missing. The delegations
32
+ # may vary depending on the klass of a relation, so we create a subclass of Relation
33
+ # for each different klass, and the delegations are compiled into that subclass only.
34
+
35
+ BLACKLISTED_ARRAY_METHODS = [
36
+ :compact!, :flatten!, :reject!, :reverse!, :rotate!, :map!,
37
+ :shuffle!, :slice!, :sort!, :sort_by!, :delete_if,
38
+ :keep_if, :pop, :shift, :delete_at, :compact, :select!,
39
+ ].to_set # :nodoc:
40
+
41
+ delegate :to_xml, :to_yaml, :length, :collect, :map, :each, :all?, :include?, :to_ary, :join, to: :to_a
42
+ delegate :inner_hits, :highlights, :total, to: :to_a
43
+ delegate :aggregations, to: :response
44
+
45
+
46
+ delegate :mapping, :index_name, :document_type, :to => :klass
47
+
48
+ module ClassSpecificRelation # :nodoc:
49
+ extend ActiveSupport::Concern
50
+
51
+ included do
52
+ @delegation_mutex = Mutex.new
53
+ end
54
+
55
+ module ClassMethods # :nodoc:
56
+ def name
57
+ superclass.name
58
+ end
59
+
60
+ def delegate_to_scoped_klass(method)
61
+ @delegation_mutex.synchronize do
62
+ return if method_defined?(method)
63
+
64
+ if method.to_s =~ /\A[a-zA-Z_]\w*[!?]?\z/
65
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
66
+ def #{method}(*args, &block)
67
+ scoping { @klass.#{method}(*args, &block) }
68
+ end
69
+ RUBY
70
+ else
71
+ define_method method do |*args, &block|
72
+ scoping { @klass.public_send(method, *args, &block) }
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ def delegate(method, opts = {})
79
+ @delegation_mutex.synchronize do
80
+ return if method_defined?(method)
81
+ super
82
+ end
83
+ end
84
+ end
85
+
86
+ protected
87
+
88
+ def method_missing(method, *args, &block)
89
+ if @klass.respond_to?(method)
90
+ self.class.delegate_to_scoped_klass(method)
91
+ scoping { @klass.public_send(method, *args, &block) }
92
+ else
93
+ super
94
+ end
95
+ end
96
+ end
97
+
98
+ module ClassMethods # :nodoc:
99
+ def create(klass, *args)
100
+ relation_class_for(klass).new(klass, *args)
101
+ end
102
+
103
+ private
104
+
105
+ def relation_class_for(klass)
106
+ klass.relation_delegate_class(self)
107
+ end
108
+ end
109
+
110
+ def respond_to?(method, include_private = false)
111
+ super || @klass.respond_to?(method, include_private) ||
112
+ array_delegable?(method)
113
+ end
114
+
115
+ protected
116
+
117
+ def array_delegable?(method)
118
+ Array.method_defined?(method) && BLACKLISTED_ARRAY_METHODS.exclude?(method)
119
+ end
120
+
121
+ def method_missing(method, *args, &block)
122
+ if @klass.respond_to?(method)
123
+ scoping { @klass.public_send(method, *args, &block) }
124
+ elsif array_delegable?(method)
125
+ to_a.public_send(method, *args, &block)
126
+ else
127
+ super
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,43 @@
1
+ module Stretchy
2
+ module Delegation
3
+ module GatewayDelegation
4
+ delegate :settings,
5
+ :mappings,
6
+ :mapping,
7
+ :document_type,
8
+ :document_type=,
9
+ :index_name,
10
+ :index_name=,
11
+ :search,
12
+ :find,
13
+ :exists?,
14
+ :create_index!,
15
+ :delete_index!,
16
+ :index_exists?,
17
+ :refresh_index!,
18
+ :count,
19
+ to: :gateway
20
+
21
+ include Rails::Instrumentation::Publishers::Record
22
+
23
+ def index_name(name=nil, &block)
24
+ if name || block_given?
25
+ return (@index_name = name || block)
26
+ end
27
+
28
+ if @index_name.respond_to?(:call)
29
+ @index_name.call
30
+ else
31
+ @index_name || base_class.model_name.collection
32
+ end
33
+ end
34
+
35
+ def gateway(&block)
36
+ @gateway ||= Stretchy::Repository.create(index_name: index_name, klass: base_class)
37
+ block.arity < 1 ? @gateway.instance_eval(&block) : block.call(@gateway) if block_given?
38
+ @gateway
39
+ end
40
+
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,48 @@
1
+ module Stretchy
2
+ module Indexing
3
+ module Bulk
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
7
+
8
+ def bulk(records)
9
+ self.gateway.client.bulk body: records
10
+ end
11
+
12
+ # bulk_in_batches(records, size: 100) do |batch|
13
+ # # do something with the batch
14
+ # batch.each { |record| record.to_bulk(method)}
15
+ # end
16
+ def bulk_in_batches(records, size: 1000)
17
+ bulk_results = records.each_slice(size).map do |batch|
18
+ yield batch if block_given?
19
+ bulk(batch)
20
+ end
21
+ self.refresh_index!
22
+ bulk_results
23
+ end
24
+
25
+
26
+ end
27
+
28
+ # TODO: May only be needed for associations
29
+ def update_all(**attributes)
30
+ # self.class.bulk_in_batches(self.all, size: 1000, method: :update) do |batch|
31
+ # batch.map! { |record| attributes.each { |key, value| record.send("#{key}=", value) } }
32
+ # end
33
+ end
34
+
35
+ def to_bulk(method = :index)
36
+ case method
37
+ when :index
38
+ { index: { _index: self.class.index_name, data: self.as_json.except(:id) } }
39
+ when :delete
40
+ { delete: { _index: self.class.index_name, _id: self.id } }
41
+ when :update
42
+ { update: { _index: self.class.index_name, _id: self.id, data: { doc: self.as_json.except(:id) } } }
43
+ end
44
+ end
45
+
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,31 @@
1
+ module Stretchy
2
+ module Model
3
+ module Callbacks
4
+
5
+ extend ActiveSupport::Concern
6
+
7
+
8
+ included do
9
+ mattr_accessor :_circuit_breaker_callbacks, default: []
10
+
11
+ define_model_callbacks :create, :save, :update, :destroy
12
+ define_model_callbacks :find, :touch, only: :after
13
+ end
14
+
15
+ class_methods do
16
+
17
+ def query_must_have(*args, &block)
18
+ options = args.extract_options!
19
+
20
+ cb = block_given? ? block : options[:validate_with]
21
+
22
+ options[:message] = "does not exist in #{options[:in]}." unless options.has_key? :message
23
+
24
+ _circuit_breaker_callbacks << {name: args.first, options: options, callback: cb}
25
+
26
+ end
27
+ end
28
+
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,20 @@
1
+ module Stretchy
2
+ module Model
3
+ module Serialization
4
+ extend ActiveSupport::Concern
5
+
6
+ def serialize(document)
7
+ Hash[document.to_hash.map { |k,v| v.upcase! if k == :title; [k,v] }]
8
+ end
9
+
10
+ def deserialize(document)
11
+ attribs = ActiveSupport::HashWithIndifferentAccess.new(document['_source']).deep_symbolize_keys
12
+ _id = __get_id_from_document(document)
13
+ attribs[:id] = _id if _id
14
+ klass.new attribs
15
+ end
16
+
17
+ end
18
+ end
19
+ end
20
+
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stretchy
4
+ module NullRelation # :nodoc:
5
+ def pluck(*column_names)
6
+ []
7
+ end
8
+
9
+ def delete_all
10
+ 0
11
+ end
12
+
13
+ def update_all(_updates)
14
+ 0
15
+ end
16
+
17
+ def delete(_id_or_array)
18
+ 0
19
+ end
20
+
21
+ def empty?
22
+ true
23
+ end
24
+
25
+ def none?
26
+ true
27
+ end
28
+
29
+ def any?
30
+ false
31
+ end
32
+
33
+ def one?
34
+ false
35
+ end
36
+
37
+ def many?
38
+ false
39
+ end
40
+
41
+ def exists?(_conditions = :none)
42
+ false
43
+ end
44
+
45
+ def or(other)
46
+ other.spawn
47
+ end
48
+
49
+ def exec_queries
50
+ @records = OpenStruct.new(klass: NullRelation, total: 0, results: []).freeze
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,43 @@
1
+ module Stretchy
2
+ module Persistence
3
+ extend ActiveSupport::Concern
4
+
5
+ class_methods do
6
+ def create(*args)
7
+ self.new(*args).save
8
+ end
9
+ end
10
+
11
+ def save
12
+ run_callbacks :save do
13
+ if new_record?
14
+ run_callbacks :create do
15
+ response = self.class.gateway.save(self.attributes)
16
+ self.id = response['_id']
17
+ end
18
+ else
19
+ self.class.gateway.save(self.attributes)
20
+ end
21
+ self
22
+ end
23
+ end
24
+
25
+ def destroy
26
+ run_callbacks :destroy do
27
+ delete
28
+ end
29
+ end
30
+
31
+ def delete
32
+ self.class.gateway.delete(self.id)["result"] == 'deleted'
33
+ end
34
+
35
+ def update(*args)
36
+ run_callbacks :update do
37
+ self.assign_attributes(*args)
38
+ self.save
39
+ end
40
+ end
41
+
42
+ end
43
+ end
@@ -0,0 +1,20 @@
1
+ module Stretchy
2
+ module Querying
3
+ delegate :first, :first!, :last, :last!, :exists?, :has_field, :any?, :many?, to: :all
4
+ delegate :order, :limit, :size, :sort, :where, :rewhere, :eager_load, :includes, :create_with, :none, :unscope, to: :all
5
+ delegate :or_filter, :filter, :fields, :source, :highlight, :aggregation, to: :all
6
+ delegate :skip_callbacks, :routing, to: :all
7
+ delegate :search_options, :routing, to: :all
8
+ delegate :must, :must_not, :should, :where_not, :query_string, to: :all
9
+
10
+ def fetch_results(es)
11
+ unless es.count?
12
+ base_class.search(es.to_elastic, es.search_options)
13
+ else
14
+ base_class.count(es.to_elastic, es.search_options)
15
+ end
16
+ end
17
+
18
+
19
+ end
20
+ end
@@ -0,0 +1,57 @@
1
+ module Stretchy
2
+ class Record
3
+
4
+ def self.inherited(base)
5
+
6
+ base.class_eval do
7
+
8
+ extend Stretchy::Delegation::GatewayDelegation
9
+
10
+ include ActiveModel::Model
11
+ include ActiveModel::Attributes
12
+ include ActiveModel::AttributeAssignment
13
+ include ActiveModel::Naming
14
+ include ActiveModel::Conversion
15
+ include ActiveModel::Serialization
16
+ include ActiveModel::Serializers::JSON
17
+ include ActiveModel::Validations
18
+ include ActiveModel::Validations::Callbacks
19
+ extend ActiveModel::Callbacks
20
+
21
+
22
+
23
+ include Stretchy::Model::Callbacks
24
+ include Stretchy::Indexing::Bulk
25
+ include Stretchy::Persistence
26
+ include Stretchy::Associations
27
+ include Stretchy::Refreshable
28
+ include Stretchy::Common
29
+ include Stretchy::Scoping
30
+ include Stretchy::Utils
31
+
32
+ extend Stretchy::Delegation::DelegateCache
33
+ extend Stretchy::Querying
34
+
35
+ # Set up common attributes
36
+ attribute :id, :string #, default: lambda { SecureRandom.uuid }
37
+ attribute :created_at, :datetime, default: lambda { Time.now.utc }
38
+ attribute :updated_at, :datetime, default: lambda { Time.now.utc }
39
+
40
+ # Set the default sort key to be used in sort operations
41
+ default_sort_key :created_at
42
+
43
+ # Defaults max record size returned by #all
44
+ # overriden by #size
45
+ default_size 10000
46
+
47
+ end
48
+
49
+ def initialize(attributes = {})
50
+ self.assign_attributes(attributes) if attributes
51
+ super()
52
+ end
53
+
54
+ end
55
+
56
+ end
57
+ end
@@ -0,0 +1,15 @@
1
+ module Stretchy
2
+ module Refreshable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ after_save :refresh_index
7
+ after_destroy :refresh_index
8
+ end
9
+
10
+ def refresh_index
11
+ self.class.refresh_index!
12
+ end
13
+
14
+ end
15
+ end
@@ -0,0 +1,169 @@
1
+ module Stretchy
2
+ # This class represents a relation to Elasticsearch documents.
3
+ # It provides methods for querying and manipulating the documents.
4
+ class Relation
5
+
6
+ # These methods can accept multiple values.
7
+ MULTI_VALUE_METHODS = [:order, :where, :or_filter, :filter, :bind, :extending, :unscope, :skip_callbacks]
8
+
9
+ # These methods can accept a single value.
10
+ SINGLE_VALUE_METHODS = [:limit, :offset, :routing, :size]
11
+
12
+ # These methods cannot be used with the `delete_all` method.
13
+ INVALID_METHODS_FOR_DELETE_ALL = [:limit, :offset]
14
+
15
+ # All value methods.
16
+ VALUE_METHODS = MULTI_VALUE_METHODS + SINGLE_VALUE_METHODS
17
+
18
+ # Include modules.
19
+ include Relations::FinderMethods, Relations::SpawnMethods, Relations::QueryMethods, Relations::SearchOptionMethods, Delegation
20
+
21
+ # Getters.
22
+ attr_reader :klass, :loaded
23
+ alias :model :klass
24
+ alias :loaded? :loaded
25
+
26
+ # Delegates to the results array.
27
+ delegate :blank?, :empty?, :any?, :many?, to: :results
28
+
29
+ # Constructor.
30
+ #
31
+ # @param klass [Class] The class of the Elasticsearch documents.
32
+ # @param values [Hash] The initial values for the relation.
33
+ def initialize(klass, values={})
34
+ @klass = klass
35
+ @values = values
36
+ @offsets = {}
37
+ @loaded = false
38
+ end
39
+
40
+ # Builds a new Elasticsearch document.
41
+ #
42
+ # @param args [Array] The arguments to pass to the document constructor.
43
+ # @return [Object] The new document.
44
+ def build(*args)
45
+ @klass.new *args
46
+ end
47
+
48
+ # Returns the results of the relation as an array.
49
+ #
50
+ # @return [Array] The results of the relation.
51
+ def to_a
52
+
53
+ load
54
+ @records
55
+ end
56
+ alias :results :to_a
57
+
58
+ def response
59
+ to_a.response
60
+ end
61
+
62
+ # Returns the results of the relation as a JSON object.
63
+ #
64
+ # @param options [Hash] The options to pass to the `as_json` method.
65
+ # @return [Hash] The results of the relation as a JSON object.
66
+ def as_json(options = nil)
67
+ to_a.as_json(options)
68
+ end
69
+
70
+ # Returns the Elasticsearch query for the relation.
71
+ #
72
+ # @return [Hash] The Elasticsearch query for the relation.
73
+ def to_elastic
74
+ query_builder.to_elastic
75
+ end
76
+
77
+ # Creates a new Elasticsearch document.
78
+ #
79
+ # @param args [Array] The arguments to pass to the document constructor.
80
+ # @param block [Proc] The block to pass to the document constructor.
81
+ # @return [Object] The new document.
82
+ def create(*args, &block)
83
+ scoping { @klass.create!(*args, &block) }
84
+ end
85
+
86
+ # Executes a block of code within the scope of the relation.
87
+ #
88
+ # @yield The block of code to execute.
89
+ def scoping
90
+ previous, klass.current_scope = klass.current_scope, self
91
+ yield
92
+ ensure
93
+ klass.current_scope = previous
94
+ end
95
+
96
+ # Loads the results of the relation.
97
+ #
98
+ # @return [Relation] The relation object.
99
+ def load
100
+ exec_queries unless loaded?
101
+
102
+ self
103
+ end
104
+ alias :fetch :load
105
+
106
+ # Deletes Elasticsearch documents.
107
+ #
108
+ # @param opts [Hash] The options for the delete operation.
109
+ def delete(opts=nil)
110
+ end
111
+
112
+ # Executes the Elasticsearch query for the relation.
113
+ #
114
+ # @return [Array] The results of the query.
115
+ def exec_queries
116
+ # Run safety callback
117
+ klass._circuit_breaker_callbacks.each do |cb|
118
+ current_scope_values = self.send("#{cb[:options][:in]}_values")
119
+ next if skip_callbacks_values.include? cb[:name]
120
+ valid = if cb[:callback].nil?
121
+ current_scope_values.collect(&:keys).flatten.include? cb[:name]
122
+ else
123
+ cb[:callback].call(current_scope_values.collect(&:keys).flatten, current_scope_values)
124
+ end
125
+
126
+ raise Stretchy::Errors::QueryOptionMissing, "#{cb[:name]} #{cb[:options][:message]}" unless valid
127
+ end
128
+
129
+ @records = @klass.fetch_results(query_builder)
130
+
131
+ @loaded = true
132
+ @records
133
+ end
134
+
135
+ # Returns the values of the relation as a hash.
136
+ #
137
+ # @return [Hash] The values of the relation.
138
+ def values
139
+ Hash[@values]
140
+ end
141
+
142
+ # Returns a string representation of the relation.
143
+ #
144
+ # @return [String] The string representation of the relation.
145
+ def inspect
146
+ begin
147
+ entries = to_a.results.take([size_value.to_i + 1, 11].compact.min).map!(&:inspect)
148
+ message = {}
149
+ message = {total: to_a.total, max: to_a.total}
150
+ message.merge!(aggregations: results.response.aggregations.keys) unless results.response.aggregations.nil?
151
+ message = message.each_pair.collect { |k,v| "#{k}: #{v}" }
152
+ message.unshift entries.join(', ') unless entries.size.zero?
153
+ "#<#{self.class.name} #{message.join(', ')}>"
154
+ rescue StandardError => e
155
+ e
156
+ end
157
+ end
158
+
159
+ private
160
+
161
+ # Returns the query builder for the relation.
162
+ #
163
+ # @return [QueryBuilder] The query builder for the relation.
164
+ def query_builder
165
+ Relations::QueryBuilder.new(values)
166
+ end
167
+
168
+ end
169
+ end