occams-record 1.3.0 → 1.4.0.pre.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95a5656a0aa870ae2458e549442d68f8f685388d2f561321daae428509bdac6e
4
- data.tar.gz: 9b5c0edddab969fd9ba3140e028dd4d36d4408fe079c5e8987be8b35a3263d4e
3
+ metadata.gz: 662bd65e909a1d957ebdbbd2d3bec3fe96fb45aac2117a786c9a3ad02b03259b
4
+ data.tar.gz: abeb69ff706dbb11f3fae3672ae60c407080f418fce7330779a7b550915aa886
5
5
  SHA512:
6
- metadata.gz: 1fb09da919637085f3673dffa20416dbbcfcd5f03d0b9d4efbcc074f26a076b44a286a10da6f311822dda843b4480f631dc93d8f6e7cfe9818998e933bd0c099
7
- data.tar.gz: 9f3dc78c47a39f396ee393e8c9a472dedd3e436db00cafd6e7773f8137e143fb5c5b86bffa7db204b63d170567285c5baf04928b4a4405aead081115c5b77bf1
6
+ metadata.gz: 8981d9c820b64ebeca28dcbe3ab7ac44e35d8ec7e14df201e890ccf6cf1cf1888d6f78ba6cac001f1a9c458f604189b1aafaf7ff644288499faf5d5afeb896c3
7
+ data.tar.gz: cdd61c50313a964db4ebdb271dbb875fc4ea888dd0b57df78be78d043e655e04e821e0cef8d9b60034e90866289a0d764f72a7728a18f46c778e511101b72f4a
data/README.md CHANGED
@@ -300,6 +300,9 @@ bundle exec rake test
300
300
 
301
301
  # test against Postgres
302
302
  TEST_DATABASE_URL=postgres://postgres@localhost:5432/occams_record bundle exec rake test
