stretchy-model 0.1.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 (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