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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -1
- 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 +3 -3
- 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 +116 -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 +14 -6
|
@@ -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,65 @@ module ActiveAdmin
|
|
|
18
22
|
return render(json: batched_export_meta(export_format))
|
|
19
23
|
end
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
if
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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(
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
184
|
+
def decode_export_cursor(field:, direction:)
|
|
185
|
+
raw = params[:export_cursor]
|
|
186
|
+
return nil if raw.blank?
|
|
167
187
|
|
|
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
|
|
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
|
-
|
|
197
|
-
BatchedExport.config.registered_macros.merge(ExportMacroCatalog.global_registry)
|
|
193
|
+
cursor
|
|
198
194
|
end
|
|
199
195
|
|
|
200
|
-
def
|
|
201
|
-
(
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
208
|
+
def export_collection
|
|
207
209
|
collection = find_collection(except: [:pagination])
|
|
208
210
|
includes_list = active_admin_config.batched_export_includes
|
|
209
|
-
|
|
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.
|
|
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
|
-
|
|
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
|