esse-active_record 0.1.1

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: eeb304ba4915091f1b51addb850fc9161b1c415dcf61fdd9f0b189b43b7b4ac2
4
+ data.tar.gz: 52182a2e70cdb56f4658ef4ba515a86267ccc4b48a5e1ef5f2895ca192aca6be
5
+ SHA512:
6
+ metadata.gz: 24eb997cfdb498b4f8725e405aef700c22dd0a31c5d08b8092a923022543e06d9235f63c75a0d8099f06d1cd69fd00a6ecd4a7d8326bd807fe609fb0b295ad93
7
+ data.tar.gz: e770dcd174126061bd938979ea4d36f9c761a3b4efb6f8360a73642cc423efa57c9c22a9ac637b5453ac4ab497f7cb5e09509b0e60608989b9114ab03099bd5e
data/.rubocop.yml ADDED
@@ -0,0 +1,35 @@
1
+ inherit_mode:
2
+ merge:
3
+ - Exclude
4
+
5
+ require:
6
+ - rubocop-performance
7
+ - rubocop-rspec
8
+ - standard/cop/block_single_line_braces
9
+
10
+ inherit_gem:
11
+ standard: config/base.yml
12
+
13
+ AllCops:
14
+ TargetRubyVersion: 2.6
15
+ SuggestExtensions: false
16
+ Exclude:
17
+ - "db/**/*"
18
+ - "tmp/**/*"
19
+ - "vendor/**/*"
20
+
21
+ Layout/SpaceInsideHashLiteralBraces:
22
+ Enabled: false
23
+
24
+ Style/TrailingCommaInArguments:
25
+ Enabled: false
26
+
27
+ Style/TrailingCommaInArrayLiteral:
28
+ Enabled: false
29
+
30
+ Style/TrailingCommaInHashLiteral:
31
+ Enabled: false
32
+
33
+ Style/StringLiterals:
34
+ Enabled: true
35
+ EnforcedStyle: single_quotes
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'esse', github: 'marcosgz/esse', branch: 'master'
6
+ gem 'sqlite3', '~> 1.3.6'
7
+ gem 'activerecord', '~> 5.2'
8
+
9
+ # Specify your gem's dependencies in esse-active_record.gemspec
10
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,128 @@
1
+ GIT
2
+ remote: https://github.com/marcosgz/esse.git
3
+ revision: fa4eb4d8c54aae89c388415585b4b27c75b31198
4
+ branch: master
5
+ specs:
6
+ esse (0.2.1)
7
+ multi_json
8
+ thor (>= 0.19)
9
+
10
+ PATH
11
+ remote: .
12
+ specs:
13
+ esse-active_record (0.1.1)
14
+ activerecord (>= 4.2, < 8)
15
+ esse
16
+
17
+ GEM
18
+ remote: https://rubygems.org/
19
+ specs:
20
+ activemodel (5.2.8.1)
21
+ activesupport (= 5.2.8.1)
22
+ activerecord (5.2.8.1)
23
+ activemodel (= 5.2.8.1)
24
+ activesupport (= 5.2.8.1)
25
+ arel (>= 9.0)
26
+ activesupport (5.2.8.1)
27
+ concurrent-ruby (~> 1.0, >= 1.0.2)
28
+ i18n (>= 0.7, < 2)
29
+ minitest (~> 5.1)
30
+ tzinfo (~> 1.1)
31
+ addressable (2.8.0)
32
+ public_suffix (>= 2.0.2, < 5.0)
33
+ arel (9.0.0)
34
+ ast (2.4.2)
35
+ awesome_print (1.9.2)
36
+ coderay (1.1.3)
37
+ concurrent-ruby (1.1.10)
38
+ crack (0.4.5)
39
+ rexml
40
+ diff-lcs (1.5.0)
41
+ dotenv (2.7.6)
42
+ hashdiff (1.0.1)
43
+ i18n (1.12.0)
44
+ concurrent-ruby (~> 1.0)
45
+ method_source (1.0.0)
46
+ minitest (5.16.2)
47
+ multi_json (1.15.0)
48
+ parallel (1.22.1)
49
+ parser (3.1.2.0)
50
+ ast (~> 2.4.1)
51
+ pry (0.14.1)
52
+ coderay (~> 1.1)
53
+ method_source (~> 1.0)
54
+ public_suffix (4.0.7)
55
+ rainbow (3.1.1)
56
+ rake (12.3.3)
57
+ regexp_parser (2.5.0)
58
+ rexml (3.2.5)
59
+ rspec (3.11.0)
60
+ rspec-core (~> 3.11.0)
61
+ rspec-expectations (~> 3.11.0)
62
+ rspec-mocks (~> 3.11.0)
63
+ rspec-core (3.11.0)
64
+ rspec-support (~> 3.11.0)
65
+ rspec-expectations (3.11.0)
66
+ diff-lcs (>= 1.2.0, < 2.0)
67
+ rspec-support (~> 3.11.0)
68
+ rspec-mocks (3.11.1)
69
+ diff-lcs (>= 1.2.0, < 2.0)
70
+ rspec-support (~> 3.11.0)
71
+ rspec-support (3.11.0)
72
+ rubocop (1.29.1)
73
+ parallel (~> 1.10)
74
+ parser (>= 3.1.0.0)
75
+ rainbow (>= 2.2.2, < 4.0)
76
+ regexp_parser (>= 1.8, < 3.0)
77
+ rexml (>= 3.2.5, < 4.0)
78
+ rubocop-ast (>= 1.17.0, < 2.0)
79
+ ruby-progressbar (~> 1.7)
80
+ unicode-display_width (>= 1.4.0, < 3.0)
81
+ rubocop-ast (1.18.0)
82
+ parser (>= 3.1.1.0)
83
+ rubocop-performance (1.13.3)
84
+ rubocop (>= 1.7.0, < 2.0)
85
+ rubocop-ast (>= 0.4.0)
86
+ rubocop-rspec (2.11.1)
87
+ rubocop (~> 1.19)
88
+ ruby-progressbar (1.11.0)
89
+ sqlite3 (1.3.13)
90
+ standard (1.12.1)
91
+ rubocop (= 1.29.1)
92
+ rubocop-performance (= 1.13.3)
93
+ thor (1.2.1)
94
+ thread_safe (0.3.6)
95
+ tzinfo (1.2.10)
96
+ thread_safe (~> 0.1)
97
+ unicode-display_width (2.2.0)
98
+ webmock (3.14.0)
99
+ addressable (>= 2.8.0)
100
+ crack (>= 0.3.2)
101
+ hashdiff (>= 0.4.0, < 2.0.0)
102
+ webrick (1.7.0)
103
+ yard (0.9.28)
104
+ webrick (~> 1.7.0)
105
+
106
+ PLATFORMS
107
+ x86_64-darwin-19
108
+ x86_64-linux
109
+
110
+ DEPENDENCIES
111
+ activerecord (~> 5.2)
112
+ awesome_print
113
+ dotenv
114
+ esse!
115
+ esse-active_record!
116
+ pry
117
+ rake (~> 12.3)
118
+ rspec (~> 3.0)
119
+ rubocop (~> 1.20)
120
+ rubocop-performance (~> 1.11, >= 1.11.5)
121
+ rubocop-rspec (~> 2.4)
122
+ sqlite3 (~> 1.3.6)
123
+ standard (~> 1.3)
124
+ webmock (~> 3.14)
125
+ yard (~> 0.9.20)
126
+
127
+ BUNDLED WITH
128
+ 2.3.21
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2022 Marcos G. Zimmermann
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,226 @@
1
+ # Esse ActiveRecord Plugin
2
+
3
+ This gem is a [esse](https://github.com/marcosgz/esse) plugin for the ActiveRecord ORM. It provides a set of methods to simplify implementation of ActiveRecord models as datasource of esse indexes.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'esse-active_record'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle install
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install esse-active_record
20
+
21
+ ## Usage
22
+
23
+ Add the `:active_record` plugin and configure the `collection` with the ActiveRecord model you want to use.
24
+
25
+ ```ruby
26
+ class UsersIndex < Esse::Index
27
+ plugin :active_record
28
+
29
+ repository :user do
30
+ collection ::User
31
+ serializer # ...
32
+ end
33
+ end
34
+ ```
35
+
36
+ Using multiple repositories is also possible:
37
+
38
+ ```ruby
39
+ class UsersIndex < Esse::Index
40
+ plugin :active_record
41
+
42
+ repository :account do
43
+ collection ::Account
44
+ serializer # ...
45
+ end
46
+
47
+ repository :admin do
48
+ collection ::User.where(admin: true)
49
+ serializer # ...
50
+ end
51
+ end
52
+ ```
53
+
54
+ ### Collection Scope
55
+ It's also possible to specify custom scopes to the repository collection to be used to import data to the index:
56
+
57
+ ```ruby
58
+ class UsersIndex < Esse::Index
59
+ plugin :active_record
60
+
61
+ repository :user do
62
+ collection ::User do
63
+ scope :active, -> { where(active: true) }
64
+ scope :role, ->(role) { where(role: role) }
65
+ end
66
+ serializer # ...
67
+ end
68
+ end
69
+
70
+ # Import data using the scopes
71
+ # > UsersIndex.elasticsearch.import(context: { active: true, role: 'admin' })
72
+ #
73
+ # Streaming data using the scopes
74
+ # > UsersIndex.documents(active: true, role: 'admin').first
75
+ ```
76
+
77
+ ## Collection Batch Context
78
+
79
+ Assume that you have a collection of orders and you want to also include the customer data that lives in a external system. To avoid making a request for each order, you can use the `batch_context` to fetch the data in batches and make it available in the serializer context.
80
+
81
+ ```ruby
82
+ class OrdersIndex < Esse::Index
83
+ plugin :active_record
84
+
85
+ repository :order do
86
+ collection ::Order do
87
+ batch_context :customers do |orders, **_existing_context|
88
+ # The return value will be available in the serializer context
89
+ # { customers: <value returned from this block> }
90
+ ExternalSystem::Customer.find_all_by_ids(orders.map(&:customer_id)).index_by(&:id) # => { 1 => <Customer>, 2 => <Customer> }
91
+ end
92
+ end
93
+ serializer do |order, customers: [], **_|
94
+ customer = customers[order.customer_id]
95
+ {
96
+ id: order.id,
97
+ customer: {
98
+ id: customer&.id,
99
+ name: customer&.name
100
+ }
101
+ }
102
+ end
103
+ end
104
+ end
105
+ ```
106
+
107
+ For active record associations, you can define the repository collection by eager loading the associations as usual:
108
+
109
+ ```ruby
110
+
111
+ class OrdersIndex < Esse::Index
112
+ plugin :active_record
113
+
114
+ repository :order do
115
+ collection ::Order.includes(:customer)
116
+ serializer do |order, **_|
117
+ {
118
+ id: order.id,
119
+ customer: {
120
+ id: order.customer&.id,
121
+ name: order.customer&.name
122
+ }
123
+ }
124
+ end
125
+ end
126
+ end
127
+ ```
128
+
129
+ ### Data Streaming Optionsou
130
+
131
+ As default the active record support 3 streaming options:
132
+ * `batch_size`: the number of documents to be streamed in each batch. Default is 1000;
133
+ * `start`: the primary key value to start from, inclusive of the value;
134
+ * `finish`: the primary key value to end at, inclusive of the value;
135
+
136
+ This is useful when you want to import simultaneous data. You can make one process import all records between 1 and 10,000, and another from 10,000 and beyond
137
+
138
+ ```ruby
139
+ UsersIndex.elasticsearch.import(context: { start: 1, finish: 10000, batch_size: 500 })
140
+ ```
141
+
142
+ The default valueof `batch_size` can be also defined in the `collection` configuration:
143
+
144
+ ```ruby
145
+ class UsersIndex < Esse::Index
146
+ plugin :active_record
147
+
148
+ repository :user do
149
+ collection ::User, batch_size: 500
150
+ serializer # ...
151
+ end
152
+ end
153
+ ```
154
+
155
+ ### Indexing Callbacks
156
+
157
+ The `index_callbacks` callback can be used to automaitcally index or delete documents after commit on create/update/destroy events.
158
+
159
+ ```ruby
160
+ class UsersIndex < Esse::Index
161
+ plugin :active_record
162
+
163
+ repository :user, const: true do
164
+ collection ::User
165
+ serializer # ...
166
+ end
167
+
168
+ end
169
+
170
+ class User < ApplicationRecord
171
+ belongs_to :organization
172
+
173
+ # Using a index and repository as argument. Note that the index name is used instead of the
174
+ # of the constant name. it's so because index and model depends on each other should result in
175
+ # circular dependencies issues.
176
+ index_callbacks 'users_index:user'
177
+ # Using a block to direct a different object to be indexed
178
+ index_callbacks('organizations') { user.organization } # The `_index` suffix and repo name is optional on the index name
179
+ end
180
+ ```
181
+
182
+ Callbacks can also be disabled/enabled globally:
183
+
184
+ ```ruby
185
+ Esse::ActiveRecord::Hoods.disable!
186
+ Esse::ActiveRecord::Hoods.enable!
187
+ Esse::ActiveRecord::Hoods.without_indexing do
188
+ 10.times { User.create! }
189
+ end
190
+ ```
191
+
192
+ or by some specific list of index or index's repository
193
+
194
+ ```ruby
195
+ Esse::ActiveRecord::Hoods.disable!(UsersIndex.repo)
196
+ Esse::ActiveRecord::Hoods.enable!(UsersIndex.repo)
197
+ Esse::ActiveRecord::Hoods.without_indexing(AccountsIndex UsersIndex.repo, ) do
198
+ 10.times { User.create! }
199
+ end
200
+ ```
201
+
202
+ or by the model that the callback is configured
203
+
204
+ ```ruby
205
+ User.without_indexing do
206
+ 10.times { User.create! }
207
+ end
208
+ User.without_indexing(AccountsIndex) do
209
+ 10.times { User.create! }
210
+ end
211
+ ```
212
+
213
+
214
+ ## Development
215
+
216
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake none` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
217
+
218
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
219
+
220
+ ## Contributing
221
+
222
+ Bug reports and pull requests are welcome on GitHub at https://github.com/marcosgz/esse-active_record.
223
+
224
+ ## License
225
+
226
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ task default: %i[]
@@ -0,0 +1,129 @@
1
+ module Esse
2
+ module ActiveRecord
3
+ class Collection
4
+ include Enumerable
5
+
6
+ # The model class or the relation to be used as the scope
7
+ # @return [Proc]
8
+ class_attribute :base_scope
9
+
10
+ # The number of records to be returned in each batch
11
+ # @return [Integer]
12
+ class_attribute :batch_size
13
+
14
+ # Hash with the custom scopes defined on the model
15
+ # @return [Hash]
16
+ class_attribute :scopes
17
+ self.scopes = {}
18
+
19
+ # The hash with the contexts as key and the transformer proc as value
20
+ # @return [Hash]
21
+ class_attribute :batch_contexts
22
+ self.batch_contexts = {}
23
+
24
+ class << self
25
+ def inspect
26
+ return super unless self < Esse::ActiveRecord::Collection
27
+ return super unless base_scope
28
+
29
+ format('#<Esse::ActiveRecord::Collection__%s>', model)
30
+ end
31
+
32
+ def model
33
+ raise(NotImplementedError, "No model defined for #{self}") unless base_scope
34
+
35
+ base_scope.call.all.model
36
+ end
37
+
38
+ def inherited(subclass)
39
+ super
40
+
41
+ subclass.scopes = scopes.dup
42
+ subclass.batch_contexts = batch_contexts.dup
43
+ end
44
+
45
+ def scope(name, proc = nil, override: false, &block)
46
+ proc = proc&.to_proc || block
47
+ raise ArgumentError, 'proc or block required' unless proc
48
+ raise ArgumentError, "scope `#{name}' already defined" if !override && scopes.key?(name.to_sym)
49
+
50
+ scopes[name.to_sym] = proc
51
+ end
52
+
53
+ def batch_context(name, proc = nil, override: false, &block)
54
+ proc = proc&.to_proc || block
55
+ raise ArgumentError, 'proc or block required' unless proc
56
+ raise ArgumentError, "batch_context `#{name}' already defined" if !override && batch_contexts.key?(name.to_sym)
57
+
58
+ batch_contexts[name.to_sym] = proc
59
+ end
60
+ end
61
+
62
+ attr_reader :start, :finish, :batch_size, :params
63
+
64
+ # @param [Integer] start Specifies the primary key value to start from, inclusive of the value.
65
+ # @param [Integer] finish Specifies the primary key value to end at, inclusive of the value.
66
+ # @param [Integer] batch_size The number of records to be returned in each batch. Defaults to 1000.
67
+ # @param [Hash] params The query criteria
68
+ # @return [Esse::ActiveRecord::Collection]
69
+ def initialize(start: nil, finish: nil, batch_size: nil, **params)
70
+ @start = start
71
+ @finish = finish
72
+ @batch_size = batch_size || self.class.batch_size || 1000
73
+ @params = params
74
+ end
75
+
76
+ def each
77
+ dataset.find_in_batches(**batch_options) do |rows|
78
+ kwargs = params.dup
79
+ self.class.batch_contexts.each do |name, proc|
80
+ kwargs[name] = proc.call(rows, **params)
81
+ end
82
+ yield(rows, **kwargs)
83
+ end
84
+ end
85
+
86
+ def dataset(**kwargs)
87
+ query = self.class.base_scope&.call || raise(NotImplementedError, "No scope defined for #{self.class}")
88
+ query = query.except(:order, :limit, :offset)
89
+ params.merge(kwargs).each do |key, value|
90
+ if self.class.scopes.key?(key)
91
+ scope_proc = self.class.scopes[key]
92
+ query = if scope_proc.arity == 0
93
+ query.instance_exec(&scope_proc)
94
+ else
95
+ query.instance_exec(value, &scope_proc)
96
+ end
97
+ elsif query.model.columns_hash.key?(key.to_s)
98
+ query = query.where(key => value)
99
+ else
100
+ raise ArgumentError, "Unknown scope `#{key}'"
101
+ end
102
+ end
103
+
104
+ query
105
+ end
106
+
107
+ def inspect
108
+ return super unless self.class < Esse::ActiveRecord::Collection
109
+ return super unless self.class.base_scope
110
+
111
+ vars = instance_variables.map do |n|
112
+ "#{n}=#{instance_variable_get(n).inspect}"
113
+ end
114
+ format('#<Esse::ActiveRecord::Collection__%s:0x%x %s>', self.class.model, object_id, vars.join(', '))
115
+ end
116
+
117
+ protected
118
+
119
+ def batch_options
120
+ {
121
+ batch_size: batch_size
122
+ }.tap do |hash|
123
+ hash[:start] = start if start
124
+ hash[:finish] = finish if finish
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Esse
4
+ module ActiveRecord
5
+ module Hooks
6
+ STORE_STATE_KEY = :esse_active_record_hooks
7
+
8
+ class << self
9
+ def register_model(model_class)
10
+ @models ||= []
11
+ @models |= [model_class]
12
+ end
13
+
14
+ def models
15
+ @models || []
16
+ end
17
+
18
+ def model_names
19
+ models.map(&:to_s)
20
+ end
21
+
22
+ # Global enable indexing callbacks. If no repository is specified, all repositories will be enabled.
23
+ # @param repos [Array<String, Esse::Index, Esse::Repo>]
24
+ # @return [void]
25
+ def enable!(*repos)
26
+ filter_repositories(*repos).each do |repo|
27
+ state[:repos][repo] = true
28
+ end
29
+ end
30
+
31
+ # Global disable indexing callbacks. If no repository is specified, all repositories will be disabled.
32
+ # @param repos [Array<String, Esse::Index, Esse::Repo>]
33
+ # @return [void]
34
+ def disable!(*repos)
35
+ filter_repositories(*repos).each do |repo|
36
+ state[:repos][repo] = false
37
+ end
38
+ end
39
+
40
+ # Check if the given repository is enabled for indexing. If no repository is specified, all repositories will be checked.
41
+ #
42
+ # @param repos [Array<String, Esse::Index, Esse::Repo>]
43
+ # @return [Boolean]
44
+ def disabled?(*repos)
45
+ filter_repositories(*repos).all? { |repo| !state[:repos][repo] }
46
+ end
47
+
48
+ # Check if the given repository is enabled for indexing. If no repository is specified, all repositories will be checked.
49
+ #
50
+ # @param repos [Array<String, Esse::Index, Esse::Repo>]
51
+ # @return [Boolean]
52
+ def enabled?(*repos)
53
+ filter_repositories(*repos).all? { |repo| state[:repos][repo] }
54
+ end
55
+
56
+ # Enable model indexing callbacks for the given model. If no repository is specified, all repositories will be enabled.
57
+ #
58
+ # @param model_class [Class]
59
+ # @param repos [Array<String, Esse::Index, Esse::Repo>]
60
+ # @raise [ArgumentError] if model repository is not registered for the given model
61
+ # @return [void]
62
+ def enable_model!(model_class, *repos)
63
+ ensure_registered_model_class!(model_class)
64
+ filter_model_repositories(model_class, *repos).each do |repo|
65
+ state[:models][model_class][repo] = true
66
+ end
67
+ end
68
+
69
+ # Disable model indexing callbacks for the given model. If no repository is specified, all repositories will be disabled.
70
+ #
71
+ # @param model_class [Class]
72
+ # @param repos [Array<String, Esse::Index, Esse::Repo>]
73
+ # @raise [ArgumentError] if model repository is not registered for the given model
74
+ # @return [void]
75
+ def disable_model!(model_class, *repos)
76
+ ensure_registered_model_class!(model_class)
77
+ filter_model_repositories(model_class, *repos).each do |repo|
78
+ state[:models][model_class][repo] = false
79
+ end
80
+ end
81
+
82
+ def ensure_registered_model_class!(model_class)
83
+ return if registered_model_class?(model_class)
84
+
85
+ raise ArgumentError, "Model class #{model_class} is not registered. The model should inherit from Esse::ActiveRecord::Model and have a `index_callbacks' callback defined"
86
+ end
87
+
88
+ # Check if the given model is enabled for indexing. If no repository is specified, all repositories will be checked.
89
+ #
90
+ # @param model_class [Class]
91
+ # @param repos [Array<String, Esse::Index, Esse::Repo>]
92
+ # @return [Boolean]
93
+ def enabled_for_model?(model_class, *repos)
94
+ return false unless registered_model_class?(model_class)
95
+
96
+ filter_model_repositories(model_class, *repos).all? do |repo|
97
+ state.dig(:models, model_class, repo) != false
98
+ end
99
+ end
100
+
101
+ # Disable indexing callbacks execution for the block execution.
102
+ # Example:
103
+ # Esse::ActiveRecord::Hooks.without_indexing { User.create! }
104
+ # Esse::ActiveRecord::Hooks.without_indexing(UsersIndex, AccountsIndex.repo(:user)) { User.create! }
105
+ def without_indexing(*repos)
106
+ state_before_disable = state[:repos].dup
107
+ disable!(*repos)
108
+
109
+ yield
110
+ ensure
111
+ state[:repos] = state_before_disable
112
+ end
113
+
114
+ # Disable model indexing callbacks execution for the block execution for the given model.
115
+ # Example:
116
+ # BroadcastChanges.without_indexing_for_model(User) { }
117
+ # BroadcastChanges.without_indexing_for_model(User, :datasync, :other) { }
118
+ def without_indexing_for_model(model_class, *repos)
119
+ state_before_disable = state[:models].dig(model_class).dup
120
+ disable_model!(model_class, *repos)
121
+ yield
122
+ ensure
123
+ if state_before_disable.nil?
124
+ state[:models].delete(model_class)
125
+ else
126
+ state[:models][model_class] = state_before_disable
127
+ end
128
+ end
129
+
130
+ def resolve_index_repository(name)
131
+ index_name, repo_name = name.to_s.underscore.split('::').join('/').split(':', 2)
132
+ if index_name !~ /(I|_i)ndex$/ && index_name !~ /_index\/([\w_]+)$/
133
+ index_name = format('%<index_name>s_index', index_name: index_name)
134
+ end
135
+ klass = index_name.classify.constantize
136
+ return klass if klass <= Esse::Repository
137
+
138
+ repo_name.present? ? klass.repo(repo_name) : klass.repo
139
+ end
140
+
141
+ private
142
+
143
+ def all_repos
144
+ models.flat_map(&method(:model_repos)).uniq
145
+ end
146
+
147
+ # Returns a list of all repositories for the given model
148
+ # @return [Array<Symbol>]
149
+ def model_repos(model_class)
150
+ expand_index_repos(*model_class.esse_index_repos.keys)
151
+ end
152
+
153
+ # Returns a list of all repositories for the given model
154
+ # If no repository is specified, all repositories will be returned.
155
+ # @return [Array<*Esse::Repository>] List of repositories
156
+ def filter_repositories(*repos)
157
+ (expand_index_repos(*repos) & all_repos).presence || all_repos
158
+ end
159
+
160
+ # Return repositorys for the given model. If no repository is specified, all repositories will be returned.
161
+ #
162
+ # @param model_class [Class]
163
+ # @param repos [Array<*Esse::Repository>] List of repositories to check for the given model
164
+ # @return [Array<*Esse::Repository>] List of repositories
165
+ def filter_model_repositories(model_class, *repos)
166
+ model_repos = model_repos(model_class) & all_repos
167
+ (expand_index_repos(*repos) & model_repos).presence || model_repos
168
+ end
169
+
170
+ def expand_index_repos(*repos)
171
+ repos.flat_map do |repo_name|
172
+ case repo_name
173
+ when Class
174
+ repo_name <= Esse::Index ? repo_name.repo_hash.values : repo_name
175
+ when String, Symbol
176
+ resolve_index_repository(repo_name)
177
+ else
178
+ raise ArgumentError, "Invalid index or repository name: #{repo_name.inspect}"
179
+ end
180
+ end
181
+ end
182
+
183
+ # Check if model class is registered
184
+ # @return [Boolean] true if model class is registered
185
+ def registered_model_class?(model_class)
186
+ models.include?(model_class)
187
+ end
188
+
189
+ # Data Structure:
190
+ #
191
+ # repos: { <Esse::Repository class> => <true|false>, ... }
192
+ # models: {
193
+ # <ActiveRecord::Base class> => {
194
+ # <Esse::Repository class> => <true|false>
195
+ # }
196
+ # }
197
+ def state
198
+ global_store[STORE_STATE_KEY] ||= {
199
+ repos: all_repos.map { |k| [k, true] }.to_h, # Control global state of the index repository level
200
+ models: Hash.new { |h, k| h[k] = {} }, # Control the state of the model & index repository level
201
+ }
202
+ end
203
+
204
+ def global_store
205
+ Thread.current
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Esse
4
+ module ActiveRecord
5
+ module Model
6
+ extend ActiveSupport::Concern
7
+
8
+ def self.inherited(subclass)
9
+ super
10
+ subclass.esse_index_repos = esse_index_repos.dup
11
+ end
12
+
13
+ module ClassMethods
14
+ attr_reader :esse_index_repos
15
+
16
+ # Define callback for create/update/delete elasticsearch index document after model commit.
17
+ #
18
+ # @param [String] index_repo_name The path of index and repository name.
19
+ # For example a index with a single repository named `users` is `users`. And a index with
20
+ # multiple repositories named `animals` and `dog` as the repository name is `animals/dog`.
21
+ # For namespace, use `/` as the separator.
22
+ # @raise [ArgumentError] when the repo and events are already registered
23
+ # @raise [ArgumentError] when the specified index have multiple repos
24
+ def index_callbacks(index_repo_name, on: %i[create update destroy], **options, &block)
25
+ @esse_index_repos ||= {}
26
+
27
+ operation_name = :index
28
+ if esse_index_repos.dig(index_repo_name, operation_name)
29
+ raise ArgumentError, format('index repository %<name>p already registered %<op>s operation', name: index_repo_name, op: operation_name)
30
+ end
31
+
32
+ esse_index_repos[index_repo_name] ||= {}
33
+ esse_index_repos[index_repo_name][operation_name] = {
34
+ record: (block || -> { self }),
35
+ options: options,
36
+ }
37
+
38
+ Esse::ActiveRecord::Hooks.register_model(self)
39
+
40
+ if_enabled = -> { Esse::ActiveRecord::Hooks.enabled?(index_repo_name) && Esse::ActiveRecord::Hooks.enabled_for_model?(self.class, index_repo_name) }
41
+ (on & %i[create update]).each do |event|
42
+ after_commit(on: event, if: if_enabled) do
43
+ opts = self.class.esse_index_repos.fetch(index_repo_name).fetch(operation_name)
44
+ record = opts.fetch(:record)
45
+ record = instance_exec(&record) if record.respond_to?(:call)
46
+ repo = Esse::ActiveRecord::Hooks.resolve_index_repository(index_repo_name)
47
+ document = repo.serialize(record)
48
+ repo.elasticsearch.index_document(document, **opts[:options]) if document
49
+ true
50
+ end
51
+ end
52
+ (on & %i[destroy]).each do |event|
53
+ after_commit(on: event, if: if_enabled) do
54
+ opts = self.class.esse_index_repos.fetch(index_repo_name).fetch(operation_name)
55
+ record = opts.fetch(:record)
56
+ record = instance_exec(&record) if record.respond_to?(:call)
57
+ repo = Esse::ActiveRecord::Hooks.resolve_index_repository(index_repo_name)
58
+ document = repo.serialize(record)
59
+ repo.elasticsearch.delete_document(document, **opts[:options]) if document
60
+ true
61
+ end
62
+ end
63
+ end
64
+
65
+ # Disable indexing for the block execution on model level
66
+ # Example:
67
+ # User.without_indexing { }
68
+ # User.without_indexing(UsersIndex, AccountsIndex::User) { }
69
+ def without_indexing(*repos)
70
+ Esse::ActiveRecord::Hooks.without_indexing_for_model(self, *repos) do
71
+ yield
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Esse
4
+ module ActiveRecord
5
+ VERSION = '0.1.1'
6
+ end
7
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'esse'
4
+ require 'active_record'
5
+ require_relative 'active_record/version'
6
+ require_relative 'active_record/model'
7
+ require_relative 'active_record/hooks'
8
+ require_relative 'active_record/collection'
9
+ require_relative 'plugins/active_record'
10
+
11
+ module Esse
12
+ module ActiveRecord
13
+ end
14
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+
5
+ module Esse
6
+ module Plugins
7
+ module ActiveRecord
8
+ module RepositoryClassMethods
9
+ # @param [Class] model_class The ActiveRecord Relation or model class
10
+ # @param [Hash] options The options
11
+ # @option options [Symbol] :batch_size The batch size for the collection
12
+ def collection(*args, **kwargs, &block)
13
+ unless model_or_relation?(args.first)
14
+ return super(*args, **kwargs, &block)
15
+ end
16
+ model_class = args.shift
17
+
18
+ repo = Class.new(Esse::ActiveRecord::Collection)
19
+ repo.base_scope = -> { model_class }
20
+ repo.batch_size = kwargs.delete(:batch_size) if kwargs.key?(:batch_size)
21
+ repo.class_eval(&block) if block
22
+
23
+ super(repo, *args, **kwargs)
24
+ end
25
+
26
+ def dataset(**params)
27
+ if @collection_proc.nil?
28
+ raise NotImplementedError, "Can't call `dataset' on a repository without a collection defined"
29
+ elsif @collection_proc.is_a?(Class) && @collection_proc < Esse::ActiveRecord::Collection
30
+ @collection_proc.new(**params).dataset
31
+ elsif defined? super
32
+ super
33
+ else
34
+ raise NoMethodError, "undefined method `dataset' for #{self}"
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def model_or_relation?(klass)
41
+ return true if klass.is_a?(Class) && klass < ::ActiveRecord::Base
42
+ return true if klass.is_a?(::ActiveRecord::Relation)
43
+
44
+ false
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,6 @@
1
+ module Esse
2
+ module ActiveRecord
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,253 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: esse-active_record
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Marcos G. Zimmermann
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-02-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: esse
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activerecord
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '4.2'
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '8'
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '4.2'
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '8'
47
+ - !ruby/object:Gem::Dependency
48
+ name: awesome_print
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: dotenv
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ - !ruby/object:Gem::Dependency
76
+ name: pry
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ - !ruby/object:Gem::Dependency
90
+ name: rake
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '12.3'
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '12.3'
103
+ - !ruby/object:Gem::Dependency
104
+ name: rspec
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '3.0'
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '3.0'
117
+ - !ruby/object:Gem::Dependency
118
+ name: webmock
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '3.14'
124
+ type: :development
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '3.14'
131
+ - !ruby/object:Gem::Dependency
132
+ name: yard
133
+ requirement: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: 0.9.20
138
+ type: :development
139
+ prerelease: false
140
+ version_requirements: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: 0.9.20
145
+ - !ruby/object:Gem::Dependency
146
+ name: standard
147
+ requirement: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '1.3'
152
+ type: :development
153
+ prerelease: false
154
+ version_requirements: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - "~>"
157
+ - !ruby/object:Gem::Version
158
+ version: '1.3'
159
+ - !ruby/object:Gem::Dependency
160
+ name: rubocop
161
+ requirement: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - "~>"
164
+ - !ruby/object:Gem::Version
165
+ version: '1.20'
166
+ type: :development
167
+ prerelease: false
168
+ version_requirements: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - "~>"
171
+ - !ruby/object:Gem::Version
172
+ version: '1.20'
173
+ - !ruby/object:Gem::Dependency
174
+ name: rubocop-performance
175
+ requirement: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - "~>"
178
+ - !ruby/object:Gem::Version
179
+ version: '1.11'
180
+ - - ">="
181
+ - !ruby/object:Gem::Version
182
+ version: 1.11.5
183
+ type: :development
184
+ prerelease: false
185
+ version_requirements: !ruby/object:Gem::Requirement
186
+ requirements:
187
+ - - "~>"
188
+ - !ruby/object:Gem::Version
189
+ version: '1.11'
190
+ - - ">="
191
+ - !ruby/object:Gem::Version
192
+ version: 1.11.5
193
+ - !ruby/object:Gem::Dependency
194
+ name: rubocop-rspec
195
+ requirement: !ruby/object:Gem::Requirement
196
+ requirements:
197
+ - - "~>"
198
+ - !ruby/object:Gem::Version
199
+ version: '2.4'
200
+ type: :development
201
+ prerelease: false
202
+ version_requirements: !ruby/object:Gem::Requirement
203
+ requirements:
204
+ - - "~>"
205
+ - !ruby/object:Gem::Version
206
+ version: '2.4'
207
+ description: ActiveRecord extensions for Esse
208
+ email:
209
+ - mgzmaster@gmail.com
210
+ executables: []
211
+ extensions: []
212
+ extra_rdoc_files: []
213
+ files:
214
+ - ".rubocop.yml"
215
+ - Gemfile
216
+ - Gemfile.lock
217
+ - LICENSE.txt
218
+ - README.md
219
+ - Rakefile
220
+ - lib/esse/active_record.rb
221
+ - lib/esse/active_record/collection.rb
222
+ - lib/esse/active_record/hooks.rb
223
+ - lib/esse/active_record/model.rb
224
+ - lib/esse/active_record/version.rb
225
+ - lib/esse/plugins/active_record.rb
226
+ - sig/esse/active_record.rbs
227
+ homepage: https://github.com/marcosgz/esse-active_record
228
+ licenses:
229
+ - MIT
230
+ metadata:
231
+ homepage_uri: https://github.com/marcosgz/esse-active_record
232
+ source_code_uri: https://github.com/marcosgz/esse-active_record
233
+ changelog_uri: https://github.com/marcosgz/esse-active_record/blob/main/CHANGELOG.md
234
+ post_install_message:
235
+ rdoc_options: []
236
+ require_paths:
237
+ - lib
238
+ required_ruby_version: !ruby/object:Gem::Requirement
239
+ requirements:
240
+ - - ">="
241
+ - !ruby/object:Gem::Version
242
+ version: 2.4.0
243
+ required_rubygems_version: !ruby/object:Gem::Requirement
244
+ requirements:
245
+ - - ">="
246
+ - !ruby/object:Gem::Version
247
+ version: '0'
248
+ requirements: []
249
+ rubygems_version: 3.0.3.1
250
+ signing_key:
251
+ specification_version: 4
252
+ summary: ActiveRecord extensions for Esse
253
+ test_files: []