esse-active_record 0.1.1

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