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.
- checksums.yaml +7 -0
- data/.rubocop.yml +37 -0
- data/CHANGELOG.md +9 -0
- data/Gemfile +14 -0
- data/LICENSE.txt +21 -0
- data/README.md +265 -0
- data/Rakefile +8 -0
- data/docs/README.md +55 -0
- data/docs/api.md +119 -0
- data/docs/usage.md +211 -0
- data/lib/esse/plugins/sequel.rb +55 -0
- data/lib/esse/sequel/callbacks/indexing_on_create.rb +16 -0
- data/lib/esse/sequel/callbacks/indexing_on_destroy.rb +18 -0
- data/lib/esse/sequel/callbacks/indexing_on_update.rb +60 -0
- data/lib/esse/sequel/callbacks/update_lazy_attribute.rb +27 -0
- data/lib/esse/sequel/callbacks.rb +66 -0
- data/lib/esse/sequel/collection.rb +191 -0
- data/lib/esse/sequel/hooks.rb +11 -0
- data/lib/esse/sequel/model.rb +138 -0
- data/lib/esse/sequel/version.rb +7 -0
- data/lib/esse/sequel.rb +16 -0
- data/mise.toml +2 -0
- metadata +232 -0
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
|