csv-utils 0.3.25 → 0.6.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 +37 -0
- data/README.md +316 -19
- data/bin/csv-change-eol +4 -8
- data/bin/csv-diff +22 -38
- data/bin/csv-duplicate-finder +18 -15
- data/bin/csv-find-error +7 -5
- data/bin/csv-grep +3 -3
- data/bin/csv-readline +7 -19
- data/bin/csv-splitter +8 -6
- data/bin/csv-validator +43 -37
- data/lib/csv-utils.rb +6 -1
- data/lib/csv_utils/byte_order_mark.rb +34 -0
- data/lib/csv_utils/csv_compare.rb +101 -71
- data/lib/csv_utils/csv_extender.rb +68 -43
- data/lib/csv_utils/csv_iterator.rb +120 -78
- data/lib/csv_utils/csv_options.rb +69 -31
- data/lib/csv_utils/csv_report.rb +31 -5
- data/lib/csv_utils/csv_row.rb +33 -1
- data/lib/csv_utils/csv_row_matcher.rb +48 -0
- data/lib/csv_utils/csv_sort.rb +134 -94
- data/lib/csv_utils/csv_transformer.rb +137 -90
- data/lib/csv_utils/csv_wrapper.rb +61 -37
- data/lib/csv_utils/version.rb +6 -0
- metadata +30 -19
- data/.gitignore +0 -52
- data/.ruby-gemset +0 -1
- data/.ruby-version +0 -1
- data/Gemfile +0 -13
- data/Gemfile.lock +0 -72
- data/csv-utils.gemspec +0 -18
- data/docs/ARCHITECTURE.md +0 -134
- data/script/console +0 -7
data/lib/csv_utils/csv_sort.rb
CHANGED
|
@@ -1,116 +1,156 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
# Utility class for sorting the rows for a csv file
|
|
4
|
-
class CSVUtils::CSVSort
|
|
5
|
-
attr_reader :csv_file,
|
|
6
|
-
:new_csv_file,
|
|
7
|
-
:has_headers,
|
|
8
|
-
:csv_options,
|
|
9
|
-
:headers
|
|
10
|
-
|
|
11
|
-
def initialize(csv_file, new_csv_file, has_headers = true, csv_options = {})
|
|
12
|
-
@csv_file = csv_file
|
|
13
|
-
@new_csv_file = new_csv_file
|
|
14
|
-
@has_headers = has_headers
|
|
15
|
-
@csv_options = csv_options
|
|
16
|
-
@csv_part_files = []
|
|
17
|
-
@files_to_delete = []
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def sort(batch_size = 100_000, &block)
|
|
21
|
-
create_sorted_csv_part_files(batch_size, &block)
|
|
22
|
-
merge_csv_part_files(&block)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
private
|
|
1
|
+
# frozen_string_literal: true
|
|
26
2
|
|
|
27
|
-
|
|
28
|
-
src1 = CSV.open(src_csv_file1, 'rb', **csv_options)
|
|
29
|
-
src2 = CSV.open(src_csv_file2, 'rb', **csv_options)
|
|
30
|
-
dest = CSV.open(dest_csv_file, 'wb', **csv_options)
|
|
31
|
-
|
|
32
|
-
if @headers
|
|
33
|
-
dest << @headers
|
|
34
|
-
src1.shift
|
|
35
|
-
src2.shift
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
row1 = src1.shift
|
|
39
|
-
row2 = src2.shift
|
|
3
|
+
require 'fileutils'
|
|
40
4
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
5
|
+
module CSVUtils
|
|
6
|
+
# Sorts a CSV file too large to load into memory with an external merge sort: batches of rows are
|
|
7
|
+
# sorted into temporary part files next to the new file, which are then merged in pairs.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# CSVUtils::CSVSort.new('input.csv', 'sorted.csv').sort { |a, b| a[0].to_i <=> b[0].to_i }
|
|
11
|
+
class CSVSort
|
|
12
|
+
# @return [String] path of the file to sort
|
|
13
|
+
attr_reader :csv_file
|
|
14
|
+
# @return [String] path of the sorted file to write
|
|
15
|
+
attr_reader :new_csv_file
|
|
16
|
+
# @return [Boolean] whether the first row is a header row, kept at the top
|
|
17
|
+
attr_reader :has_headers
|
|
18
|
+
# @return [Hash] options passed to CSV.open
|
|
19
|
+
attr_reader :csv_options
|
|
20
|
+
# @return [Array<String>, nil] the header row, once {#sort} has read it
|
|
21
|
+
attr_reader :headers
|
|
22
|
+
|
|
23
|
+
# @param csv_file [String] path of the file to sort
|
|
24
|
+
# @param new_csv_file [String] path of the sorted file to write
|
|
25
|
+
# @param has_headers [Boolean] whether the first row is a header row
|
|
26
|
+
# @param csv_options [Hash] options passed to CSV.open
|
|
27
|
+
def initialize(csv_file, new_csv_file, has_headers = true, csv_options = {})
|
|
28
|
+
@csv_file = csv_file
|
|
29
|
+
@new_csv_file = new_csv_file
|
|
30
|
+
@has_headers = has_headers
|
|
31
|
+
@csv_options = csv_options
|
|
32
|
+
@csv_part_files = []
|
|
44
33
|
end
|
|
45
34
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
35
|
+
# Writes the sorted file. Temporary files are removed even when sorting fails.
|
|
36
|
+
# @param batch_size [Integer] rows held in memory and sorted at a time
|
|
37
|
+
# @yieldparam row1 [Array<String>]
|
|
38
|
+
# @yieldparam row2 [Array<String>]
|
|
39
|
+
# @yieldreturn [Integer] negative, 0 or positive like <=>; without a block rows are compared as arrays
|
|
40
|
+
# @return [void]
|
|
41
|
+
def sort(batch_size = 100_000, &block)
|
|
42
|
+
block ||= proc { |row1, row2| row1 <=> row2 }
|
|
43
|
+
@csv_part_files = []
|
|
44
|
+
create_sorted_csv_part_files(batch_size, &block)
|
|
45
|
+
merge_csv_part_files(&block)
|
|
46
|
+
ensure
|
|
47
|
+
delete_csv_part_files
|
|
49
48
|
end
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
53
|
+
def merge_sort_csv_files(src_csv_file1, src_csv_file2, dest_csv_file)
|
|
54
|
+
src1 = CSV.open(src_csv_file1, 'rb', **csv_options)
|
|
55
|
+
begin
|
|
56
|
+
src2 = CSV.open(src_csv_file2, 'rb', **csv_options)
|
|
57
|
+
begin
|
|
58
|
+
dest = CSV.open(dest_csv_file, 'wb', **csv_options)
|
|
59
|
+
begin
|
|
60
|
+
if @headers
|
|
61
|
+
dest << @headers
|
|
62
|
+
src1.shift
|
|
63
|
+
src2.shift
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
row1 = src1.shift
|
|
67
|
+
row2 = src2.shift
|
|
68
|
+
|
|
69
|
+
append_row1_proc = proc do
|
|
70
|
+
dest << row1
|
|
71
|
+
row1 = src1.shift
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
append_row2_proc = proc do
|
|
75
|
+
dest << row2
|
|
76
|
+
row2 = src2.shift
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
while row1 || row2
|
|
80
|
+
if row1.nil?
|
|
81
|
+
append_row2_proc.call
|
|
82
|
+
elsif row2.nil?
|
|
83
|
+
append_row1_proc.call
|
|
84
|
+
elsif yield(row1, row2) <= 0
|
|
85
|
+
append_row1_proc.call
|
|
86
|
+
else
|
|
87
|
+
append_row2_proc.call
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
ensure
|
|
91
|
+
dest.close
|
|
92
|
+
end
|
|
93
|
+
ensure
|
|
94
|
+
src2.close
|
|
95
|
+
end
|
|
96
|
+
ensure
|
|
97
|
+
src1.close
|
|
60
98
|
end
|
|
61
99
|
end
|
|
62
100
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
101
|
+
def create_sorted_csv_part_files(batch_size, &block)
|
|
102
|
+
src = CSV.open(csv_file, 'rb', **csv_options)
|
|
103
|
+
begin
|
|
104
|
+
@headers = src.shift if has_headers
|
|
105
|
+
|
|
106
|
+
batch = []
|
|
107
|
+
create_batch_part_proc = proc do
|
|
108
|
+
batch.sort!(&block)
|
|
109
|
+
@csv_part_files << "#{new_csv_file}.part.#{@csv_part_files.size}"
|
|
110
|
+
CSV.open(@csv_part_files.last, 'wb', **csv_options) do |csv|
|
|
111
|
+
csv << @headers if @headers
|
|
112
|
+
batch.each { |row| csv << row }
|
|
113
|
+
end
|
|
114
|
+
batch = []
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
while (row = src.shift)
|
|
118
|
+
batch << row
|
|
119
|
+
create_batch_part_proc.call if batch.size >= batch_size
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
create_batch_part_proc.call if batch.size.positive?
|
|
123
|
+
ensure
|
|
124
|
+
src.close
|
|
80
125
|
end
|
|
81
|
-
batch = []
|
|
82
126
|
end
|
|
83
127
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
create_batch_part_proc.call if batch.size >= batch_size
|
|
87
|
-
end
|
|
128
|
+
def merge_csv_part_files(&)
|
|
129
|
+
file_merge_cnt = 0
|
|
88
130
|
|
|
89
|
-
|
|
131
|
+
while @csv_part_files.size > 1
|
|
132
|
+
file_merge_cnt += 1
|
|
90
133
|
|
|
91
|
-
|
|
92
|
-
|
|
134
|
+
# inputs stay in the list until merged, so a failed merge still cleans them up
|
|
135
|
+
csv_part_file1, csv_part_file2 = @csv_part_files.first(2)
|
|
136
|
+
@csv_part_files << "#{new_csv_file}.merge.#{file_merge_cnt}"
|
|
93
137
|
|
|
94
|
-
|
|
95
|
-
file_merge_cnt = 0
|
|
138
|
+
merge_sort_csv_files(csv_part_file1, csv_part_file2, @csv_part_files.last, &)
|
|
96
139
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
csv_part_file1 = @csv_part_files.shift
|
|
101
|
-
csv_part_file2 = @csv_part_files.shift
|
|
102
|
-
@csv_part_files << "#{new_csv_file}.merge.#{file_merge_cnt}"
|
|
103
|
-
|
|
104
|
-
merge_sort_csv_files(csv_part_file1, csv_part_file2, @csv_part_files.last, &block)
|
|
140
|
+
@csv_part_files.shift(2).each { |file| File.unlink(file) }
|
|
141
|
+
end
|
|
105
142
|
|
|
106
|
-
|
|
107
|
-
|
|
143
|
+
if @csv_part_files.size.positive?
|
|
144
|
+
FileUtils.mv(@csv_part_files.pop, new_csv_file)
|
|
145
|
+
else
|
|
146
|
+
FileUtils.cp(@csv_file, new_csv_file)
|
|
147
|
+
end
|
|
108
148
|
end
|
|
109
149
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
150
|
+
# removes the temporary files left behind when sorting fails part way through
|
|
151
|
+
def delete_csv_part_files
|
|
152
|
+
@csv_part_files.each { |file| FileUtils.rm_f(file) }
|
|
153
|
+
@csv_part_files = []
|
|
114
154
|
end
|
|
115
155
|
end
|
|
116
156
|
end
|
|
@@ -1,119 +1,166 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CSVUtils
|
|
4
|
+
# Copies a CSV file through a pipeline of steps that filter, change and add to its rows in batches.
|
|
5
|
+
# Each step method returns the transformer so they can be chained, and {#process} runs them.
|
|
6
|
+
#
|
|
7
|
+
# Step blocks receive the row, the headers as they were when the step was added, and the value
|
|
8
|
+
# computed by the last {#additional_data} step for the current batch.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# CSVUtils::CSVTransformer.new('input.csv', 'output.csv')
|
|
12
|
+
# .read_headers
|
|
13
|
+
# .select { |row, _headers, _data| row[0].to_i > 100 }
|
|
14
|
+
# .append(['Email']) { |row, _headers, _data| ["#{row[1].downcase}@example.com"] }
|
|
15
|
+
# .process
|
|
16
|
+
class CSVTransformer
|
|
17
|
+
# @return [Array<String>, nil] the headers the output will have after the steps added so far
|
|
18
|
+
attr_reader :headers
|
|
19
|
+
|
|
20
|
+
# @param src_csv [String, CSV, #shift] path or CSV to read; paths are opened and closed by the transformer
|
|
21
|
+
# @param dest_csv [String, CSV, #<<] path or CSV to write
|
|
22
|
+
# @param csv_options [Hash] options passed to CSV.open for paths
|
|
23
|
+
def initialize(src_csv, dest_csv, csv_options = {})
|
|
24
|
+
@src_csv = CSVUtils::CSVWrapper.new(src_csv, 'rb', csv_options)
|
|
25
|
+
@dest_csv = CSVUtils::CSVWrapper.new(dest_csv, 'wb', csv_options)
|
|
26
|
+
end
|
|
9
27
|
|
|
10
|
-
|
|
11
|
-
@
|
|
12
|
-
|
|
13
|
-
|
|
28
|
+
# Reads the first row as the headers. Without it, every row is data and the output has no header row.
|
|
29
|
+
# @return [self]
|
|
30
|
+
def read_headers
|
|
31
|
+
@headers = @src_csv.shift
|
|
32
|
+
self
|
|
33
|
+
end
|
|
14
34
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
35
|
+
# Computes a value once per batch, passed to the steps that follow as their third argument.
|
|
36
|
+
# @yieldparam batch [Array<Array<String>>]
|
|
37
|
+
# @yieldparam headers [Array<String>, nil]
|
|
38
|
+
# @return [self]
|
|
39
|
+
def additional_data(&block)
|
|
40
|
+
steps << [:additional_data, @headers, block]
|
|
41
|
+
self
|
|
42
|
+
end
|
|
19
43
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
44
|
+
# Keeps the rows the block returns true for.
|
|
45
|
+
# @yieldparam row [Array<String>]
|
|
46
|
+
# @yieldparam headers [Array<String>, nil]
|
|
47
|
+
# @yieldparam additional_data [Object]
|
|
48
|
+
# @return [self]
|
|
49
|
+
def select(&block)
|
|
50
|
+
steps << [:select, @headers, block]
|
|
51
|
+
self
|
|
52
|
+
end
|
|
24
53
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
self
|
|
28
|
-
|
|
54
|
+
# Drops the rows the block returns true for.
|
|
55
|
+
# @yieldparam (see #select)
|
|
56
|
+
# @return [self]
|
|
57
|
+
def reject(&block)
|
|
58
|
+
steps << [:reject, @headers, block]
|
|
59
|
+
self
|
|
60
|
+
end
|
|
29
61
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
@
|
|
33
|
-
|
|
34
|
-
|
|
62
|
+
# Replaces each row with the block's result.
|
|
63
|
+
# @param new_headers [Array<String>, nil] the headers after this step
|
|
64
|
+
# @yieldparam (see #select)
|
|
65
|
+
# @yieldreturn [Array] the new row
|
|
66
|
+
# @return [self]
|
|
67
|
+
def map(new_headers, &block)
|
|
68
|
+
steps << [:map, @headers, block]
|
|
69
|
+
@headers = new_headers
|
|
70
|
+
self
|
|
71
|
+
end
|
|
35
72
|
|
|
36
|
-
|
|
37
|
-
|
|
73
|
+
# Adds the block's values to the end of each row.
|
|
74
|
+
# @param additional_headers [Array<String>, nil] headers for the new values; nil drops the header row
|
|
75
|
+
# @yieldparam (see #select)
|
|
76
|
+
# @yieldreturn [Array] values to append
|
|
77
|
+
# @return [self]
|
|
78
|
+
def append(additional_headers, &block)
|
|
79
|
+
steps << [:append, @headers, block]
|
|
80
|
+
# without headers (read_headers not called) the output has no header row to extend
|
|
81
|
+
@headers = (@headers + additional_headers if @headers && additional_headers)
|
|
82
|
+
self
|
|
83
|
+
end
|
|
38
84
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
85
|
+
# Calls the block for each row without changing it.
|
|
86
|
+
# @yieldparam (see #select)
|
|
87
|
+
# @return [self]
|
|
88
|
+
def each(&block)
|
|
89
|
+
steps << [:each, @headers, block]
|
|
90
|
+
self
|
|
43
91
|
end
|
|
44
92
|
|
|
45
|
-
|
|
46
|
-
|
|
93
|
+
# Replaces the headers written to the output.
|
|
94
|
+
# @param headers [Array<String>, nil]
|
|
95
|
+
# @return [self]
|
|
96
|
+
def set_headers(headers)
|
|
97
|
+
@headers = headers
|
|
98
|
+
self
|
|
99
|
+
end
|
|
47
100
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
101
|
+
# Runs the steps over every row, writes the output and closes the files it opened, even when a step raises.
|
|
102
|
+
# @param batch_size [Integer] rows read and run through the steps at a time
|
|
103
|
+
# @return [void]
|
|
104
|
+
def process(batch_size = 10_000)
|
|
105
|
+
batch = []
|
|
52
106
|
|
|
53
|
-
|
|
54
|
-
@headers = headers
|
|
55
|
-
self
|
|
56
|
-
end
|
|
107
|
+
@dest_csv << @headers if @headers
|
|
57
108
|
|
|
58
|
-
|
|
59
|
-
|
|
109
|
+
steps_proc = proc do
|
|
110
|
+
steps.each do |step_type, current_headers, proc|
|
|
111
|
+
batch = process_step(step_type, current_headers, batch, &proc)
|
|
112
|
+
end
|
|
60
113
|
|
|
61
|
-
|
|
114
|
+
batch.each { |row| @dest_csv << row }
|
|
62
115
|
|
|
63
|
-
|
|
64
|
-
steps.each do |step_type, current_headers, proc|
|
|
65
|
-
batch = process_step(step_type, current_headers, batch, &proc)
|
|
116
|
+
batch = []
|
|
66
117
|
end
|
|
67
118
|
|
|
68
|
-
|
|
119
|
+
while (row = @src_csv.shift)
|
|
120
|
+
batch << row
|
|
121
|
+
steps_proc.call if batch.size >= batch_size
|
|
122
|
+
end
|
|
69
123
|
|
|
70
|
-
|
|
124
|
+
steps_proc.call if batch.size.positive?
|
|
125
|
+
ensure
|
|
126
|
+
@src_csv.close
|
|
127
|
+
@dest_csv.close
|
|
71
128
|
end
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def steps
|
|
133
|
+
@steps ||= []
|
|
76
134
|
end
|
|
77
135
|
|
|
78
|
-
|
|
136
|
+
# each step type has a process_<type>_step method that changes the batch in place
|
|
137
|
+
def process_step(step_type, current_headers, batch, &)
|
|
138
|
+
send(:"process_#{step_type}_step", current_headers, batch, &)
|
|
139
|
+
batch
|
|
140
|
+
end
|
|
79
141
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
142
|
+
def process_select_step(current_headers, batch)
|
|
143
|
+
batch.select! { |row| yield row, current_headers, @additional_data }
|
|
144
|
+
end
|
|
83
145
|
|
|
84
|
-
|
|
146
|
+
def process_reject_step(current_headers, batch)
|
|
147
|
+
batch.reject! { |row| yield row, current_headers, @additional_data }
|
|
148
|
+
end
|
|
85
149
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
150
|
+
def process_map_step(current_headers, batch)
|
|
151
|
+
batch.map! { |row| yield row, current_headers, @additional_data }
|
|
152
|
+
end
|
|
89
153
|
|
|
154
|
+
def process_append_step(current_headers, batch)
|
|
155
|
+
batch.map! { |row| row + yield(row, current_headers, @additional_data) }
|
|
156
|
+
end
|
|
90
157
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
when :select
|
|
94
|
-
batch.select! do |row|
|
|
95
|
-
block.call row, current_headers, @additional_data
|
|
96
|
-
end
|
|
97
|
-
when :reject
|
|
98
|
-
batch.reject! do |row|
|
|
99
|
-
block.call row, current_headers, @additional_data
|
|
100
|
-
end
|
|
101
|
-
when :map
|
|
102
|
-
batch.map! do |row|
|
|
103
|
-
block.call row, current_headers, @additional_data
|
|
104
|
-
end
|
|
105
|
-
when :append
|
|
106
|
-
batch.map! do |row|
|
|
107
|
-
row + block.call(row, current_headers, @additional_data)
|
|
108
|
-
end
|
|
109
|
-
when :additional_data
|
|
110
|
-
@additional_data = block.call(batch, current_headers)
|
|
111
|
-
when :each
|
|
112
|
-
batch.each do |row|
|
|
113
|
-
block.call(row, current_headers, @additional_data)
|
|
114
|
-
end
|
|
158
|
+
def process_additional_data_step(current_headers, batch)
|
|
159
|
+
@additional_data = yield batch, current_headers
|
|
115
160
|
end
|
|
116
161
|
|
|
117
|
-
batch
|
|
162
|
+
def process_each_step(current_headers, batch)
|
|
163
|
+
batch.each { |row| yield row, current_headers, @additional_data }
|
|
164
|
+
end
|
|
118
165
|
end
|
|
119
166
|
end
|
|
@@ -1,51 +1,75 @@
|
|
|
1
|
-
#
|
|
2
|
-
class CSVUtils::CSVWrapper
|
|
3
|
-
attr_reader :csv
|
|
1
|
+
# frozen_string_literal: true
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
module CSVUtils
|
|
4
|
+
# Wraps a path or an open CSV so callers can read, write and close it the same way.
|
|
5
|
+
# It closes only the files it opened itself.
|
|
6
|
+
class CSVWrapper
|
|
7
|
+
# @return [CSV, #shift, #<<] the CSV being wrapped
|
|
8
|
+
attr_reader :csv
|
|
9
|
+
|
|
10
|
+
# @param csv [String, CSV, #shift, #<<] path to open, or a CSV (or array of rows) to use as is
|
|
11
|
+
# @param mode [String] file mode for a path
|
|
12
|
+
# @param csv_options [Hash] options passed to CSV.open for a path
|
|
13
|
+
def initialize(csv, mode, csv_options)
|
|
14
|
+
open(csv, mode, csv_options)
|
|
15
|
+
end
|
|
8
16
|
|
|
9
|
-
|
|
10
|
-
|
|
17
|
+
# Wraps a path or CSV; with a block, yields the wrapper and closes it afterwards, even when the block raises.
|
|
18
|
+
# @param file [String, CSV] path or CSV
|
|
19
|
+
# @param mode [String] file mode for a path
|
|
20
|
+
# @param csv_options [Hash] options passed to CSV.open for a path
|
|
21
|
+
# @yieldparam csv [CSVWrapper]
|
|
22
|
+
# @return [CSVWrapper, Object] the wrapper, or the block's value
|
|
23
|
+
def self.open(file, mode, csv_options = {})
|
|
24
|
+
csv = new(file, mode, csv_options)
|
|
25
|
+
return csv unless block_given?
|
|
11
26
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
27
|
+
begin
|
|
28
|
+
yield csv
|
|
29
|
+
ensure
|
|
30
|
+
csv.close
|
|
31
|
+
end
|
|
17
32
|
end
|
|
18
|
-
end
|
|
19
33
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
# @api private
|
|
35
|
+
# @return [CSV, #shift, #<<]
|
|
36
|
+
def open(csv, mode, csv_options)
|
|
37
|
+
if csv.is_a?(String)
|
|
38
|
+
@close_when_done = true
|
|
39
|
+
@csv = CSV.open(csv, mode, **csv_options)
|
|
40
|
+
else
|
|
41
|
+
@close_when_done = false
|
|
42
|
+
@csv = csv
|
|
43
|
+
end
|
|
27
44
|
end
|
|
28
|
-
end
|
|
29
45
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
# @param row [Array]
|
|
47
|
+
# @return [CSV, #<<]
|
|
48
|
+
def <<(row)
|
|
49
|
+
csv << row
|
|
50
|
+
end
|
|
33
51
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
52
|
+
# @return [Array<String>, nil] the next row, nil at the end
|
|
53
|
+
def shift
|
|
54
|
+
csv.shift
|
|
55
|
+
end
|
|
37
56
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
57
|
+
# Moves back to the first row.
|
|
58
|
+
# @return [void]
|
|
59
|
+
def rewind
|
|
60
|
+
csv.rewind
|
|
61
|
+
end
|
|
41
62
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
63
|
+
# Closes the CSV if the wrapper opened it.
|
|
64
|
+
# @return [void]
|
|
65
|
+
def close
|
|
66
|
+
csv.close if close_when_done?
|
|
67
|
+
end
|
|
45
68
|
|
|
46
|
-
|
|
69
|
+
private
|
|
47
70
|
|
|
48
|
-
|
|
49
|
-
|
|
71
|
+
def close_when_done?
|
|
72
|
+
@close_when_done
|
|
73
|
+
end
|
|
50
74
|
end
|
|
51
75
|
end
|