esse-sequel 0.0.1.beta1

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.
data/docs/usage.md ADDED
@@ -0,0 +1,211 @@
1
+ # Usage Guide
2
+
3
+ ## Installation
4
+
5
+ ```ruby
6
+ # Gemfile
7
+ gem 'esse'
8
+ gem 'esse-sequel'
9
+ gem 'sequel'
10
+ ```
11
+
12
+ Enable the plugin on any index:
13
+
14
+ ```ruby
15
+ class UsersIndex < Esse::Index
16
+ plugin :sequel
17
+ end
18
+ ```
19
+
20
+ ## The `collection` DSL
21
+
22
+ Pass a Sequel model (or dataset) to `collection`:
23
+
24
+ ```ruby
25
+ class UsersIndex < Esse::Index
26
+ plugin :sequel
27
+
28
+ repository :user do
29
+ collection ::User
30
+ document { |u, **| { _id: u.id, name: u.name } }
31
+ end
32
+ end
33
+ ```
34
+
35
+ Options:
36
+
37
+ | Option | Default | Description |
38
+ |--------|---------|-------------|
39
+ | `batch_size` | `1000` | Records per batch |
40
+ | `connect_with` | — | `{ server: :read_only }` (a server from the Sequel `:servers` option) |
41
+
42
+ ```ruby
43
+ repository :user do
44
+ collection ::User, batch_size: 500, connect_with: { server: :read_only }
45
+ document { |u, **| { _id: u.id, name: u.name } }
46
+ end
47
+ ```
48
+
49
+ The same can be declared inside the collection block with `connected_to server: :read_only`. The collection dataset runs on that server, and when the `server_block` extension is loaded (`DB.extension :server_block`) the whole iteration, `batch_context` queries included, runs inside `DB.with_server`.
50
+
51
+ ## Scopes
52
+
53
+ ```ruby
54
+ repository :user do
55
+ collection ::User do
56
+ scope :active, -> { where(active: true) }
57
+ scope :role, ->(role) { where(role: role) }
58
+ scope :created_after, ->(date) { where(Sequel.lit('created_at > ?', date)) }
59
+ end
60
+
61
+ document { |u, **| { _id: u.id, name: u.name, role: u.role } }
62
+ end
63
+
64
+ UsersIndex.import(context: { active: true, role: 'admin' })
65
+ ```
66
+
67
+ ## Batch context
68
+
69
+ ```ruby
70
+ repository :order do
71
+ collection ::Order do
72
+ batch_context :customers do |orders, **|
73
+ Customer.where(id: orders.map(&:customer_id)).all.index_by(&:id)
74
+ end
75
+ end
76
+
77
+ document do |order, customers: {}, **|
78
+ { _id: order.id, customer_name: customers[order.customer_id]&.name }
79
+ end
80
+ end
81
+ ```
82
+
83
+ ## Eager loading
84
+
85
+ ```ruby
86
+ repository :order do
87
+ collection ::Order.eager(:customer, :line_items)
88
+ document do |order, **|
89
+ { _id: order.id, customer: order.customer.name }
90
+ end
91
+ end
92
+ ```
93
+
94
+ ## Automatic callbacks
95
+
96
+ ```ruby
97
+ class User < Sequel::Model
98
+ include Esse::Sequel::Model
99
+ index_callback 'users_index:user'
100
+ end
101
+ ```
102
+
103
+ Options:
104
+
105
+ ```ruby
106
+ index_callback 'users_index:user',
107
+ on: %i[create update],
108
+ with: :update,
109
+ if: :active?,
110
+ unless: :deleted?
111
+ ```
112
+
113
+ ### Reference format
114
+
115
+ Same as esse-active_record:
116
+
117
+ - `'users'`
118
+ - `'users_index'`
119
+ - `'users_index:user'`
120
+ - `'UsersIndex'`
121
+ - `'UsersIndex::User'`
122
+ - `'foo/v1/users_index:user'` (namespaced)
123
+
124
+ ### Routing changes
125
+
126
+ When the document has a routing key, the update callback deletes the document from its previous routing when the routing value changes. The previous values come from the Sequel `dirty` plugin, so load it in those models:
127
+
128
+ ```ruby
129
+ class City < Sequel::Model
130
+ plugin :dirty
131
+ include Esse::Sequel::Model
132
+
133
+ index_callback 'geos_index:city'
134
+ end
135
+ ```
136
+
137
+ Without the plugin the document is still indexed, but the stale copy under the old routing is not removed.
138
+
139
+ ### Index an associated record
140
+
141
+ ```ruby
142
+ class City < Sequel::Model
143
+ many_to_one :state
144
+ include Esse::Sequel::Model
145
+
146
+ index_callback('geos_index:state') { state }
147
+ end
148
+ ```
149
+
150
+ ### Update a lazy attribute
151
+
152
+ ```ruby
153
+ class Comment < Sequel::Model
154
+ many_to_one :post
155
+ include Esse::Sequel::Model
156
+
157
+ update_lazy_attribute_callback('posts_index:post', 'comments_count') { post_id }
158
+ end
159
+ ```
160
+
161
+ ## Disabling callbacks
162
+
163
+ ### Per-block
164
+
165
+ ```ruby
166
+ Esse::Sequel::Hooks.without_indexing do
167
+ 10_000.times { User.create(...) }
168
+ end
169
+ ```
170
+
171
+ ### Per-repository
172
+
173
+ ```ruby
174
+ Esse::Sequel::Hooks.without_indexing(UsersIndex, AccountsIndex) do
175
+ migrate_users!
176
+ end
177
+ ```
178
+
179
+ ### Per-model
180
+
181
+ ```ruby
182
+ User.without_indexing { User.create(...) }
183
+ User.without_indexing(UsersIndex) { User.create(...) }
184
+ ```
185
+
186
+ ### Globally
187
+
188
+ ```ruby
189
+ Esse::Sequel::Hooks.disable!
190
+ # ... bulk work ...
191
+ Esse::Sequel::Hooks.enable!
192
+ ```
193
+
194
+ ## Streaming by primary key range
195
+
196
+ ```ruby
197
+ UsersIndex.import(context: { start: 1, finish: 5000, batch_size: 500 })
198
+ UsersIndex.import(context: { start: 5001, finish: 10000, batch_size: 500 })
199
+ ```
200
+
201
+ ## Differences from esse-active_record
202
+
203
+ Behaviorally the plugins are identical. The differences:
204
+
205
+ - Uses Sequel datasets (`where`, `eager`, `select`) in lieu of ActiveRecord scopes.
206
+ - Collections accept a model class or a model dataset (`User.where(...)`), not `User.all` (which returns an Array in Sequel).
207
+ - Callbacks run in the `after_create`/`after_update`/`after_destroy` model hooks and are deferred with `DB.after_commit`. Models overriding these hooks must call `super`.
208
+ - `connect_with`/`connected_to` take a Sequel `server:` instead of ActiveRecord `role:`/`shard:`.
209
+ - Routing changes on update require the `dirty` plugin (ActiveRecord tracks previous changes natively).
210
+ - Async callbacks from esse-async_indexing are not available for Sequel models yet.
211
+ - Hook store key differs (`:esse_sequel_hooks` vs `:esse_active_record_hooks`), so you can use both side-by-side in a single process.
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel"
4
+
5
+ module Esse
6
+ module Plugins
7
+ module Sequel
8
+ module RepositoryClassMethods
9
+ # @param [Class, Sequel::Dataset] model_class The Sequel model class or model dataset
10
+ # @param [Hash] options The options
11
+ # @option options [Integer] :batch_size The batch size for the collection
12
+ # @option options [Hash] :connect_with The database server to run the collection queries on. Ex: `{ server: :read_only }`
13
+ def collection(*args, &block)
14
+ unless model_or_dataset?(args.first)
15
+ return super
16
+ end
17
+ kwargs = args.last.is_a?(Hash) ? args.pop.dup : {}
18
+ model_class = args.shift
19
+
20
+ repo = Class.new(Esse::Sequel::Collection)
21
+ repo.base_scope = -> { model_class }
22
+ repo.batch_size = kwargs.delete(:batch_size) if kwargs.key?(:batch_size)
23
+ repo.connect_with = kwargs.delete(:connect_with) if kwargs.key?(:connect_with)
24
+ repo.class_eval(&block) if block
25
+
26
+ super(repo, *args, **kwargs)
27
+ end
28
+ # Sequel models and datasets respond to #to_hash, so a **kwargs signature would make
29
+ # Ruby 2.7 implicitly convert the model argument into keyword arguments.
30
+ ruby2_keywords(:collection) if respond_to?(:ruby2_keywords, true)
31
+
32
+ def dataset(**params)
33
+ if @collection_proc.nil?
34
+ raise NotImplementedError, "Can't call `dataset' on a repository without a collection defined"
35
+ elsif @collection_proc.is_a?(Class) && @collection_proc < Esse::Sequel::Collection
36
+ @collection_proc.new(**params).dataset
37
+ elsif defined? super
38
+ super
39
+ else
40
+ raise NoMethodError, "undefined method `dataset' for #{self}"
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def model_or_dataset?(klass)
47
+ return true if klass.is_a?(Class) && klass < ::Sequel::Model
48
+ return true if klass.is_a?(::Sequel::Dataset) && klass.respond_to?(:model)
49
+
50
+ false
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Esse::Sequel
4
+ module Callbacks
5
+ class IndexingOnCreate < Callback
6
+ def call(model)
7
+ record = block_result || model
8
+ document = repo.serialize(record)
9
+ repo.index.index(document, **options) if document && !document.ignore_on_index?
10
+ true
11
+ end
12
+ end
13
+
14
+ register_callback(:indexing, :create, IndexingOnCreate)
15
+ end
16
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Esse::Sequel
4
+ module Callbacks
5
+ class IndexingOnDestroy < Callback
6
+ def call(model)
7
+ record = block_result || model
8
+ document = repo.serialize(record)
9
+ repo.index.delete(document, **options) if document && !document.ignore_on_delete?
10
+ true
11
+ rescue Esse::Transport::NotFoundError
12
+ true
13
+ end
14
+ end
15
+
16
+ register_callback(:indexing, :destroy, IndexingOnDestroy)
17
+ end
18
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Esse::Sequel
4
+ module Callbacks
5
+ class IndexingOnUpdate < Callback
6
+ def call(model)
7
+ record = block_result || model
8
+
9
+ document = repo.serialize(record)
10
+ return true unless document
11
+
12
+ update_document(document)
13
+ return true unless document.routing
14
+
15
+ prev_document = previous_document(model)
16
+
17
+ return true unless prev_document
18
+ return true if [prev_document.id, prev_document.routing].include?(nil)
19
+ return true if prev_document.routing == document.routing
20
+ return true if prev_document.id != document.id
21
+
22
+ begin
23
+ repo.index.delete(prev_document, **options)
24
+ rescue Esse::Transport::NotFoundError
25
+ end
26
+
27
+ true
28
+ end
29
+
30
+ protected
31
+
32
+ def update_document(document)
33
+ return if document.ignore_on_index?
34
+
35
+ if @with == :update || (@with.nil? && repo.lazy_document_attributes.any?)
36
+ begin
37
+ repo.index.update(document, **options)
38
+ rescue Esse::Transport::NotFoundError
39
+ repo.index.index(document, **options)
40
+ end
41
+ else
42
+ repo.index.index(document, **options)
43
+ end
44
+ end
45
+
46
+ # Previous column values are only known when the model uses the Sequel dirty plugin
47
+ def previous_document(model)
48
+ return unless model.respond_to?(:previous_changes)
49
+
50
+ changes = model.previous_changes
51
+ return if changes.nil? || changes.empty?
52
+
53
+ prev_values = model.values.merge(changes.transform_values(&:first))
54
+ repo.serialize(model.class.call(prev_values).freeze)
55
+ end
56
+ end
57
+
58
+ register_callback(:indexing, :update, IndexingOnUpdate)
59
+ end
60
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Esse::Sequel
4
+ module Callbacks
5
+ class UpdateLazyAttribute < Callback
6
+ attr_reader :attribute_name
7
+
8
+ def initialize(attribute_name:, **kwargs, &block)
9
+ @attribute_name = attribute_name
10
+ super(**kwargs, &block)
11
+ end
12
+
13
+ def call(model)
14
+ related_ids = Esse::ArrayUtils.wrap(block_result || model.id)
15
+ return true if related_ids.empty?
16
+
17
+ repo.update_documents_attribute(attribute_name, related_ids, options)
18
+
19
+ true
20
+ end
21
+ end
22
+
23
+ register_callback(:update_lazy_attribute, :create, UpdateLazyAttribute)
24
+ register_callback(:update_lazy_attribute, :update, UpdateLazyAttribute)
25
+ register_callback(:update_lazy_attribute, :destroy, UpdateLazyAttribute)
26
+ end
27
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Esse
4
+ module Sequel
5
+ class Callback
6
+ attr_reader :repo, :options, :block_result
7
+
8
+ def initialize(repo:, block_result: nil, with: nil, **kwargs)
9
+ @repo = repo
10
+ @with = with
11
+ @options = kwargs
12
+ @block_result = block_result
13
+ end
14
+
15
+ def call(model)
16
+ raise NotImplementedError, "You must implement #call method"
17
+ end
18
+ end
19
+
20
+ module Callbacks
21
+ class << self
22
+ def to_h
23
+ @callbacks || {}.freeze
24
+ end
25
+
26
+ def register_callback(identifier, operation, callback_class)
27
+ unless callback_class < Esse::Sequel::Callback
28
+ raise ArgumentError, "callback_class must be a subclass of Esse::Sequel::Callback"
29
+ end
30
+
31
+ key = [operation, identifier].join("_").to_sym
32
+
33
+ @callbacks = @callbacks ? @callbacks.dup : {}
34
+ if @callbacks.key?(key)
35
+ raise ArgumentError, "callback #{identifier} for #{operation} operation already registered"
36
+ end
37
+
38
+ @callbacks[key] = callback_class
39
+ ensure
40
+ @callbacks&.freeze
41
+ end
42
+
43
+ def registered?(identifier, operation)
44
+ return false unless @callbacks
45
+
46
+ key = [operation, identifier].join("_").to_sym
47
+ @callbacks.key?(key)
48
+ end
49
+
50
+ def fetch!(identifier, operation)
51
+ key = [operation, identifier].join("_").to_sym
52
+ if registered?(identifier, operation)
53
+ [key, @callbacks[key]]
54
+ else
55
+ raise ArgumentError, "callback #{identifier} for #{operation} operation not registered"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ require_relative "callbacks/indexing_on_create"
64
+ require_relative "callbacks/indexing_on_update"
65
+ require_relative "callbacks/indexing_on_destroy"
66
+ require_relative "callbacks/update_lazy_attribute"
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Esse
4
+ module Sequel
5
+ class Collection
6
+ include Enumerable
7
+
8
+ class << self
9
+ # The model class or the dataset to be used as the scope
10
+ # @return [Proc]
11
+ attr_accessor :base_scope
12
+
13
+ # The number of records to be returned in each batch
14
+ # @return [Integer]
15
+ attr_accessor :batch_size
16
+
17
+ # Hash with the custom scopes defined on the collection
18
+ # @return [Hash]
19
+ attr_accessor :scopes
20
+
21
+ # The hash with the contexts as key and the transformer proc as value
22
+ # @return [Hash]
23
+ attr_accessor :batch_contexts
24
+
25
+ # The database server (shard) options for the collection query. Ex: `{ server: :read_only }`
26
+ # @return [Hash, nil]
27
+ attr_accessor :connect_with
28
+
29
+ def inspect
30
+ return super unless self < Esse::Sequel::Collection
31
+ return super unless base_scope
32
+
33
+ format("#<Esse::Sequel::Collection__%s>", model)
34
+ end
35
+
36
+ def model
37
+ raise(NotImplementedError, "No model defined for #{self}") unless base_scope
38
+
39
+ scope = base_scope.call
40
+ scope.is_a?(::Sequel::Dataset) ? scope.model : scope
41
+ end
42
+
43
+ def inherited(subclass)
44
+ super
45
+
46
+ subclass.base_scope = base_scope
47
+ subclass.batch_size = batch_size
48
+ subclass.scopes = scopes.dup
49
+ subclass.batch_contexts = batch_contexts.dup
50
+ subclass.connect_with = connect_with&.dup
51
+ end
52
+
53
+ def scope(name, proc = nil, override: false, &block)
54
+ proc = proc&.to_proc || block
55
+ raise ArgumentError, "proc or block required" unless proc
56
+ raise ArgumentError, "scope `#{name}' already defined" if !override && scopes.key?(name.to_sym)
57
+
58
+ scopes[name.to_sym] = proc
59
+ end
60
+
61
+ def batch_context(name, proc = nil, override: false, &block)
62
+ proc = proc&.to_proc || block
63
+ raise ArgumentError, "proc or block required" unless proc
64
+ raise ArgumentError, "batch_context `#{name}' already defined" if !override && batch_contexts.key?(name.to_sym)
65
+
66
+ batch_contexts[name.to_sym] = proc
67
+ end
68
+
69
+ # Runs the collection queries on the given database server (shard)
70
+ # @param [Symbol] server The server name as configured in the Sequel database :servers option
71
+ def connected_to(server:)
72
+ self.connect_with = {server: server}
73
+ end
74
+ end
75
+
76
+ self.scopes = {}
77
+ self.batch_contexts = {}
78
+
79
+ attr_reader :start, :finish, :batch_size, :params
80
+
81
+ # @param [Integer] start Specifies the primary key value to start from, inclusive of the value.
82
+ # @param [Integer] finish Specifies the primary key value to end at, inclusive of the value.
83
+ # @param [Integer] batch_size The number of records to be returned in each batch. Defaults to 1000.
84
+ # @param [Hash] params The query criteria
85
+ # @return [Esse::Sequel::Collection]
86
+ def initialize(start: nil, finish: nil, batch_size: nil, **params)
87
+ @start = start
88
+ @finish = finish
89
+ @batch_size = batch_size || self.class.batch_size || 1000
90
+ @params = params
91
+ end
92
+
93
+ def each
94
+ with_connection do
95
+ find_in_batches(dataset) do |rows|
96
+ kwargs = params.dup
97
+ self.class.batch_contexts.each do |name, proc|
98
+ kwargs[name] = proc.call(rows, **params)
99
+ end
100
+ yield(rows, **kwargs)
101
+ end
102
+ end
103
+ end
104
+
105
+ def each_batch_ids
106
+ with_connection do
107
+ find_in_batches(dataset, ids_only: true) do |ids|
108
+ yield(ids)
109
+ end
110
+ end
111
+ end
112
+
113
+ def count
114
+ with_connection do
115
+ dataset.unordered.unlimited.ungrouped.count
116
+ end
117
+ end
118
+ alias_method :size, :count
119
+
120
+ def dataset(**kwargs)
121
+ query = self.class.base_scope&.call || raise(NotImplementedError, "No scope defined for #{self.class}")
122
+ query = query.dataset unless query.is_a?(::Sequel::Dataset)
123
+ query = query.unordered.unlimited
124
+ query = query.server(server) if server
125
+ params.merge(kwargs).each do |key, value|
126
+ if self.class.scopes.key?(key)
127
+ scope_proc = self.class.scopes[key]
128
+ query = if scope_proc.arity == 0
129
+ query.instance_exec(&scope_proc)
130
+ else
131
+ query.instance_exec(value, &scope_proc)
132
+ end
133
+ elsif query.model.columns.include?(key.to_sym)
134
+ query = query.where(::Sequel[query.model.table_name][key.to_sym] => value)
135
+ else
136
+ raise ArgumentError, "Unknown scope `#{key}'"
137
+ end
138
+ end
139
+
140
+ query
141
+ end
142
+
143
+ def inspect
144
+ return super unless self.class < Esse::Sequel::Collection
145
+ return super unless self.class.base_scope
146
+
147
+ vars = instance_variables.map do |n|
148
+ "#{n}=#{instance_variable_get(n).inspect}"
149
+ end
150
+ format("#<Esse::Sequel::Collection__%s:0x%x %s>", self.class.model, object_id, vars.join(", "))
151
+ end
152
+
153
+ protected
154
+
155
+ def server
156
+ self.class.connect_with&.fetch(:server, nil)
157
+ end
158
+
159
+ # Wraps the block with Database#with_server when the server_block extension is loaded,
160
+ # so queries made by batch contexts also run on the configured server.
161
+ def with_connection(&block)
162
+ db = server && self.class.model.db
163
+ if db.respond_to?(:with_server)
164
+ db.with_server(server, &block)
165
+ else
166
+ yield
167
+ end
168
+ end
169
+
170
+ # Keyset pagination over the primary key, inclusive of start and finish values.
171
+ def find_in_batches(query, ids_only: false)
172
+ pk = ::Sequel[query.model.table_name][query.model.primary_key]
173
+ query = query.where(pk >= start) if start
174
+ query = query.where(pk <= finish) if finish
175
+ query = query.order(pk).limit(batch_size)
176
+
177
+ last_id = nil
178
+ loop do
179
+ page = last_id.nil? ? query : query.where(pk > last_id)
180
+ rows = ids_only ? page.select_map(pk) : page.all
181
+ break if rows.empty?
182
+
183
+ yield rows
184
+ break if rows.size < batch_size
185
+
186
+ last_id = ids_only ? rows.last : rows.last.pk
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Esse
4
+ module Sequel
5
+ module Hooks
6
+ STORE_STATE_KEY = :esse_sequel_hooks
7
+
8
+ include Esse::Hooks[store_key: STORE_STATE_KEY]
9
+ end
10
+ end
11
+ end