diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-08-03 15:09:25 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-08-03 15:09:25 +0300 |
commit | aeee636c18f82107ec7a489f33c944c65ad5f34e (patch) | |
tree | 2c30286279e096c9114e9a41a3ed07a83293c059 /gems/csv_builder | |
parent | 3d8459c18b7a20d9142359bb9334b467e774eb36 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'gems/csv_builder')
-rw-r--r-- | gems/csv_builder/lib/csv_builder.rb | 2 | ||||
-rw-r--r-- | gems/csv_builder/lib/csv_builder/builder.rb | 4 | ||||
-rw-r--r-- | gems/csv_builder/lib/csv_builder/gzip.rb | 23 | ||||
-rw-r--r-- | gems/csv_builder/spec/csv_builder/gzip_spec.rb | 33 | ||||
-rw-r--r-- | gems/csv_builder/spec/csv_builder_spec.rb | 177 |
5 files changed, 157 insertions, 82 deletions
diff --git a/gems/csv_builder/lib/csv_builder.rb b/gems/csv_builder/lib/csv_builder.rb index 1ef38a1d6a4..86b682939dc 100644 --- a/gems/csv_builder/lib/csv_builder.rb +++ b/gems/csv_builder/lib/csv_builder.rb @@ -2,11 +2,13 @@ require 'csv' require 'tempfile' +require 'zlib' require_relative "csv_builder/version" require_relative "csv_builder/builder" require_relative "csv_builder/single_batch" require_relative "csv_builder/stream" +require_relative "csv_builder/gzip" # Generates CSV when given a collection and a mapping. # diff --git a/gems/csv_builder/lib/csv_builder/builder.rb b/gems/csv_builder/lib/csv_builder/builder.rb index 3baa2155fc9..99b63153ab2 100644 --- a/gems/csv_builder/lib/csv_builder/builder.rb +++ b/gems/csv_builder/lib/csv_builder/builder.rb @@ -59,8 +59,10 @@ module CsvBuilder @collection.each_batch(order_hint: :created_at) do |relation| relation.preload(@associations_to_preload).order(:id).each(&block) end - else + elsif @collection.respond_to?(:find_each) @collection.find_each(&block) + else + @collection.each(&block) end end diff --git a/gems/csv_builder/lib/csv_builder/gzip.rb b/gems/csv_builder/lib/csv_builder/gzip.rb new file mode 100644 index 00000000000..60875006a35 --- /dev/null +++ b/gems/csv_builder/lib/csv_builder/gzip.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module CsvBuilder + class Gzip < CsvBuilder::Builder + # Writes the CSV file compressed and yields the written tempfile. + # + # Example: + # > CsvBuilder::Gzip.new(Issue, { title: -> (row) { row.title.upcase }, id: :id }).render do |tempfile| + # > puts tempfile.path + # > puts `zcat #{tempfile.path}` + # > end + def render + Tempfile.open(['csv_builder_gzip', '.csv.gz']) do |tempfile| + csv = CSV.new(Zlib::GzipWriter.open(tempfile.path)) + + write_csv csv, until_condition: -> {} # truncation must be handled outside of the CsvBuilder + + csv.close + yield tempfile + end + end + end +end diff --git a/gems/csv_builder/spec/csv_builder/gzip_spec.rb b/gems/csv_builder/spec/csv_builder/gzip_spec.rb new file mode 100644 index 00000000000..9d24d351247 --- /dev/null +++ b/gems/csv_builder/spec/csv_builder/gzip_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CsvBuilder::Gzip do + let(:event_1) { double(title: 'Added salt', description: 'A teaspoon') } + let(:event_2) { double(title: 'Added sugar', description: 'Just a pinch') } + let(:items) { [event_1, event_2] } + + subject(:builder) { described_class.new(items, 'Title' => 'title', 'Description' => 'description') } + + describe '#render' do + it 'returns yields a tempfile' do + written_content = nil + + builder.render do |tempfile| + reader = Zlib::GzipReader.new(tempfile) + written_content = reader.read.split("\n") + end + + expect(written_content).to eq( + [ + "Title,Description", + "Added salt,A teaspoon", + "Added sugar,Just a pinch" + ]) + end + + it 'requires a block' do + expect { builder.render }.to raise_error(LocalJumpError) + end + end +end diff --git a/gems/csv_builder/spec/csv_builder_spec.rb b/gems/csv_builder/spec/csv_builder_spec.rb index 9391938f59d..9d6283b3985 100644 --- a/gems/csv_builder/spec/csv_builder_spec.rb +++ b/gems/csv_builder/spec/csv_builder_spec.rb @@ -2,126 +2,141 @@ RSpec.describe CsvBuilder do let(:object) { double(question: :answer) } - let(:fake_relation) { described_class::FakeRelation.new([object]) } let(:csv_data) { subject.render } let(:subject) do described_class.new( - fake_relation, 'Q & A' => :question, 'Reversed' => ->(o) { o.question.to_s.reverse }) + enumerable, 'Q & A' => :question, 'Reversed' => ->(o) { o.question.to_s.reverse }) end - before do - stub_const("#{described_class}::FakeRelation", Array) + shared_examples 'csv builder examples' do + let(:items) { [object] } - described_class::FakeRelation.class_eval do - def find_each(&block) - each(&block) - end + it "has a version number" do + expect(CsvBuilder::Version::VERSION).not_to be nil end - end - it "has a version number" do - expect(CsvBuilder::Version::VERSION).not_to be nil - end + it 'generates a csv' do + expect(csv_data.scan(/(,|\n)/).join).to include ",\n," + end - it 'generates a csv' do - expect(csv_data.scan(/(,|\n)/).join).to include ",\n," - end + it 'uses a temporary file to reduce memory allocation' do + expect(CSV).to receive(:new).with(instance_of(Tempfile)).and_call_original - it 'uses a temporary file to reduce memory allocation' do - expect(CSV).to receive(:new).with(instance_of(Tempfile)).and_call_original + subject.render + end - subject.render - end + it 'counts the number of rows' do + subject.render - it 'counts the number of rows' do - subject.render + expect(subject.rows_written).to eq 1 + end - expect(subject.rows_written).to eq 1 - end + describe 'rows_expected' do + it 'uses rows_written if CSV rendered successfully' do + subject.render - describe 'rows_expected' do - it 'uses rows_written if CSV rendered successfully' do - subject.render + expect(enumerable).not_to receive(:count) + expect(subject.rows_expected).to eq 1 + end - expect(fake_relation).not_to receive(:count) - expect(subject.rows_expected).to eq 1 + it 'falls back to calling .count before rendering begins' do + expect(subject.rows_expected).to eq 1 + end end - it 'falls back to calling .count before rendering begins' do - expect(subject.rows_expected).to eq 1 - end - end + describe 'truncation' do + let(:big_object) { double(question: 'Long' * 1024) } + let(:row_size) { big_object.question.length * 2 } + let(:items) { [big_object, big_object, big_object] } + + it 'occurs after given number of bytes' do + expect(subject.render(row_size * 2).length).to be_between(row_size * 2, row_size * 3) + expect(subject).to be_truncated + expect(subject.rows_written).to eq 2 + end + + it 'is ignored by default' do + expect(subject.render.length).to be > row_size * 3 + expect(subject.rows_written).to eq 3 + end - describe 'truncation' do - let(:big_object) { double(question: 'Long' * 1024) } - let(:row_size) { big_object.question.length * 2 } - let(:fake_relation) { described_class::FakeRelation.new([big_object, big_object, big_object]) } + it 'causes rows_expected to fall back to .count' do + subject.render(0) - it 'occurs after given number of bytes' do - expect(subject.render(row_size * 2).length).to be_between(row_size * 2, row_size * 3) - expect(subject).to be_truncated - expect(subject.rows_written).to eq 2 + expect(enumerable).to receive(:count).and_call_original + expect(subject.rows_expected).to eq 3 + end end - it 'is ignored by default' do - expect(subject.render.length).to be > row_size * 3 - expect(subject.rows_written).to eq 3 + it 'avoids loading all data in a single query' do + expect(enumerable).to receive(:find_each) + + subject.render end - it 'causes rows_expected to fall back to .count' do - subject.render(0) + it 'uses hash keys as headers' do + expect(csv_data).to start_with 'Q & A' + end - expect(fake_relation).to receive(:count).and_call_original - expect(subject.rows_expected).to eq 3 + it 'gets data by calling method provided as hash value' do + expect(csv_data).to include 'answer' end - end - it 'avoids loading all data in a single query' do - expect(fake_relation).to receive(:find_each) + it 'allows lamdas to look up more complicated data' do + expect(csv_data).to include 'rewsna' + end - subject.render - end + describe 'excel sanitization' do + let(:dangerous_title) { double(title: "=cmd|' /C calc'!A0 title", description: "*safe_desc") } + let(:dangerous_desc) { double(title: "*safe_title", description: "=cmd|' /C calc'!A0 desc") } + let(:items) { [dangerous_title, dangerous_desc] } + let(:subject) { described_class.new(enumerable, 'Title' => 'title', 'Description' => 'description') } + let(:csv_data) { subject.render } - it 'uses hash keys as headers' do - expect(csv_data).to start_with 'Q & A' - end + it 'sanitizes dangerous characters at the beginning of a column' do + expect(csv_data).to include "'=cmd|' /C calc'!A0 title" + expect(csv_data).to include "'=cmd|' /C calc'!A0 desc" + end - it 'gets data by calling method provided as hash value' do - expect(csv_data).to include 'answer' - end + it 'does not sanitize safe symbols at the beginning of a column' do + expect(csv_data).not_to include "'*safe_desc" + expect(csv_data).not_to include "'*safe_title" + end - it 'allows lamdas to look up more complicated data' do - expect(csv_data).to include 'rewsna' - end + context 'when dangerous characters are after a line break' do + let(:items) { [double(title: "Safe title", description: "With task list\n-[x] todo 1")] } - describe 'excel sanitization' do - let(:dangerous_title) { double(title: "=cmd|' /C calc'!A0 title", description: "*safe_desc") } - let(:dangerous_desc) { double(title: "*safe_title", description: "=cmd|' /C calc'!A0 desc") } - let(:fake_relation) { described_class::FakeRelation.new([dangerous_title, dangerous_desc]) } - let(:subject) { described_class.new(fake_relation, 'Title' => 'title', 'Description' => 'description') } - let(:csv_data) { subject.render } + it 'does not append single quote to description' do + builder = described_class.new(enumerable, 'Title' => 'title', 'Description' => 'description') - it 'sanitizes dangerous characters at the beginning of a column' do - expect(csv_data).to include "'=cmd|' /C calc'!A0 title" - expect(csv_data).to include "'=cmd|' /C calc'!A0 desc" - end + csv_data = builder.render - it 'does not sanitize safe symbols at the beginning of a column' do - expect(csv_data).not_to include "'*safe_desc" - expect(csv_data).not_to include "'*safe_title" + expect(csv_data).to eq("Title,Description\nSafe title,\"With task list\n-[x] todo 1\"\n") + end + end end + end - context 'when dangerous characters are after a line break' do - it 'does not append single quote to description' do - fake_object = double(title: "Safe title", description: "With task list\n-[x] todo 1") - fake_relation = described_class::FakeRelation.new([fake_object]) - builder = described_class.new(fake_relation, 'Title' => 'title', 'Description' => 'description') + context 'when ActiveRecord::Relation like object is given' do + let(:enumerable) { described_class::FakeRelation.new(items) } - csv_data = builder.render + before do + stub_const("#{described_class}::FakeRelation", Array) - expect(csv_data).to eq("Title,Description\nSafe title,\"With task list\n-[x] todo 1\"\n") + described_class::FakeRelation.class_eval do + def find_each(&block) + each(&block) + end end end + + it_behaves_like 'csv builder examples' + end + + context 'when Enumerable like object is given' do + let(:enumerable) { items } + + it_behaves_like 'csv builder examples' end end |