activeadmin_batched_export 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "builder"
4
- require "csv"
3
+ require "activeadmin/batched_export/chunk_renderer"
4
+ require "activeadmin/batched_export/errors"
5
+ require "activeadmin/batched_export/export_cursor"
6
+ require "activeadmin/batched_export/keyset_page"
7
+ require "activeadmin/batched_export/snapshot_page"
5
8
 
6
9
  module ActiveAdmin
7
10
  module BatchedExport
8
11
  module ControllerMethods
9
12
  extend ActiveSupport::Concern
13
+ include ChunkRenderer
10
14
 
11
15
  def batched_export
12
16
  authorize! ActiveAdmin::Authorization::READ, active_admin_config.resource_class
@@ -18,29 +22,65 @@ module ActiveAdmin
18
22
  return render(json: batched_export_meta(export_format))
19
23
  end
20
24
 
21
- batch_page = params[:batch_page].to_i
22
- if batch_page.positive?
23
- if request.format.symbol != export_format
24
- head :not_acceptable
25
- return
26
- end
27
- ensure_batch_download_format_allowed!(export_format)
28
- page = page_relation(batch_page)
29
- if page.out_of_range?
30
- head :not_found
31
- return
32
- end
33
- begin
34
- body = batched_export_batch_body(export_format, batch_page, page: page)
35
- rescue ExportMacroCatalog::UnknownMacroError
36
- return render(
37
- plain: I18n.t("active_admin.batched_export_page.unknown_macro"),
38
- status: :unprocessable_content
39
- )
40
- end
41
- return render(plain: body, content_type: batch_content_type(export_format))
25
+ return render_export_batch(export_format) if export_batch_request?(export_format)
26
+ return head(:not_acceptable) if %i[csv json xml].include?(request.format.symbol)
27
+
28
+ render_export_workspace(export_format)
29
+ end
30
+
31
+ private
32
+
33
+ def normalized_export_format
34
+ format_name = params[:export_format].to_s.downcase
35
+ format_name = "csv" if format_name.blank?
36
+ unless %w[csv xml json].include?(format_name)
37
+ render(plain: "Invalid export format", status: :bad_request)
38
+ return nil
42
39
  end
40
+ format_name.to_sym
41
+ end
43
42
 
43
+ def export_batch_request?(export_format)
44
+ %i[csv json xml].include?(request.format.symbol) &&
45
+ request.format.symbol == export_format &&
46
+ params[:export_meta].blank?
47
+ end
48
+
49
+ def render_export_batch(export_format)
50
+ ensure_batch_download_format_allowed!(export_format)
51
+ refuse_over_max_export_rows! if starting_export_walk?
52
+ field, direction = batched_export_sort_pair
53
+ cursor = decode_export_cursor(field: field, direction: direction)
54
+ page = snapshot_export_page(field: field, direction: direction, cursor: cursor)
55
+ response.set_header(ExportCursor::NEXT_HEADER, page.next_cursor) if page.next_cursor
56
+ response.set_header(SnapshotPage::HEADER, page.snapshot_token) if page.snapshot_token
57
+ body = batched_export_batch_body(export_format, page.records, first_page: page.first_page)
58
+ render(plain: body, content_type: batch_content_type(export_format))
59
+ rescue ExportCursor::Invalid
60
+ render(plain: "Invalid export cursor", status: :bad_request)
61
+ rescue InvalidExportSnapshotError
62
+ render(
63
+ plain: I18n.t("active_admin.batched_export_page.unavailable_session"),
64
+ status: :bad_request
65
+ )
66
+ rescue ExportTooLargeError
67
+ render(
68
+ plain: I18n.t("active_admin.batched_export_page.over_max_rows"),
69
+ status: :bad_request
70
+ )
71
+ rescue UnresolvableExportColumnsError
72
+ render(
73
+ plain: I18n.t("active_admin.batched_export_page.select_at_least_one_column"),
74
+ status: :bad_request
75
+ )
76
+ rescue ExportMacroCatalog::UnknownMacroError
77
+ render(
78
+ plain: I18n.t("active_admin.batched_export_page.unknown_macro"),
79
+ status: :unprocessable_content
80
+ )
81
+ end
82
+
83
+ def render_export_workspace(export_format)
44
84
  ensure_batch_download_format_allowed!(export_format)
