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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/CODE_OF_CONDUCT.md +14 -26
- data/CONTRIBUTING.md +11 -17
- data/GOVERNANCE.md +11 -18
- data/README.md +44 -11
- data/SECURITY.md +10 -13
- data/activeadmin_batched_export.gemspec +2 -2
- data/app/assets/controllers/activeadmin_batched_export/batched_export_controller.js +136 -96
- data/app/assets/javascripts/activeadmin_batched_export/chunk_assembly.mjs +30 -0
- data/app/views/active_admin/batched_export/_actions.html.erb +12 -1
- data/app/views/active_admin/batched_export/_progress.html.erb +1 -1
- data/app/views/active_admin/batched_export/workspace.html.erb +3 -0
- data/app/views/active_admin/shared/_download_format_links.html.erb +1 -1
- data/config/locales/activeadmin_batched_export.en.yml +7 -2
- data/lib/activeadmin/batched_export/chunk_renderer.rb +113 -0
- data/lib/activeadmin/batched_export/configuration.rb +4 -2
- data/lib/activeadmin/batched_export/controller_methods.rb +115 -113
- data/lib/activeadmin/batched_export/engine.rb +13 -1
- data/lib/activeadmin/batched_export/errors.rb +9 -0
- data/lib/activeadmin/batched_export/export_cursor.rb +56 -0
- data/lib/activeadmin/batched_export/keyset_page.rb +84 -0
- data/lib/activeadmin/batched_export/row_sanitizer.rb +11 -0
- data/lib/activeadmin/batched_export/snapshot_page.rb +140 -0
- data/lib/activeadmin/batched_export/snapshot_row.rb +41 -0
- data/lib/activeadmin/batched_export/styles.rb +1 -0
- data/lib/activeadmin/batched_export/version.rb +1 -1
- metadata +12 -4
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
4
|
-
require "
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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(
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
197
|
-
BatchedExport.config.registered_macros.merge(ExportMacroCatalog.global_registry)
|
|
192
|
+
cursor
|
|
198
193
|
end
|
|
199
194
|
|
|
200
|
-
def
|
|
201
|
-
(
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
207
|
+
def export_collection
|
|
207
208
|
collection = find_collection(except: [:pagination])
|
|
208
209
|
includes_list = active_admin_config.batched_export_includes
|
|
209
|
-
|
|
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.
|
|
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
|
-
|
|
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,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,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
|