activeadmin_batched_export 0.2.0 → 0.3.0

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,64 @@ 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
+
27
+ render_export_workspace(export_format)
28
+ end
29
+
30
+ private
31
+
32
+ def normalized_export_format
33
+ format_name = params[:export_format].to_s.downcase
34
+ format_name = "csv" if format_name.blank?
35
+ unless %w[csv xml json].include?(format_name)
36
+ render(plain: "Invalid export format", status: :bad_request)
37
+ return nil
42
38
  end
39
+ format_name.to_sym
40
+ end
43
41
 
42
+ def export_batch_request?(export_format)
43
+ %i[csv json xml].include?(request.format.symbol) &&
44
+ request.format.symbol == export_format &&
45
+ params[:export_meta].blank?
46
+ end
47
+
48
+ def render_export_batch(export_format)
49
+ ensure_batch_download_format_allowed!(export_format)
50
+ refuse_over_max_export_rows! if starting_export_walk?
51
+ field, direction = batched_export_sort_pair
52
+ cursor = decode_export_cursor(field: field, direction: direction)
53
+ page = snapshot_export_page(field: field, direction: direction, cursor: cursor)
54
+ response.set_header(ExportCursor::NEXT_HEADER, page.next_cursor) if page.next_cursor
55
+ response.set_header(SnapshotPage::HEADER, page.snapshot_token) if page.snapshot_token
56
+ body = batched_export_batch_body(export_format, page.records, first_page: page.first_page)
57
+ render(plain: body, content_type: batch_content_type(export_format))
58
+ rescue ExportCursor::Invalid
59
+ render(plain: "Invalid export cursor", status: :bad_request)
60
+ rescue InvalidExportSnapshotError
61
+ render(
62
+ plain: I18n.t("active_admin.batched_export_page.unavailable_session"),
63
+ status: :bad_request
64
+ )
65
+ rescue ExportTooLargeError
66
+ render(
67
+ plain: I18n.t("active_admin.batched_export_page.over_max_rows"),
68
+ status: :bad_request
69
+ )
70
+ rescue UnresolvableExportColumnsError
71
+ render(
72
+ plain: I18n.t("active_admin.batched_export_page.select_at_least_one_column"),
73
+ status: :bad_request
74
+ )
75
+ rescue ExportMacroCatalog::UnknownMacroError
76
+ render(
77
+ plain: I18n.t("active_admin.batched_export_page.unknown_macro"),
78
+ status: :unprocessable_content
79
+ )
80
+ end
81
+
82
+ def render_export_workspace(export_format)
44
83
  ensure_batch_download_format_allowed!(export_format)
45
84
  @batched_export_format = export_format
46
85
  @batched_export_meta_url = batched_export_url_for(
@@ -57,20 +96,10 @@ module ActiveAdmin
57
96
  render "active_admin/batched_export/workspace", layout: "active_admin"
58
97
  end
59
98
 
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
99
  def batched_export_url_for(request_format:, extra_params: {})
73
- query = request.query_parameters.except(:format, :commit, :page, :batch_page, :export_meta)
100
+ query = request.query_parameters.except(
101
+ :format, :commit, :page, :batch_page, :export_meta, :export_cursor, :export_snapshot
102
+ )
74
103
  hash = query.respond_to?(:to_unsafe_h) ? query.to_unsafe_h : query.to_h
75
104
  hash = hash.merge(extra_params.stringify_keys)
76
105
  url_for(action: :batched_export, format: request_format, params: hash, only_path: true)
@@ -92,16 +121,34 @@ module ActiveAdmin
92
121
  first_page = paginate(base, 1, effective_batch_size)
93
122
  total_count = first_page.total_count
94
123
  total_batches = total_count.zero? ? 0 : first_page.total_pages
124
+ cap = BatchedExport.config.max_export_rows
95
125
  {
96
126
  export_format: export_format,
97
127
  total_count: total_count,
98
128
  total_batches: total_batches,
99
129
  batch_size: effective_batch_size,
100
130
  filename: export_filename(export_format),
101
- large_export: total_count >= BatchedExport.config.large_export_row_threshold
131
+ large_export: total_count >= BatchedExport.config.large_export_row_threshold,
132
+ over_max: cap.present? && total_count > cap,
133
+ max_export_rows: cap
102
134
  }
103
135
  end
104
136
 
137
+ def export_filtered_count
138
+ paginate(find_collection(except: [:pagination]), 1, effective_batch_size).total_count
139
+ end
140
+
141
+ def starting_export_walk?
142
+ params[:export_snapshot].blank? && params[:export_cursor].blank?
143
+ end
144
+
145
+ def refuse_over_max_export_rows!
146
+ cap = BatchedExport.config.max_export_rows
147
+ return if cap.blank?
148
+
149
+ raise ExportTooLargeError if export_filtered_count > cap
150
+ end
151
+
105
152
  def export_filename(format_symbol)
106
153
  filename_proc = active_admin_config.batched_export_filename_proc
107
154
  if filename_proc
@@ -112,15 +159,6 @@ module ActiveAdmin
112
159
  "#{base}-#{Time.zone.now.to_date}.#{format_symbol}"
113
160
  end
114
161
 
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
162
  def batch_content_type(export_format)
125
163
  case export_format
126
164
  when :csv then "text/csv; charset=utf-8"
@@ -130,84 +168,46 @@ module ActiveAdmin
130
168
  end
131
169
  end
132
170
 
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)
171
+ def batched_export_sort_pair
172
+ model = active_admin_config.resource_class
173
+ order_param = params[:order].presence || active_admin_config.sort_order
174
+ clause = ActiveAdmin::OrderClause.new(active_admin_config, order_param)
175
+ field = clause.valid? ? clause.field.to_s.split(".").last : nil
176
+ if field && model.column_names.include?(field)
177
+ [field, clause.order.to_s]
178
+ else
179
+ [model.primary_key.to_s, "desc"]
151
180
  end
152
- buffer
153
181
  end
154
182
 
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
183
+ def decode_export_cursor(field:, direction:)
184
+ raw = params[:export_cursor]
185
+ return nil if raw.blank?
167
186
 
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
187
+ cursor = ExportCursor.decode(raw)
188
+ unless cursor.field == field && cursor.direction == direction
189
+ raise ExportCursor::Invalid, "mismatch"
182
190
  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
191
 
196
- def merged_macro_registry
197
- BatchedExport.config.registered_macros.merge(ExportMacroCatalog.global_registry)
192
+ cursor
198
193
  end
199
194
 
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
195
+ def snapshot_export_page(field:, direction:, cursor:)
196
+ SnapshotPage.fetch(
197
+ export_collection,
198
+ model: active_admin_config.resource_class,
199
+ field: field,
200
+ direction: direction,
201
+ cursor: cursor,
202
+ snapshot_param: params[:export_snapshot],
203
+ limit: effective_batch_size
204
+ )
204
205
  end
205
206
 
206
- def page_relation(page)
207
+ def export_collection
207
208
  collection = find_collection(except: [:pagination])
208
209
  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)
210
+ includes_list.present? ? collection.includes(includes_list) : collection
211
211
  end
212
212
 
213
213
  def effective_batch_size
@@ -269,7 +269,9 @@ module ActiveAdmin
269
269
  return columns if indices.empty?
270
270
 
271
271
  resolved = indices.filter_map { |index| columns[index] }
272
- resolved.presence || columns
272
+ raise UnresolvableExportColumnsError if resolved.empty?
273
+
274
+ resolved
273
275
  end
274
276
  end
275
277
  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