45
85
  @batched_export_format = export_format
46
86
  @batched_export_meta_url = batched_export_url_for(
@@ -57,20 +97,10 @@ module ActiveAdmin
57
97
  render "active_admin/batched_export/workspace", layout: "active_admin"
58
98
  end
59
99
 
60
- private
61
-
62
- def normalized_export_format
63
- format_name = params[:export_format].to_s.downcase
64
- format_name = "csv" if format_name.blank?
65
- unless %w[csv xml json].include?(format_name)
66
- render(plain: "Invalid export format", status: :bad_request)
67
- return nil
68
- end
69
- format_name.to_sym
70
- end
71
-
72
100
  def batched_export_url_for(request_format:, extra_params: {})
73
- query = request.query_parameters.except(:format, :commit, :page, :batch_page, :export_meta)
101
+ query = request.query_parameters.except(
102
+ :format, :commit, :page, :batch_page, :export_meta, :export_cursor, :export_snapshot
103
+ )
74
104
  hash = query.respond_to?(:to_unsafe_h) ? query.to_unsafe_h : query.to_h
75
105
  hash = hash.merge(extra_params.stringify_keys)
76
106
  url_for(action: :batched_export, format: request_format, params: hash, only_path: true)
@@ -92,16 +122,34 @@ module ActiveAdmin
92
122
  first_page = paginate(base, 1, effective_batch_size)
93
123
  total_count = first_page.total_count
94
124
  total_batches = total_count.zero? ? 0 : first_page.total_pages
125
+ cap = BatchedExport.config.max_export_rows
95
126
  {
96
127
  export_format: export_format,
97
128
  total_count: total_count,
98
129
  total_batches: total_batches,
99
130
  batch_size: effective_batch_size,
100
131
  filename: export_filename(export_format),
101
- large_export: total_count >= BatchedExport.config.large_export_row_threshold
132
+ large_export: total_count >= BatchedExport.config.large_export_row_threshold,
133
+ over_max: cap.present? && total_count > cap,
134
+ max_export_rows: cap
102
135
  }
103
136
  end
104
137
 
138
+ def export_filtered_count
139
+ paginate(find_collection(except: [:pagination]), 1, effective_batch_size).total_count
140
+ end
141
+
142
+ def starting_export_walk?
143
+ params[:export_snapshot].blank? && params[:export_cursor].blank?
144
+ end
145
+
146
+ def refuse_over_max_export_rows!
147
+ cap = BatchedExport.config.max_export_rows
148
+ return if cap.blank?
149
+
150
+ raise ExportTooLargeError if export_filtered_count > cap
151
+ end
152
+
105
153
  def export_filename(format_symbol)
106
154
  filename_proc = active_admin_config.batched_export_filename_proc
107
155
  if filename_proc
@@ -112,15 +160,6 @@ module ActiveAdmin
112
160
  "#{base}-#{Time.zone.now.to_date}.#{format_symbol}"
113
161
  end
114
162
 
115
- def batched_export_batch_body(export_format, batch_page, page: nil)
116
- case export_format
117
- when :csv then batched_csv_chunk(batch_page, page: page)
118
- when :json then batched_json_chunk(batch_page, page: page)
119
- when :xml then batched_xml_chunk(batch_page, page: page)
120
- else ""
121
- end
122
- end
123
-
124
163
  def batch_content_type(export_format)
125
164
  case export_format
126
165
  when :csv then "text/csv; charset=utf-8"
@@ -130,84 +169,46 @@ module ActiveAdmin
130
169
  end
131
170
  end
132
171
 
133
- def batched_csv_chunk(batch_page, page: nil)
134
- builder = active_admin_config.csv_builder
135
- options = builder.options.dup
136
- csv_options = options.except(:encoding_options, :humanize_name, :byte_order_mark)
137
- columns = batched_export_filter_columns(builder.exec_columns(view_context))
138
- buffer = +""
139
- byte_order_mark = options[:byte_order_mark]
140
- buffer << byte_order_mark if batch_page == 1 && byte_order_mark
141
- if batch_page == 1 && options.fetch(:column_names, true)
142
- header_line = columns.map do |column|
143
- ActiveAdmin::Sanitizer.sanitize(builder.send(:encode, column.name, options))
144
- end
145
- buffer << CSV.generate_line(header_line, **csv_options)
146
- end
147
- paginated_export_rows(batch_page, page: page) do |resource|
148
- row = builder.build_row(resource, columns, options)
149
- row = apply_export_macros(row, columns, resource)
150
- buffer << CSV.generate_line(row, **csv_options)
172
+ def batched_export_sort_pair
173
+ model = active_admin_config.resource_class
174
+ order_param = params[:order].presence || active_admin_config.sort_order
175
+ clause = ActiveAdmin::OrderClause.new(active_admin_config, order_param)
176
+ field = clause.valid? ? clause.field.to_s.split(".").last : nil
177
+ if field && model.column_names.include?(field)
178
+ [field, clause.order.to_s]
179
+ else
180
+ [model.primary_key.to_s, "desc"]
151
181
  end
152
- buffer
153
182
  end
154
183
 
155
- def batched_json_chunk(batch_page, page: nil)
156
- builder = active_admin_config.csv_builder
157
- options = builder.options
158
- columns = batched_export_filter_columns(builder.exec_columns(view_context))
159
- names = columns.map(&:name)
160
- rows = []
161
- paginated_export_rows(batch_page, page: page) do |resource|
162
- row = apply_export_macros(builder.build_row(resource, columns, options), columns, resource)
163
- rows << names.zip(row).to_h
164
- end
165
- rows.to_json
166
- end
184
+ def decode_export_cursor(field:, direction:)
185
+ raw = params[:export_cursor]
186
+ return nil if raw.blank?
167
187
 
168
- def batched_xml_chunk(batch_page, page: nil)
169
- builder = active_admin_config.csv_builder
170
- options = builder.options
171
- columns = batched_export_filter_columns(builder.exec_columns(view_context))
172
- xml = Builder::XmlMarkup.new(indent: 0)
173
- paginated_export_rows(batch_page, page: page) do |resource|
174
- row = apply_export_macros(builder.build_row(resource, columns, options), columns, resource)
175
- xml.batch do
176
- xml.record do
177
- columns.each_with_index do |column, index|
178
- xml.field("name" => column.name) { xml.text!(row[index].to_s) }
179
- end
180
- end
181
- end
188
+ cursor = ExportCursor.decode(raw)
189
+ unless cursor.field == field && cursor.direction == direction
190
+ raise ExportCursor::Invalid, "mismatch"
182
191
  end
183
- xml.target!
184
- end
185
-
186
- def apply_export_macros(row, columns, resource)
187
- ExportMacroResolver.apply(
188
- row: row,
189
- columns: columns,
190
- resource: resource,
191
- resource_settings: active_admin_config.batched_export_settings,
192
- registry: merged_macro_registry
193
- )
194
- end
195
192
 
196
- def merged_macro_registry
197
- BatchedExport.config.registered_macros.merge(ExportMacroCatalog.global_registry)
193
+ cursor
198
194
  end
199
195
 
200
- def paginated_export_rows(batch_page, page: nil)
201
- (page || page_relation(batch_page)).each do |resource|
202
- yield apply_decorator(resource)
203
- end
196
+ def snapshot_export_page(field:, direction:, cursor:)
197
+ SnapshotPage.fetch(
198
+ export_collection,
199
+ model: active_admin_config.resource_class,
200
+ field: field,
201
+ direction: direction,
202
+ cursor: cursor,
203
+ snapshot_param: params[:export_snapshot],
204
+ limit: effective_batch_size
205
+ )
204
206
  end
205
207
 
206
- def page_relation(page)
208
+ def export_collection
207
209
  collection = find_collection(except: [:pagination])
208
210
  includes_list = active_admin_config.batched_export_includes
209
- collection = collection.includes(includes_list) if includes_list.present?
210
- paginate(collection, page, effective_batch_size)
211
+ includes_list.present? ? collection.includes(includes_list) : collection
211
212
  end
212
213
 
213
214
  def effective_batch_size
@@ -269,7 +270,9 @@ module ActiveAdmin
269
270
  return columns if indices.empty?
270
271
 
271
272
  resolved = indices.filter_map { |index| columns[index] }
272
- resolved.presence || columns
273
+ raise UnresolvableExportColumnsError if resolved.empty?
274
+
275
+ resolved
273
276
  end
274
277
  end
275
278
  end
@@ -12,13 +12,23 @@ module ActiveAdmin
12
12
  require "activeadmin/batched_export/export_macro_resolver"
13
13
  require "activeadmin/batched_export/configuration"
14
14
  require "activeadmin/batched_export/resource_extension"
15
+ require "activeadmin/batched_export/errors"
16
+ require "activeadmin/batched_export/export_cursor"
17
+ require "activeadmin/batched_export/keyset_page"
18
+ require "activeadmin/batched_export/snapshot_row"
19
+ require "activeadmin/batched_export/snapshot_page"
20
+ require "activeadmin/batched_export/row_sanitizer"
21
+ require "activeadmin/batched_export/chunk_renderer"
15
22
  require "activeadmin/batched_export/controller_methods"
16
23
  require "activeadmin/batched_export/install"
17
24
  end
18
25
 
19
26
  initializer "activeadmin_batched_export.assets" do |app|
20
27
  assets_path = root.join("app/assets")
21
- app.config.importmap.cache_sweepers << assets_path.join("controllers") if app.config.respond_to?(:importmap)
28
+ next unless app.config.respond_to?(:importmap)
29
+
30
+ app.config.importmap.cache_sweepers << assets_path.join("controllers")
31
+ app.config.importmap.cache_sweepers << assets_path.join("javascripts")
22
32
  end
23
33
 
24
34
  initializer "activeadmin_batched_export.importmap", after: :load_config_initializers do
@@ -27,6 +37,8 @@ module ActiveAdmin
27
37
  pin_controller = proc do |importmap|
28
38
  importmap.pin "controllers/activeadmin_batched_export/batched_export_controller",
29
39
  to: "activeadmin_batched_export/batched_export_controller.js"
40
+ importmap.pin "activeadmin_batched_export/chunk_assembly",
41
+ to: "activeadmin_batched_export/chunk_assembly.mjs"
30
42
  end
31
43
 
32
44
  Rails.application.importmap.draw(&pin_controller)
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module BatchedExport
5
+ class UnresolvableExportColumnsError < StandardError; end
6
+ class ExportTooLargeError < StandardError; end
7
+ class InvalidExportSnapshotError < StandardError; end
8
+ end
9
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+
6
+ module ActiveAdmin
7
+ module BatchedExport
8
+ class ExportCursor
9
+ Invalid = Class.new(StandardError)
10
+ NEXT_HEADER = "X-Batched-Export-Next"
11
+
12
+ Payload = Struct.new(:field, :direction, :primary_key, :sort_value, :position)
13
+
14
+ def self.encode(field:, direction:, primary_key:, sort_value:, position: nil)
15
+ payload = {
16
+ "f" => field,
17
+ "d" => direction,
18
+ "k" => dump_value(primary_key),
19
+ "s" => dump_value(sort_value)
20
+ }
21
+ payload["p"] = position unless position.nil?
22
+ Base64.urlsafe_encode64(JSON.generate(payload), padding: false)
23
+ end
24
+
25
+ def self.decode(raw)
26
+ raise Invalid, "blank" if raw.nil? || raw.to_s.empty?
27
+
28
+ payload_from_hash(JSON.parse(Base64.urlsafe_decode64(raw.to_s)))
29
+ rescue ArgumentError, JSON::ParserError, KeyError, TypeError
30
+ raise Invalid
31
+ end
32
+
33
+ def self.dump_value(value)
34
+ case value
35
+ when Time, DateTime then value.iso8601(6)
36
+ when Date then value.iso8601
37
+ else value
38
+ end
39
+ end
40
+
41
+ def self.payload_from_hash(hash)
42
+ direction = hash.fetch("d")
43
+ raise Invalid, "direction" unless %w[asc desc].include?(direction)
44
+
45
+ Payload.new(
46
+ hash.fetch("f").to_s,
47
+ direction,
48
+ hash.fetch("k"),
49
+ hash.fetch("s"),
50
+ hash["p"]
51
+ )
52
+ end
53
+ private_class_method :payload_from_hash
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module BatchedExport
5
+ class KeysetPage
6
+ def self.records(relation, model:, field:, direction:, cursor:, limit:)
7
+ scoped = ordered(relation, model: model, field: field, direction: direction)
8
+ scoped = apply_after(scoped, model: model, field: field, direction: direction, cursor: cursor) if cursor
9
+ scoped.limit(limit).to_a
10
+ end
11
+
12
+ def self.ordered(relation, model:, field:, direction:)
13
+ table = model.arel_table
14
+ primary_key = model.primary_key.to_s
15
+ primary_key_order = arel_direction(table[primary_key], direction)
16
+ return relation.except(:order).order(primary_key_order) if field == primary_key
17
+
18
+ relation.except(:order).order(
19
+ table[field].eq(nil),
20
+ arel_direction(table[field], direction),
21
+ primary_key_order
22
+ )
23
+ end
24
+
25
+ def self.next_cursor(records, field:, direction:, primary_key:, limit:, position: nil)
26
+ return nil if records.empty? || records.length < limit
27
+
28
+ last_record = records.last
29
+ ExportCursor.encode(
30
+ field: field,
31
+ direction: direction,
32
+ primary_key: last_record.public_send(primary_key),
33
+ sort_value: last_record.public_send(field),
34
+ position: position
35
+ )
36
+ end
37
+
38
+ def self.apply_after(relation, model:, field:, direction:, cursor:)
39
+ table = model.arel_table
40
+ primary_key = model.primary_key
41
+ primary_key_value = cast(model, primary_key, cursor.primary_key)
42
+ if field == primary_key.to_s
43
+ return after_primary_key(relation, table[primary_key], primary_key_value, direction)
44
+ end
45
+
46
+ after_sort_and_primary_key(
47
+ relation,
48
+ table[field],
49
+ cast(model, field, cursor.sort_value),
50
+ table[primary_key],
51
+ primary_key_value,
52
+ direction
53
+ )
54
+ end
55
+
56
+ def self.arel_direction(column, direction)
57
+ (direction == "desc") ? column.desc : column.asc
58
+ end
59
+
60
+ def self.after_primary_key(relation, column, value, direction)
61
+ comparator = (direction == "desc") ? :lt : :gt
62
+ relation.where(column.public_send(comparator, value))
63
+ end
64
+
65
+ def self.after_sort_and_primary_key(relation, sort_column, sort_value, pk_column, pk_value, direction)
66
+ pk_past = pk_column.public_send((direction == "desc") ? :lt : :gt, pk_value)
67
+ return relation.where(sort_column.eq(nil).and(pk_past)) if sort_value.nil?
68
+
69
+ sort_past = sort_column.public_send((direction == "desc") ? :lt : :gt, sort_value)
70
+ relation.where(
71
+ sort_past.and(sort_column.not_eq(nil))
72
+ .or(sort_column.eq(sort_value).and(pk_past))
73
+ .or(sort_column.eq(nil))
74
+ )
75
+ end
76
+
77
+ def self.cast(model, name, raw)
78
+ model.type_for_attribute(name).cast(raw)
79
+ end
80
+ private_class_method :apply_after, :arel_direction, :after_primary_key,
81
+ :after_sort_and_primary_key, :cast
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module BatchedExport
5
+ module RowSanitizer
6
+ def self.apply(row)
7
+ row.map { |cell| ActiveAdmin::Sanitizer.sanitize(cell) }
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "activeadmin/batched_export/errors"
5
+ require "activeadmin/batched_export/export_cursor"
6
+ require "activeadmin/batched_export/keyset_page"
7
+ require "activeadmin/batched_export/snapshot_row"
8
+
9
+ module ActiveAdmin
10
+ module BatchedExport
11
+ class SnapshotPage
12
+ HEADER = "X-Batched-Export-Snapshot"
13
+ INSERT_SLICE = 500
14
+ Page = Struct.new(:records, :snapshot_token, :next_cursor, :first_page)
15
+
16
+ def self.fetch(collection, model:, field:, direction:, cursor:, snapshot_param:, limit:)
17
+ if snapshot_param.present?
18
+ resume(collection, model: model, field: field, direction: direction,
19
+ cursor: cursor, snapshot_param: snapshot_param, limit: limit)
20
+ elsif SnapshotRow.available? && cursor.nil?
21
+ freeze_first(collection, model: model, field: field, direction: direction, limit: limit)
22
+ else
23
+ live(collection, model: model, field: field, direction: direction, cursor: cursor, limit: limit)
24
+ end
25
+ end
26
+
27
+ def self.freeze_first(collection, model:, field:, direction:, limit:)
28
+ SnapshotRow.expire_stale!(ttl: BatchedExport.config.snapshot_ttl)
29
+ # Full id list in process memory on freeze. Stream into insert_all when that ceiling matters.
30
+ ids = KeysetPage.ordered(collection, model: model, field: field, direction: direction)
31
+ .pluck(model.primary_key)
32
+ return Page.new(records: [], snapshot_token: nil, next_cursor: nil, first_page: true) if ids.empty?
33
+
34
+ token = insert_ids(ids, model.name)
35
+ walk(collection, model: model, field: field, direction: direction,
36
+ token: token, after_position: 0, limit: limit, first_page: true)
37
+ end
38
+
39
+ def self.resume(collection, model:, field:, direction:, cursor:, snapshot_param:, limit:)
40
+ raise InvalidExportSnapshotError unless SnapshotRow.available?
41
+ raise InvalidExportSnapshotError if cursor.nil? || cursor.position.nil?
42
+
43
+ token = authentic_token(snapshot_param, model.name)
44
+ walk(collection, model: model, field: field, direction: direction,
45
+ token: token, after_position: cursor.position, limit: limit, first_page: false)
46
+ end
47
+
48
+ def self.live(collection, model:, field:, direction:, cursor:, limit:)
49
+ records = KeysetPage.records(
50
+ collection, model: model, field: field, direction: direction, cursor: cursor, limit: limit
51
+ )
52
+ Page.new(
53
+ records: records,
54
+ snapshot_token: nil,
55
+ next_cursor: KeysetPage.next_cursor(
56
+ records, field: field, direction: direction, primary_key: model.primary_key, limit: limit
57
+ ),
58
+ first_page: cursor.nil?
59
+ )
60
+ end
61
+
62
+ def self.insert_ids(ids, resource_type)
63
+ token = SecureRandom.urlsafe_base64(32)
64
+ created_at = Time.current
65
+ ids.each_slice(INSERT_SLICE).with_index do |slice, slice_index|
66
+ SnapshotRow.insert_all(slice.each_with_index.map { |record_id, offset|
67
+ {
68
+ token: token, resource_type: resource_type, record_id: record_id,
69
+ position: (slice_index * INSERT_SLICE) + offset + 1, created_at: created_at
70
+ }
71
+ })
72
+ end
73
+ token
74
+ end
75
+
76
+ def self.authentic_token(raw, resource_type)
77
+ anchor = SnapshotRow.where(token: raw).order(:position).first
78
+ raise InvalidExportSnapshotError if anchor.nil?
79
+ raise InvalidExportSnapshotError if SnapshotRow.expired?(anchor.created_at, ttl: BatchedExport.config.snapshot_ttl)
80
+ raise InvalidExportSnapshotError unless anchor.resource_type == resource_type
81
+
82
+ anchor.token
83
+ end
84
+
85
+ def self.walk(collection, model:, field:, direction:, token:, after_position:, limit:, first_page:)
86
+ records, consumed = fill_live(collection, model: model, token: token,
87
+ after_position: after_position, limit: limit)
88
+ if SnapshotRow.remaining_after?(token, consumed) && records.length == limit
89
+ return Page.new(
90
+ records: records, snapshot_token: token, first_page: first_page,
91
+ next_cursor: KeysetPage.next_cursor(
92
+ records, field: field, direction: direction,
93
+ primary_key: model.primary_key, limit: limit, position: consumed
94
+ )
95
+ )
96
+ end
97
+
98
+ SnapshotRow.delete_token!(token)
99
+ Page.new(records: records, snapshot_token: nil, next_cursor: nil, first_page: first_page)
100
+ end
101
+
102
+ def self.fill_live(collection, model:, token:, after_position:, limit:)
103
+ live = []
104
+ consumed = after_position
105
+ position = after_position
106
+ loop do
107
+ window = SnapshotRow.window_after(token, position, limit)
108
+ break if window.empty?
109
+
110
+ found = live_by_id(collection, model.primary_key, window)
111
+ consumed = append_live(window, found, live, limit)
112
+ break if live.length == limit
113
+
114
+ position = window.last.position
115
+ end
116
+ [live, consumed]
117
+ end
118
+
119
+ def self.live_by_id(collection, primary_key, rows)
120
+ collection.where(primary_key => rows.map(&:record_id))
121
+ .index_by { |record| record.public_send(primary_key).to_i }
122
+ end
123
+
124
+ def self.append_live(window, found, live, limit)
125
+ consumed = nil
126
+ window.each do |row|
127
+ consumed = row.position
128
+ record = found[row.record_id.to_i]
129
+ next unless record
130
+
131
+ live << record
132
+ break if live.length == limit
133
+ end
134
+ consumed
135
+ end
136
+ private_class_method :freeze_first, :resume, :live, :insert_ids, :authentic_token,
137
+ :walk, :fill_live, :live_by_id, :append_live
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module ActiveAdmin
6
+ module BatchedExport
7
+ class SnapshotRow < ::ActiveRecord::Base
8
+ self.table_name = "active_admin_batched_export_snapshot_rows"
9
+ self.record_timestamps = false
10
+
11
+ def self.available?
12
+ table_exists?
13
+ end
14
+
15
+ def self.expire_stale!(ttl:)
16
+ # Global sweep on freeze start. Upgrade to a per-token job when volume matters.
17
+ where("created_at < ?", cutoff(ttl)).delete_all
18
+ end
19
+
20
+ def self.expired?(created_at, ttl:)
21
+ created_at < cutoff(ttl)
22
+ end
23
+
24
+ def self.cutoff(ttl)
25
+ ttl.respond_to?(:ago) ? ttl.ago : Time.current - ttl
26
+ end
27
+
28
+ def self.window_after(token, position, limit)
29
+ where(token: token).where("position > ?", position).order(:position).limit(limit).to_a
30
+ end
31
+
32
+ def self.remaining_after?(token, position)
33
+ where(token: token).where("position > ?", position).exists?
34
+ end
35
+
36
+ def self.delete_token!(token)
37
+ where(token: token).delete_all
38
+ end
39
+ end
40
+ end
41
+ end