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.
@@ -1,116 +1,156 @@
1
- require 'fileutils'
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
- def merge_sort_csv_files(src_csv_file1, src_csv_file2, dest_csv_file)
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
- append_row1_proc = Proc.new do
42
- dest << row1
43
- row1 = src1.shift
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
- append_row2_proc = Proc.new do
47
- dest << row2
48
- row2 = src2.shift
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
- while row1 || row2
52
- if row1.nil?
53
- append_row2_proc.call
54
- elsif row2.nil?
55
- append_row1_proc.call
56
- elsif yield(row1, row2) <= 0
57
- append_row1_proc.call
58
- else
59
- append_row2_proc.call
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
- src1.close
64
- src2.close
65
- dest.close
66
- end
67
-
68
- def create_sorted_csv_part_files(batch_size, &block)
69
- src = CSV.open(csv_file, 'rb', **csv_options)
70
-
71
- @headers = src.shift if has_headers
72
-
73
- batch = []
74
- create_batch_part_proc = Proc.new do
75
- batch.sort!(&block)
76
- @csv_part_files << "#{new_csv_file}.part.#{@csv_part_files.size}"
77
- CSV.open(@csv_part_files.last, 'wb', **csv_options) do |csv|
78
- csv << @headers if @headers
79
- batch.each { |row| csv << row }
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
- while (row = src.shift)
85
- batch << row
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
- create_batch_part_proc.call if batch.size > 0
131
+ while @csv_part_files.size > 1
132
+ file_merge_cnt += 1
90
133
 
91
- src.close
92
- end
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
- def merge_csv_part_files(&block)
95
- file_merge_cnt = 0
138
+ merge_sort_csv_files(csv_part_file1, csv_part_file2, @csv_part_files.last, &)
96
139
 
97
- while @csv_part_files.size > 1
98
- file_merge_cnt += 1
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
- File.unlink(csv_part_file1)
107
- File.unlink(csv_part_file2)
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
- if @csv_part_files.size > 0
111
- FileUtils.mv(@csv_part_files.last, new_csv_file)
112
- else
113
- FileUtils.cp(@csv_file, new_csv_file)
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
- # Transforms a CSV given a series of steps
2
- class CSVUtils::CSVTransformer
3
- attr_reader :headers
4
-
5
- def initialize(src_csv, dest_csv, csv_options = {})
6
- @src_csv = CSVUtils::CSVWrapper.new(src_csv, 'rb', csv_options)
7
- @dest_csv = CSVUtils::CSVWrapper.new(dest_csv, 'wb', csv_options)
8
- end
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
- def read_headers
11
- @headers = @src_csv.shift
12
- self
13
- end
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
- def additional_data(&block)
16
- steps << [:additional_data, @headers, block]
17
- self
18
- end
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
- def select(&block)
21
- steps << [:select, @headers, block]
22
- self
23
- end
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
- def reject(&block)
26
- steps << [:reject, @headers, block]
27
- self
28
- end
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
- def map(new_headers, &block)
31
- steps << [:map, @headers, block]
32
- @headers = new_headers
33
- self
34
- end
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
- def append(additional_headers, &block)
37
- steps << [:append, @headers, block]
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
- if additional_headers
40
- @headers += additional_headers
41
- else
42
- @headers = nil
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
- self
46
- end
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
- def each(&block)
49
- steps << [:each, @headers, block]
50
- self
51
- end
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
- def set_headers(headers)
54
- @headers = headers
55
- self
56
- end
107
+ @dest_csv << @headers if @headers
57
108
 
58
- def process(batch_size = 10_000, &block)
59
- batch = []
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
- @dest_csv << @headers if @headers
114
+ batch.each { |row| @dest_csv << row }
62
115
 
63
- steps_proc = Proc.new do
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
- batch.each { |row| @dest_csv << row }
119
+ while (row = @src_csv.shift)
120
+ batch << row
121
+ steps_proc.call if batch.size >= batch_size
122
+ end
69
123
 
70
- batch = []
124
+ steps_proc.call if batch.size.positive?
125
+ ensure
126
+ @src_csv.close
127
+ @dest_csv.close
71
128
  end
72
-
73
- while (row = @src_csv.shift)
74
- batch << row
75
- steps_proc.call if batch.size >= batch_size
129
+
130
+ private
131
+
132
+ def steps
133
+ @steps ||= []
76
134
  end
77
135
 
78
- steps_proc.call if batch.size > 0
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
- @src_csv.close
81
- @dest_csv.close
82
- end
142
+ def process_select_step(current_headers, batch)
143
+ batch.select! { |row| yield row, current_headers, @additional_data }
144
+ end
83
145
 
84
- private
146
+ def process_reject_step(current_headers, batch)
147
+ batch.reject! { |row| yield row, current_headers, @additional_data }
148
+ end
85
149
 
86
- def steps
87
- @steps ||= []
88
- end
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
- def process_step(step_type, current_headers, batch, &block)
92
- case step_type
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
- # Wraps a CSV object, if wrapper opens the csv file it will close it
2
- class CSVUtils::CSVWrapper
3
- attr_reader :csv
1
+ # frozen_string_literal: true
4
2
 
5
- def initialize(csv, mode, csv_options)
6
- open(csv, mode, csv_options)
7
- end
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
- def self.open(file, mode, csv_options = {})
10
- csv = new(file, mode, csv_options)
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
- if block_given?
13
- yield csv
14
- csv.close
15
- else
16
- csv
27
+ begin
28
+ yield csv
29
+ ensure
30
+ csv.close
31
+ end
17
32
  end
18
- end
19
33
 
20
- def open(csv, mode, csv_options)
21
- if csv.is_a?(String)
22
- @close_when_done = true
23
- @csv = CSV.open(csv, mode, **csv_options)
24
- else
25
- @close_when_done = false
26
- @csv = csv
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
- def <<(row)
31
- csv << row
32
- end
46
+ # @param row [Array]
47
+ # @return [CSV, #<<]
48
+ def <<(row)
49
+ csv << row
50
+ end
33
51
 
34
- def shift
35
- csv.shift
36
- end
52
+ # @return [Array<String>, nil] the next row, nil at the end
53
+ def shift
54
+ csv.shift
55
+ end
37
56
 
38
- def rewind
39
- csv.rewind
40
- end
57
+ # Moves back to the first row.
58
+ # @return [void]
59
+ def rewind
60
+ csv.rewind
61
+ end
41
62
 
42
- def close
43
- csv.close if close_when_done?
44
- end
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
- private
69
+ private
47
70
 
48
- def close_when_done?
49
- @close_when_done
71
+ def close_when_done?
72
+ @close_when_done
73
+ end
50
74
  end
51
75
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # Gem version, bumped by release-please
5
+ VERSION = '0.6.0'
6
+ end