Skip to content

Commit 9976ded

Browse files
seanlinsleyvaryonic
authored andcommitted
allow CSV pagination to be customizable
This fixes the performance issues that activeadmin#3375 introduced by switching from `find_each` to Kaminari, since Kaminari has to do a SQL count. For those that want that behavior they can enable it by setting `paginate_with: :kaminari`, but the fastest option should be the default for Active Admin. I chose this approach instead of activeadmin#4082 because databases don’t guarantee any particular order when requesting records, so it’s possible that the resulting CSV won’t include all records. In this commit I removed the `batch_size` method. Ideally it should be a config option, but I didn’t have the time / motivation to write tests for it. Currently both pagination methods use a 1000 record page size. This commit refactors the tests that were creating their own controllers, to reduce duplication. While writing this I came across a bug with the byte order mark / BOM character. Our Cucumber tests had set it to an empty string, which would cause the unit tests to fail. Turns out we weren’t encoding that character. I updated the code to fix that, but I didn’t think it was important enough to write a separate test for.
1 parent cc52208 commit 9976ded

File tree

3 files changed

+106
-83
lines changed

3 files changed

+106
-83
lines changed

docs/4-csv-format.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,25 @@ environments where CSV streaming is disabled, you can change this setting:
5656

5757
config.disable_streaming_in = ['development', 'staging']
5858
```
59+
60+
# Pagination
61+
62+
By default we use `find_each` to paginate through your records because it's the
63+
best option made available by Rails. Note that it relies on the table's primary
64+
key, and orders the collection by ID. If you would like to configure either of
65+
those, you can use the `paginate_with` option.
66+
67+
```ruby
68+
ActiveAdmin.register Post do
69+
controller do
70+
def collection
71+
Post.order created_at: :desc
72+
end
73+
end
74+
75+
csv paginate_with: :kaminari
76+
end
77+
```
78+
79+
You can also provide any custom pagination by passing a proc/lambda. The only
80+
requirement is that it needs to respond to `each`.

lib/active_admin/csv_builder.rb

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,15 @@ def self.default_for_resource(resource)
2626
end
2727
end
2828

29-
attr_reader :columns, :options, :byte_order_mark, :column_names, :csv_options
29+
attr_reader :columns, :options, :paginate_with, :byte_order_mark, :column_names, :csv_options
3030

3131
COLUMN_TRANSITIVE_OPTIONS = [:humanize_name].freeze
3232

3333
def initialize(options={}, &block)
3434
@resource = options.delete(:resource)
3535
@block = block
3636
@options = ActiveAdmin.application.csv_options.merge options
37+
@paginate_with = @options.delete(:paginate_with) { :find_each }
3738
@byte_order_mark = @options.delete :byte_order_mark
3839
@column_names = @options.delete(:column_names) { true }
3940
@csv_options = @options.except :encoding_options
@@ -47,17 +48,15 @@ def build(controller, csv)
4748
@collection = controller.send :find_collection, except: :pagination
4849
columns = exec_columns controller.view_context
4950

50-
csv << byte_order_mark if byte_order_mark
51+
csv << encode(byte_order_mark) if byte_order_mark
5152

5253
if column_names
53-
csv << CSV.generate_line(columns.map{ |c| encode c.name, options }, csv_options)
54+
csv << CSV.generate_line(columns.map{ |c| encode c.name }, csv_options)
5455
end
5556

56-
(1..paginated_collection.total_pages).each do |page|
57-
paginated_collection(page).each do |resource|
58-
resource = controller.send :apply_decorator, resource
59-
csv << CSV.generate_line(build_row(resource, columns, options), csv_options)
60-
end
57+
each_resource do |resource|
58+
resource = controller.send :apply_decorator, resource
59+
csv << CSV.generate_line(build_row(resource, columns), csv_options)
6160
end
6261

6362
csv
@@ -72,13 +71,13 @@ def exec_columns(view_context = nil)
7271
@columns
7372
end
7473

75-
def build_row(resource, columns, options)
74+
def build_row(resource, columns)
7675
columns.map do |column|
77-
encode call_method_or_proc_on(resource, column.data), options
76+
encode call_method_or_proc_on(resource, column.data)
7877
end
7978
end
8079

81-
def encode(content, options)
80+
def encode(content)
8281
if options[:encoding]
8382
content.to_s.encode options[:encoding], options[:encoding_options]
8483
else
@@ -98,12 +97,23 @@ def column_transitive_options
9897
@column_transitive_options ||= @options.slice(*COLUMN_TRANSITIVE_OPTIONS)
9998
end
10099

101-
def paginated_collection(page_no = 1)
102-
@collection.public_send(Kaminari.config.page_method_name, page_no).per(batch_size)
100+
def each_resource
101+
case paginate_with
102+
when :find_each
103+
@collection.find_each{ |resource| yield resource }
104+
when :kaminari
105+
(1..kaminari_collection.total_pages).each do |page|
106+
kaminari_collection(page).each{ |resource| yield resource }
107+
end
108+
when Proc
109+
paginate_with.call(@collection).each{ |resource| yield resource }
110+
else
111+
fail "unexpected argument for paginate_with: #{paginate_with}"
112+
end
103113
end
104114

105-
def batch_size
106-
1000
115+
def kaminari_collection(page = 1)
116+
@collection.public_send(Kaminari.config.page_method_name, page).per 1000
107117
end
108118

109119
class Column

spec/unit/csv_builder_spec.rb

Lines changed: 59 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,27 @@
66
let(:builder) { ActiveAdmin::CSVBuilder.new options, &block }
77
let(:options) { {} }
88
let(:block) { ->{} }
9-
before{ |ex| builder.send :exec_columns unless ex.metadata[:skip_exec] }
9+
10+
let(:view_context) {
11+
context = double
12+
method = MethodOrProcHelper.instance_method(:call_method_or_proc_on).bind(context)
13+
allow(context).to receive(:call_method_or_proc_on){ |*args| method.call *args }
14+
context
15+
}
16+
let(:controller) {
17+
controller = double view_context: view_context, find_collection: collection
18+
allow(controller).to receive(:apply_decorator) { |r| r }
19+
controller
20+
}
21+
let(:collection) { Post.all }
22+
23+
before{ |ex| builder.send :exec_columns, view_context unless ex.metadata[:skip_exec] }
24+
25+
before :all do
26+
Post.destroy_all
27+
@post1 = Post.create!(title: "Hello1", published_date: Date.today - 2.day )
28+
@post2 = Post.create!(title: "Hello2", published_date: Date.today - 1.day )
29+
end
1030

1131
context 'when empty' do
1232
it "has no columns" do
@@ -158,46 +178,45 @@
158178
end
159179
end
160180

161-
context "with access to the controller", skip_exec: true do
162-
let(:dummy_view_context) { double(controller: dummy_controller) }
163-
let(:dummy_controller) { double(names: %w(title summary updated_at created_at))}
181+
context "with access to the controller"do
182+
let(:view_context) { double controller: double(names: %w(title summary updated_at created_at)) }
164183
let(:block) {
165184
-> {
166185
column "id"
167186
controller.names.each{ |name| column name }
168187
}
169188
}
170-
before{ builder.send :exec_columns, dummy_view_context }
171189

172190
it "builds columns provided by the controller" do
173191
expect(builder.columns.map(&:data)).to match_array([:id, :title, :summary, :updated_at, :created_at])
174192
end
175193
end
176194

177-
context "build csv using the supplied order" do
178-
before do
179-
@post1 = Post.create!(title: "Hello1", published_date: Date.today - 2.day )
180-
@post2 = Post.create!(title: "Hello2", published_date: Date.today - 1.day )
195+
it "generates data ignoring pagination" do
196+
expect(controller).to receive(:find_collection).with(except: :pagination).once
197+
expect(builder).to receive(:build_row).and_return([]).twice
198+
builder.build controller, []
199+
end
200+
201+
describe "paginate_with: :per_page" do
202+
it "works" do
203+
expect(collection).to receive(:find_each).and_call_original
204+
builder.build controller, []
181205
end
182-
let(:dummy_controller) {
183-
class DummyController
184-
def find_collection(*)
185-
collection
186-
end
206+
end
187207

188-
def collection
189-
Post.order('published_date DESC')
190-
end
208+
describe "paginate_with: <Proc>" do
209+
let(:options) { {paginate_with: ->collection { collection }} }
191210

192-
def apply_decorator(resource)
193-
resource
194-
end
211+
it "works" do
212+
expect(collection).to receive(:each).and_call_original
213+
builder.build controller, []
214+
end
215+
end
195216

196-
def view_context
197-
end
198-
end
199-
DummyController.new
200-
}
217+
describe "paginate_with: :kaminari" do
218+
let(:options) { {paginate_with: :kaminari} }
219+
let(:collection) { Post.order published_date: :desc }
201220
let(:block) {
202221
-> {
203222
column "id"
@@ -209,71 +228,43 @@ def view_context
209228
it "generates data with the supplied order" do
210229
expect(builder).to receive(:build_row).and_return([]).once.ordered { |post| expect(post.id).to eq @post2.id }
211230
expect(builder).to receive(:build_row).and_return([]).once.ordered { |post| expect(post.id).to eq @post1.id }
212-
builder.build dummy_controller, []
213-
end
214-
215-
it "generates data ignoring pagination" do
216-
expect(dummy_controller).to receive(:find_collection).
217-
with(except: :pagination).once.
218-
and_call_original
219-
expect(builder).to receive(:build_row).and_return([]).twice
220-
builder.build dummy_controller, []
231+
builder.build controller, []
221232
end
222-
223233
end
224234

225-
context "build csv using specified encoding and encoding_options" do
226-
let(:dummy_controller) do
227-
class DummyController
228-
def find_collection(*)
229-
collection
230-
end
231-
232-
def collection
233-
Post
234-
end
235-
236-
def apply_decorator(resource)
237-
resource
238-
end
239-
240-
def view_context
241-
end
242-
end
243-
DummyController.new
244-
end
235+
describe ":encoding and :encoding_options" do
245236
let(:encoding) { Encoding::ASCII }
246237
let(:encoding_options) { {} }
247238
let(:options) { {encoding: encoding, encoding_options: encoding_options} }
248239
let(:block) {
249240
-> {
250-
column "おはようございます"
251-
column "title"
241+
column("おはようございます") { |p| p.title }
252242
}
253243
}
254244

255-
context "Shift-JIS with options" do
245+
context "Shift-JIS" do
256246
let(:encoding) { Encoding::Shift_JIS }
257247
let(:encoding_options) { {invalid: :replace, undef: :replace, replace: "?"} }
258248

259249
it "encodes the CSV" do
260-
receiver = []
261-
builder.build dummy_controller, receiver
262-
line = receiver.last
263-
expect(line.encoding).to eq(encoding)
250+
csv = []
251+
builder.build controller, csv
252+
253+
expect(csv.map(&:encoding).uniq).to eq [encoding]
254+
expect(csv).to include "おはようございます\n".encode(encoding, encoding_options)
264255
end
265256
end
266257

267-
context "ASCII with options" do
258+
context "ASCII" do
268259
let(:encoding) { Encoding::ASCII }
269260
let(:encoding_options) { {invalid: :replace, undef: :replace, replace: "__REPLACED__"} }
270261

271262
it "encodes the CSV without errors" do
272-
receiver = []
273-
builder.build dummy_controller, receiver
274-
line = receiver.last
275-
expect(line.encoding).to eq(encoding)
276-
expect(line).to include("__REPLACED__")
263+
csv = []
264+
builder.build controller, csv
265+
266+
expect(csv.map(&:encoding).uniq).to eq [encoding]
267+
expect(csv.first).to include "__REPLACED__"
277268
end
278269
end
279270
end

0 commit comments

Comments
 (0)