303
+
304
+ # test against MySQL
305
+ TEST_DATABASE_URL=mysql2://root:@127.0.0.1:3306/occams_record bundle exec rake test
303
306
  ```
304
307
 
305
308
  **Test against all supported ActiveRecord versions**
@@ -0,0 +1,59 @@
1
+ module OccamsRecord
2
+ module Batches
3
+ module CursorHelpers
4
+ #
5
+ # Loads records in batches of N and yields each record to a block (if given). If no block is given,
6
+ # returns an Enumerator.
7
+ #
8
+ # NOTE Unlike find_each, batches are loaded using a cursor, which offers better performance.
9
+ # Postgres only. See the docs for OccamsRecord::Cursor for more details.
10
+ #
11
+ # @param batch_size [Integer] fetch this many rows at once
12
+ # @param use_transaction [Boolean] Ensure it runs inside of a database transaction
13
+ # @yield [OccamsRecord::Results::Row]
14
+ # @return [Enumerator] will yield each record
15
+ #
16
+ def find_each_with_cursor(batch_size: 1000, use_transaction: true)
17
+ enum = Enumerator.new { |y|
18
+ cursor.open(use_transaction: use_transaction) { |c|
19
+ c.each(batch_size: batch_size) { |record|
20
+ y.yield record
21
+ }
22
+ }
23
+ }
24
+ if block_given?
25
+ enum.each { |record| yield record }
26
+ else
27
+ enum
28
+ end
29
+ end
30
+
31
+ #
32
+ # Loads records in batches of N and yields each batch to a block (if given). If no block is given,
33
+ # returns an Enumerator.
34
+ #
35
+ # NOTE Unlike find_in_batches, batches are loaded using a cursor, which offers better performance.
36
+ # Postgres only. See the docs for OccamsRecord::Cursor for more details.
37
+ #
38
+ # @param batch_size [Integer] fetch this many rows at once
39
+ # @param use_transaction [Boolean] Ensure it runs inside of a database transaction
40
+ # @yield [OccamsRecord::Results::Row]
41
+ # @return [Enumerator] will yield each record
42
+ #
43
+ def find_in_batches_with_cursor(batch_size: 1000, use_transaction: true)
44
+ enum = Enumerator.new { |y|
45
+ cursor.open(use_transaction: use_transaction) { |c|
46
+ c.each_batch(batch_size: batch_size) { |batch|
47
+ y.yield batch
48
+ }
49
+ }
50
+ }
51
+ if block_given?
52
+ enum.each { |batch| yield batch }
53
+ else
54
+ enum
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,53 @@
1
+ module OccamsRecord
2
+ module Batches
3
+ module OffsetLimit
4
+ class RawQuery
5
+ def initialize(conn, sql, binds, use: nil, query_logger: nil, eager_loaders: nil)
6
+ @conn, @sql, @binds = conn, sql, binds
7
+ @use, @query_logger, @eager_loaders = use, query_logger, eager_loaders
8
+
9
+ unless @sql =~ /LIMIT\s+%\{batch_limit\}/i and @sql =~ /OFFSET\s+%\{batch_offset\}/i
10
+ raise ArgumentError, "When using find_each/find_in_batches you must specify 'LIMIT %{batch_limit} OFFSET %{batch_offset}'. SQL statement: #{@sql}"
11
+ end
12
+ end
13
+
14
+ #
15
+ # Returns an Enumerator that yields batches of records, of size "of".
16
+ # The SQL string must include 'LIMIT %{batch_limit} OFFSET %{batch_offset}'.
17
+ # The bind values will be provided by OccamsRecord.
18
+ #
19
+ # @param batch_size [Integer] batch size
20
+ # @param use_transaction [Boolean] Ensure it runs inside of a database transaction
21
+ # @return [Enumerator] yields batches
22
+ #
23
+ def enum(batch_size:, use_transaction: true)
24
+ Enumerator.new do |y|
25
+ if use_transaction and @conn.open_transactions == 0
26
+ @conn.transaction {
27
+ run_batches y, batch_size
28
+ }
29
+ else
30
+ run_batches y, batch_size
31
+ end
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def run_batches(y, of)
38
+ offset = 0
39
+ loop do
40
+ results = ::OccamsRecord::RawQuery.new(@sql, @binds.merge({
41
+ batch_limit: of,
42
+ batch_offset: offset,
43
+ }), use: @use, query_logger: @query_logger, eager_loaders: @eager_loaders).run
44
+
45
+ y.yield results if results.any?
46
+ break if results.size < of
47
+ offset += results.size
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,72 @@
1
+ module OccamsRecord
2
+ #
3
+ # Methods for building batch finding methods. It expects "model" and "scope" methods to be present.
4
+ #
5
+ module Batches
6
+ module OffsetLimit
7
+ class Scoped
8
+ def initialize(model, scope, use: nil, query_logger: nil, eager_loaders: nil)
9
+ @model, @scope = model, scope
10
+ @use, @query_logger, @eager_loaders = use, query_logger, eager_loaders
11
+ end
12
+
13
+ #
14
+ # Returns an Enumerator that yields batches of records, of size "of".
15
+ # NOTE ActiveRecord 5+ provides the 'in_batches' method to do something
16
+ # similiar, although 4.2 does not. Also it does not respect ORDER BY,
17
+ # whereas this does.
18
+ #
19
+ # @param batch_size [Integer] batch size
20
+ # @param use_transaction [Boolean] Ensure it runs inside of a database transaction
21
+ # @param append_order_by [String] Append this column to ORDER BY to ensure consistent results. Defaults to the primary key. Pass false to disable.
22
+ # @return [Enumerator] yields batches
23
+ #
24
+ def enum(batch_size:, use_transaction: true, append_order_by: nil)
25
+ append_order =
26
+ case append_order_by
27
+ when false then nil
28
+ when nil then @model.primary_key
29
+ else append_order_by
30
+ end
31
+
32
+ Enumerator.new do |y|
33
+ if use_transaction and @model.connection.open_transactions == 0
34
+ @model.connection.transaction {
35
+ run_batches y, batch_size, append_order
36
+ }
37
+ else
38
+ run_batches y, batch_size, append_order
39
+ end
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def run_batches(y, of, append_order_by = nil)
46
+ limit = @scope.limit_value
47
+ batch_size = limit && limit < of ? limit : of
48
+
49
+ offset = @scope.offset_value || 0
50
+ out_of_records, count = false, 0
51
+ order_by =
52
+ if append_order_by
53
+ append_order_by.to_s == @model.primary_key.to_s ? append_order_by.to_sym : append_order_by
54
+ end
55
+
56
+ until out_of_records
57
+ l = limit && batch_size > limit - count ? limit - count : batch_size
58
+ q = @scope
59
+ q = q.order(order_by) if order_by
60
+ q = q.offset(offset).limit(l)
61
+ results = ::OccamsRecord::Query.new(q, use: @use, query_logger: @query_logger, eager_loaders: @eager_loaders).run
62
+
63
+ y.yield results if results.any?
64
+ count += results.size
65
+ offset += results.size
66
+ out_of_records = results.size < batch_size || (limit && count >= limit)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,237 @@
1
+ require 'securerandom'
2
+
3
+ module OccamsRecord
4
+ #
5
+ # An interface to database cursors. Supported databases:
6
+ # * PostgreSQL
7
+ #
8
+ class Cursor
9
+ # @private
10
+ SCROLL = {
11
+ true => "SCROLL",
12
+ false => "NO SCROLL",
13
+ nil => "",
14
+ }.freeze
15
+
16
+ # @private
17
+ HOLD = {
18
+ true => "WITH HOLD",
19
+ false => "WITHOUT HOLD",
20
+ nil => "",
21
+ }.freeze
22
+
23
+ # @private
24
+ DIRECTIONS = {
25
+ next: "NEXT",
26
+ prior: "PRIOR",
27
+ first: "FIRST",
28
+ last: "LAST",
29
+ absolute: "ABSOLUTE",
30
+ relative: "RELATIVE",
31
+ forward: "FORWARD",
32
+ backward: "BACKWARD",
33
+ }.freeze
34
+
35
+ # @return [ActiveRecord::Connection]
36
+ attr_reader :conn
37
+
38
+ # Name of the cursor
39
+ # @return [String]
40
+ attr_reader :name
41
+
42
+ # Name of the cursor (safely SQL-escaped)
43
+ # @return [String]
44
+ attr_reader :quoted_name
45
+
46
+ #
47
+ # Initializes a new Cursor. NOTE all operations must be performed within a block passed to #open.
48
+ #
49
+ # While you CAN manually initialize a cursor, it's more common to get one via OccamsRecord::Query#cursor
50
+ # or OccamsRecord::RawQuery#cursor.
51
+ #
52
+ # @param conn [ActiveRecord::Connection]
53
+ # @param sql [String] The query to run
54
+ # @param name [String] Specify a name for the cursor (defaults to a random name)
55
+ # @param scroll [Boolean] true = SCROLL, false = NO SCROLL, nil = default behavior of DB
56
+ # @param hold [Boolean] true = WITH HOLD, false = WITHOUT HOLD, nil = default behavior of DB
57
+ # @param use [Array<Module>] optional Module to include in the result class (single or array)
58
+ # @param query_logger [Array] (optional) an array into which all queries will be inserted for logging/debug purposes
59
+ # @param eager_loaders [OccamsRecord::EagerLoaders::Context]
60
+ #
61
+ def initialize(conn, sql, name: nil, scroll: nil, hold: nil, use: nil, query_logger: nil, eager_loaders: nil)
62
+ @conn, @sql = conn, sql
63
+ @scroll = SCROLL.fetch(scroll)
64
+ @hold = HOLD.fetch(hold)
65
+ @use, @query_logger, @eager_loaders = use, query_logger, eager_loaders
66
+ @name = name || "occams_cursor_#{SecureRandom.hex 4}"
67
+ @quoted_name = conn.quote_table_name(@name)
68
+ end
69
+
70
+ #
71
+ # Declares and opens the cursor, runs the given block (yielding self), and closes the cursor.
72
+ #
73
+ # cursor.open do |c|
74
+ # c.fetch :forward, 100
75
+ # end
76
+ #
77
+ # @param use_transaction [Boolean] When true, ensures it's wrapped in a transaction
78
+ # @yield [self]
79
+ # @return the value returned by the block
80
+ #
81
+ def open(use_transaction: true)
82
+ raise ArgumentError, "A block is required" unless block_given?
83
+ if use_transaction and conn.open_transactions == 0
84
+ conn.transaction {
85
+ perform { yield self }
86
+ }
87
+ else
88
+ perform { yield self }
89
+ end
90
+ end
91
+
92
+ #
93
+ # Loads records in batches of N and yields each record to a block (if given). If no block is given,
94
+ # returns an Enumerator.
95
+ #
96
+ # cursor.open do |c|
97
+ # c.each do |record|
98
+ # ...
99
+ # end
100
+ # end
101
+ #
102
+ # @param batch_size [Integer] fetch this many rows at once
103
+ #
104
+ def each(batch_size: 1000)
105
+ enum = Enumerator.new { |y|
106
+ each_batch(batch_size: batch_size).each { |batch|
107
+ batch.each { |record| y.yield record }
108
+ }
109
+ }
110
+ if block_given?
111
+ enum.each { |record| yield record }
112
+ else
113
+ enum
114
+ end
115
+ end
116
+
117
+ #
118
+ # Loads records in batches of N and yields each batch to a block (if given). If no block is given,
119
+ # returns an Enumerator.
120
+ #
121
+ # cursor.open do |c|
122
+ # c.each_batch do |batch|
123
+ # ...
124
+ # end
125
+ # end
126
+ #
127
+ # @param batch_size [Integer] fetch this many rows at once
128
+ #
129
+ def each_batch(batch_size: 1000)
130
+ enum = Enumerator.new { |y|
131
+ out_of_records = false
132
+ until out_of_records
133
+ results = fetch :forward, batch_size
134
+ y.yield results if results.any?
135
+ out_of_records = results.size < batch_size
136
+ end
137
+ }
138
+ if block_given?
139
+ enum.each { |batch| yield batch }
140
+ else
141
+ enum
142
+ end
143
+ end
144
+
145
+ #
146
+ # Fetch records in the given direction.
147
+ #
148
+ # cursor.open do |c|
149
+ # c.fetch :forward, 100
150
+ # ...
151
+ # end
152
+ #
153
+ # @param direction [Symbol] :next, :prior, :first, :last, :absolute, :relative, :forward or :backward
154
+ # @param num [Integer] number of rows to fetch (optional for some directions)
155
+ # @return [OccamsRecord::Results::Row]
156
+ #
157
+ def fetch(direction, num = nil)
158
+ query "FETCH %{dir} %{num} FROM %{name}".freeze % {
159
+ dir: DIRECTIONS.fetch(direction),
160
+ num: num&.to_i,
161
+ name: @quoted_name,
162
+ }
163
+ end
164
+
165
+ #
166
+ # Move the cursor the given direction.
167
+ #
168
+ # cursor.open do |c|
169
+ # ...
170
+ # c.move :backward, 100
171
+ # ...
172
+ # end
173
+ #
174
+ # @param direction [Symbol] :next, :prior, :first, :last, :absolute, :relative, :forward or :backward
175
+ # @param num [Integer] number of rows to move (optional for some directions)
176
+ #
177
+ def move(direction, num = nil)
178
+ query "MOVE %{dir} %{num} FROM %{name}".freeze % {
179
+ dir: DIRECTIONS.fetch(direction),
180
+ num: num&.to_i,
181
+ name: @quoted_name,
182
+ }
183
+ end
184
+
185
+ #
186
+ # Run an arbitrary query on the cursor. Use 'binds' to escape inputs.
187
+ #
188
+ # cursor.open do |c|
189
+ # c.query("FETCH FORWARD %{num} FOR #{c.quoted_name}", {num: 100})
190
+ # ...
191
+ # end
192
+ #
193
+ def query(sql, binds = {})
194
+ ::OccamsRecord::RawQuery.new(sql, binds, use: @use, query_logger: @query_logger, eager_loaders: @eager_loaders, connection: conn).run
195
+ end
196
+
197
+ #
198
+ # Run an arbitrary command on the cursor. Use 'binds' to escape inputs.
199
+ #
200
+ # cursor.open do |c|
201
+ # c.execute("MOVE FORWARD %{num} FOR #{c.quoted_name}", {num: 100})
202
+ # ...
203
+ # end
204
+ #
205
+ def execute(sql, binds = {})
206
+ conn.execute(sql % binds.reduce({}) { |acc, (key, val)|
207
+ acc[key] = conn.quote(val)
208
+ acc
209
+ })
210
+ end
211
+
212
+ private
213
+
214
+ def perform
215
+ ex = nil
216
+ conn.execute "DECLARE %{name} %{scroll} CURSOR %{hold} FOR %{query}".freeze % {
217
+ name: @quoted_name,
218
+ scroll: @scroll,
219
+ hold: @hold,
220
+ query: @sql,
221
+ }
222
+ yield
223
+ rescue => e
224
+ ex = e
225
+ raise ex
226
+ ensure
227
+ begin
228
+ conn.execute "CLOSE %{name}".freeze % {name: @quoted_name}
229
+ rescue => e
230
+ # Don't let an error from CLOSE (like a dead transaction) hide what lead to the error with CLOSE (like bad SQL that raised an error and aborted the transaction)
231
+ raise ex || e
232
+ else
233
+ raise ex if ex
234
+ end
235
+ end
236
+ end
237
+ end
@@ -1,5 +1,3 @@
1
- require 'occams-record/batches'
2
-
3
1
  module OccamsRecord
4
2
  #
5
3
  # Starts building a OccamsRecord::Query. Pass it a scope from any of ActiveRecord's query builder
@@ -36,7 +34,7 @@ module OccamsRecord
36
34
  # @return [ActiveRecord::Relation] scope for building the main SQL query
37
35
  attr_reader :scope
38
36
 
39
- include Batches
37
+ include OccamsRecord::Batches::CursorHelpers
40
38
  include EagerLoaders::Builder
41
39
  include Enumerable
42
40
  include Measureable
@@ -154,5 +152,75 @@ module OccamsRecord
154
152
  to_a.each
155
153
  end
156
154
  end
155
+
156
+ #
157
+ # Load records in batches of N and yield each record to a block if given. If no block is given,
158
+ # returns an Enumerator.
159
+ #
160
+ # NOTE Unlike ActiveRecord's find_each, ORDER BY is respected. The primary key will be appended
161
+ # to the ORDER BY clause to help ensure consistent batches. Additionally, it will be run inside
162
+ # of a transaction.
163
+ #
164
+ # @param batch_size [Integer]
165
+ # @param use_transaction [Boolean] Ensure it runs inside of a database transaction
166
+ # @param append_order_by [String] Append this column to ORDER BY to ensure consistent results. Defaults to the primary key. Pass false to disable.
167
+ # @yield [OccamsRecord::Results::Row]
168
+ # @return [Enumerator] will yield each record
169
+ #
170
+ def find_each(batch_size: 1000, use_transaction: true, append_order_by: nil)
171
+ enum = Enumerator.new { |y|
172
+ find_in_batches(batch_size: 1000, use_transaction: use_transaction, append_order_by: append_order_by).each { |batch|
173
+ batch.each { |record| y.yield record }
174
+ }
175
+ }
176
+ if block_given?
177
+ enum.each { |record| yield record }
178
+ else
179
+ enum
180
+ end
181
+ end
182
+
183
+ #
184
+ # Load records in batches of N and yield each batch to a block if given.
185
+ # If no block is given, returns an Enumerator.
186
+ #
187
+ # NOTE Unlike ActiveRecord's find_each, ORDER BY is respected. The primary key will be appended
188
+ # to the ORDER BY clause to help ensure consistent batches. Additionally, it will be run inside
189
+ # of a transaction.
190
+ #
191
+ # @param batch_size [Integer]
192
+ # @param use_transaction [Boolean] Ensure it runs inside of a database transaction
193
+ # @param append_order_by [String] Append this column to ORDER BY to ensure consistent results. Defaults to the primary key. Pass false to disable.
194
+ # @yield [OccamsRecord::Results::Row]
195
+ # @return [Enumerator] will yield each batch
196
+ #
197
+ def find_in_batches(batch_size: 1000, use_transaction: true, append_order_by: nil)
198
+ enum = Batches::OffsetLimit::Scoped
199
+ .new(model, scope, use: @use, query_logger: @query_logger, eager_loaders: @eager_loaders)
200
+ .enum(batch_size: batch_size, use_transaction: use_transaction, append_order_by: append_order_by)
201
+ if block_given?
202
+ enum.each { |batch| yield batch }
203
+ else
204
+ enum
205
+ end
206
+ end
207
+
208
+ #
209
+ # Returns a cursor you can open and perform operations on. A lower-level alternative to
210
+ # find_each_with_cursor and find_in_batches_with_cursor.
211
+ #
212
+ # NOTE Postgres only. See the docs for OccamsRecord::Cursor for more details.
213
+ #
214
+ # @param name [String] Specify a name for the cursor (defaults to a random name)
215
+ # @param scroll [Boolean] true = SCROLL, false = NO SCROLL, nil = default behavior of DB
216
+ # @param hold [Boolean] true = WITH HOLD, false = WITHOUT HOLD, nil = default behavior of DB
217
+ # @return [OccamsRecord::Cursor]
218
+ #
219
+ def cursor(name: nil, scroll: nil, hold: nil)
220
+ Cursor.new(model.connection, scope.to_sql,
221
+ name: name, scroll: scroll, hold: hold,
222
+ use: @use, query_logger: @query_logger, eager_loaders: @eager_loaders,
223
+ )
224
+ end
157
225
  end
158
226
  end
@@ -61,7 +61,7 @@ module OccamsRecord
61
61
  # @return [Hash]
62
62
  attr_reader :binds
63
63
 
64
- include Batches
64
+ include OccamsRecord::Batches::CursorHelpers
65
65
  include EagerLoaders::Builder
66
66
  include Enumerable
67
67
  include Measureable
@@ -75,13 +75,15 @@ module OccamsRecord
75
75
  # @param eager_loaders [OccamsRecord::EagerLoaders::Context]
76
76
  # @param query_logger [Array] (optional) an array into which all queries will be inserted for logging/debug purposes
77
77
  # @param measurements [Array]
78
+ # @param connection
78
79
  #
79
- def initialize(sql, binds, use: nil, eager_loaders: nil, query_logger: nil, measurements: nil)
80
+ def initialize(sql, binds, use: nil, eager_loaders: nil, query_logger: nil, measurements: nil, connection: nil)
80
81
  @sql = sql
81
82
  @binds = binds
82
83
  @use = use
83
84
  @eager_loaders = eager_loaders || EagerLoaders::Context.new
84
85
  @query_logger, @measurements = query_logger, measurements
86
+ @conn = connection
85
87
  end
86
88
 
87
89
  #
@@ -139,6 +141,74 @@ module OccamsRecord
139
141
  end
140
142
  end
141
143
 
144
+ #
145
+ # Load records in batches of N and yield each record to a block if given. If no block is given,
146
+ # returns an Enumerator.
147
+ #
148
+ # NOTE Unlike ActiveRecord's find_each, ORDER BY is respected. The primary key will be appended
149
+ # to the ORDER BY clause to help ensure consistent batches. Additionally, it will be run inside
150
+ # of a transaction.
151
+ #
152
+ # @param batch_size [Integer]
153
+ # @param use_transaction [Boolean] Ensure it runs inside of a database transaction
154
+ # @yield [OccamsRecord::Results::Row]
155
+ # @return [Enumerator] will yield each record
156
+ #
157
+ def find_each(batch_size: 1000, use_transaction: true)
158
+ enum = Enumerator.new { |y|
159
+ find_in_batches(batch_size: batch_size, use_transaction: use_transaction).each { |batch|
160
+ batch.each { |record| y.yield record }
161
+ }
162
+ }
163
+ if block_given?
164
+ enum.each { |record| yield record }
165
+ else
166
+ enum
167
+ end
168
+ end
169
+
170
+ #
171
+ # Load records in batches of N and yield each batch to a block if given.
172
+ # If no block is given, returns an Enumerator.
173
+ #
174
+ # NOTE Unlike ActiveRecord's find_each, ORDER BY is respected. The primary key will be appended
175
+ # to the ORDER BY clause to help ensure consistent batches. Additionally, it will be run inside
176
+ # of a transaction.
177
+ #
178
+ # @param batch_size [Integer]
179
+ # @param use_transaction [Boolean] Ensure it runs inside of a database transaction
180
+ # @yield [OccamsRecord::Results::Row]
181
+ # @return [Enumerator] will yield each batch
182
+ #
183
+ def find_in_batches(batch_size: 1000, use_transaction: true)
184
+ enum = Batches::OffsetLimit::RawQuery
185
+ .new(conn, @sql, @binds, use: @use, query_logger: @query_logger, eager_loaders: @eager_loaders)
186
+ .enum(batch_size: batch_size, use_transaction: use_transaction)
187
+ if block_given?
188
+ enum.each { |batch| yield batch }
189
+ else
190
+ enum
191
+ end
192
+ end
193
+
194
+ #
195
+ # Returns a cursor you can open and perform operations on. A lower-level alternative to
196
+ # find_each_with_cursor and find_in_batches_with_cursor.
197
+ #
198
+ # NOTE Postgres only. See the docs for OccamsRecord::Cursor for more details.
199
+ #
200
+ # @param name [String] Specify a name for the cursor (defaults to a random name)
201
+ # @param scroll [Boolean] true = SCROLL, false = NO SCROLL, nil = default behavior of DB
202
+ # @param hold [Boolean] true = WITH HOLD, false = WITHOUT HOLD, nil = default behavior of DB
203
+ # @return [OccamsRecord::Cursor]
204
+ #
205
+ def cursor(name: nil, scroll: nil, hold: nil)
206
+ Cursor.new(conn, @sql,
207
+ name: name, scroll: scroll, hold: hold,
208
+ use: @use, query_logger: @query_logger, eager_loaders: @eager_loaders,
209
+ )
210
+ end
211
+
142
212
  private
143
213
 
144
214
  # Returns the SQL as a String with all variables escaped
@@ -158,45 +228,6 @@ module OccamsRecord
158
228
  @sql.match(/\s+FROM\s+"?(\w+)"?/i)&.captures&.first
159
229
  end
160
230
 
161
- #
162
- # Returns an Enumerator that yields batches of records, of size "of".
163
- # The SQL string must include 'LIMIT %{batch_limit} OFFSET %{batch_offset}'.
164
- # The bind values will be provided by OccamsRecord.
165
- #
166
- # @param of [Integer] batch size
167
- # @param use_transaction [Boolean] Ensure it runs inside of a database transaction
168
- # @return [Enumerator] yields batches
169
- #
170
- def batches(of:, use_transaction: true, append_order_by: nil)
171
- unless @sql =~ /LIMIT\s+%\{batch_limit\}/i and @sql =~ /OFFSET\s+%\{batch_offset\}/i
172
- raise ArgumentError, "When using find_each/find_in_batches you must specify 'LIMIT %{batch_limit} OFFSET %{batch_offset}'. SQL statement: #{@sql}"
173
- end
174
-
175
- Enumerator.new do |y|
176
- if use_transaction and conn.open_transactions == 0
177
- conn.transaction {
178
- run_batches y, of
179
- }
180
- else
181
- run_batches y, of
182
- end
183
- end
184
- end
185
-
186
- def run_batches(y, of)
187
- offset = 0
188
- loop do
189
- results = RawQuery.new(@sql, @binds.merge({
190
- batch_limit: of,
191
- batch_offset: offset,
192
- }), use: @use, query_logger: @query_logger, eager_loaders: @eager_loaders).run
193
-
194
- y.yield results if results.any?
195
- break if results.size < of
196
- offset += results.size
197
- end
198
- end
199
-
200
231
  def conn
201
232
  @conn ||= @eager_loaders.model&.connection || ActiveRecord::Base.connection
202
233
  end
@@ -2,6 +2,6 @@
2
2
  # Main entry point for using OccamsRecord.
3
3
  #
4
4
  module OccamsRecord
5
- # Library version
6
- VERSION = "1.3.0".freeze
5
+ # @private
6
+ VERSION = "1.4.0-beta1".freeze
7
7
  end
data/lib/occams-record.rb CHANGED
@@ -5,9 +5,15 @@ require 'occams-record/measureable'
5
5
  require 'occams-record/eager_loaders/eager_loaders'
6
6
  require 'occams-record/results/results'
7
7
  require 'occams-record/results/row'
8
+ require 'occams-record/cursor'
9
+ require 'occams-record/errors'
10
+
11
+ require 'occams-record/batches/offset_limit/scoped'
12
+ require 'occams-record/batches/offset_limit/raw_query'
13
+ require 'occams-record/batches/cursor_helpers'
14
+
8
15
  require 'occams-record/query'
9
16
  require 'occams-record/raw_query'
10
- require 'occams-record/errors'
11
17
 
12
18
  module OccamsRecord
13
19
  autoload :Ugly, 'occams-record/ugly'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: occams-record
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0.pre.beta1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Hollinger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-01-30 00:00:00.000000000 Z
11
+ date: 2022-05-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -44,8 +44,8 @@ dependencies:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
46
  version: '0'
47
- description: A faster, lower-memory querying API for ActiveRecord that returns results
48
- as unadorned, read-only objects.
47
+ description: A faster, lower-memory, fuller-featured querying API for ActiveRecord
48
+ that returns results as unadorned, read-only objects.
49
49
  email: jordan.hollinger@gmail.com
50
50
  executables: []
51
51
  extensions: []
@@ -53,7 +53,10 @@ extra_rdoc_files: []
53
53
  files:
54
54
  - README.md
55
55
  - lib/occams-record.rb
56
- - lib/occams-record/batches.rb
56
+ - lib/occams-record/batches/cursor_helpers.rb
57
+ - lib/occams-record/batches/offset_limit/raw_query.rb
58
+ - lib/occams-record/batches/offset_limit/scoped.rb
59
+ - lib/occams-record/cursor.rb
57
60
  - lib/occams-record/eager_loaders/ad_hoc_base.rb
58
61
  - lib/occams-record/eager_loaders/ad_hoc_many.rb
59
62
  - lib/occams-record/eager_loaders/ad_hoc_one.rb
@@ -91,9 +94,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
91
94
  version: 2.3.0
92
95
  required_rubygems_version: !ruby/object:Gem::Requirement
93
96
  requirements:
94
- - - ">="
97
+ - - ">"
95
98
  - !ruby/object:Gem::Version
96
- version: '0'
99
+ version: 1.3.1
97
100
  requirements: []
98
101
  rubygems_version: 3.1.6
99
102
  signing_key:
@@ -1,113 +0,0 @@
1
- module OccamsRecord
2
- #
3
- # Methods for building batch finding methods. It expects "model" and "scope" methods to be present.
4
- #
5
- module Batches
6
- #
7
- # Load records in batches of N and yield each record to a block if given. If no block is given,
8
- # returns an Enumerator.
9
- #
10
- # NOTE Unlike ActiveRecord's find_each, ORDER BY is respected. The primary key will be appended
11
- # to the ORDER BY clause to help ensure consistent batches. Additionally, it will be run inside
12
- # of a transaction.
13
- #
14
- # @param batch_size [Integer]
15
- # @param use_transaction [Boolean] Ensure it runs inside of a database transaction
16
- # @param append_order_by [String] Append this column to ORDER BY to ensure consistent results. Defaults to the primary key. Pass false to disable.
17
- # @yield [OccamsRecord::Results::Row]
18
- # @return [Enumerator] will yield each record
19
- #
20
- def find_each(batch_size: 1000, use_transaction: true, append_order_by: nil)
21
- enum = Enumerator.new { |y|
22
- batches(of: batch_size, use_transaction: use_transaction, append_order_by: append_order_by).each { |batch|
23
- batch.each { |record| y.yield record }
24
- }
25
- }
26
- if block_given?
27
- enum.each { |record| yield record }
28
- else
29
- enum
30
- end
31
- end
32
-
33
- #
34
- # Load records in batches of N and yield each batch to a block if given.
35
- # If no block is given, returns an Enumerator.
36
- #
37
- # NOTE Unlike ActiveRecord's find_each, ORDER BY is respected. The primary key will be appended
38
- # to the ORDER BY clause to help ensure consistent batches. Additionally, it will be run inside
39
- # of a transaction.
40
- #
41
- # @param batch_size [Integer]
42
- # @param use_transaction [Boolean] Ensure it runs inside of a database transaction
43
- # @param append_order_by [String] Append this column to ORDER BY to ensure consistent results. Defaults to the primary key. Pass false to disable.
44
- # @yield [OccamsRecord::Results::Row]
45
- # @return [Enumerator] will yield each batch
46
- #
47
- def find_in_batches(batch_size: 1000, use_transaction: true, append_order_by: nil)
48
- enum = batches(of: batch_size, use_transaction: use_transaction, append_order_by: append_order_by)
49
- if block_given?
50
- enum.each { |batch| yield batch }
51
- else
52
- enum
53
- end
54
- end
55
-
56
- private
57
-
58
- #
59
- # Returns an Enumerator that yields batches of records, of size "of".
60
- # NOTE ActiveRecord 5+ provides the 'in_batches' method to do something
61
- # similiar, although 4.2 does not. Also it does not respect ORDER BY,
62
- # whereas this does.
63
- #
64
- # @param of [Integer] batch size
65
- # @param use_transaction [Boolean] Ensure it runs inside of a database transaction
66
- # @param append_order_by [String] Append this column to ORDER BY to ensure consistent results. Defaults to the primary key. Pass false to disable.
67
- # @return [Enumerator] yields batches
68
- #
69
- def batches(of:, use_transaction: true, append_order_by: nil)
70
- append_order =
71
- case append_order_by
72
- when false then nil
73
- when nil then model.primary_key
74
- else append_order_by
75
- end
76
-
77
- Enumerator.new do |y|
78
- if use_transaction and model.connection.open_transactions == 0
79
- model.connection.transaction {
80
- run_batches y, of, append_order
81
- }
82
- else
83
- run_batches y, of, append_order
84
- end
85
- end
86
- end
87
-
88
- def run_batches(y, of, append_order_by = nil)
89
- limit = scope.limit_value
90
- batch_size = limit && limit < of ? limit : of
91
-
92
- offset = scope.offset_value || 0
93
- out_of_records, count = false, 0
94
- order_by =
95
- if append_order_by
96
- append_order_by.to_s == model.primary_key.to_s ? append_order_by.to_sym : append_order_by
97
- end
98
-
99
- until out_of_records
100
- l = limit && batch_size > limit - count ? limit - count : batch_size
101
- q = scope
102
- q = q.order(order_by) if order_by
103
- q = q.offset(offset).limit(l)
104
- results = Query.new(q, use: @use, query_logger: @query_logger, eager_loaders: @eager_loaders).run
105
-
106
- y.yield results if results.any?
107
- count += results.size
108
- offset += results.size
109
- out_of_records = results.size < batch_size || (limit && count >= limit)
110
- end
111
- end
112
- end
113
- end