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.
data/bin/csv-find-error CHANGED
@@ -1,19 +1,21 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  require 'csv'
4
- require 'shellwords'
4
+ require 'rbconfig'
5
+
6
+ file = ARGV[0] || abort('Usage: csv-find-error <csv file>')
5
7
 
6
8
  prev_row = nil
7
9
  begin
8
- CSV.open(ARGV[0], 'rb').each { |row| prev_row = row }
10
+ CSV.open(file, 'rb') { |csv| csv.each { |row| prev_row = row } }
9
11
  rescue CSV::MalformedCSVError => e
10
12
  puts e.class.to_s + ': ' + e.message
11
13
  puts "previous row was #{prev_row}"
12
14
  if e.message =~ /line (\d+)/
13
15
  lineno = $1.to_i
14
- cmd = "csv-readline #{Shellwords.escape(ARGV[0])} #{lineno}"
15
- puts "running #{cmd}"
16
- system(cmd)
16
+ # run the csv-readline next to this script, with this gem's lib, rather than whichever is on the PATH
17
+ puts "running csv-readline #{file} #{lineno}"
18
+ system(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), File.join(__dir__, 'csv-readline'), file, lineno.to_s)
17
19
  end
18
20
  exit 1
19
21
  end
data/bin/csv-grep CHANGED
@@ -34,7 +34,7 @@ OptionParser.new do |opts|
34
34
  options[:limit] = v
35
35
  end
36
36
 
37
- opts.on('-i', '--ignore-case', 'Ignore case') do |v|
37
+ opts.on('-i', '--ignore-case', 'Ignore case') do
38
38
  options[:search_regex_options] = Regexp::IGNORECASE
39
39
  end
40
40
  end.parse!
@@ -55,7 +55,7 @@ search_regex =
55
55
  headers =
56
56
  case options[:headers]
57
57
  when :first
58
- [csv .headers.first]
58
+ [csv.headers.first]
59
59
  when :all
60
60
  csv.headers
61
61
  else
@@ -63,7 +63,7 @@ headers =
63
63
  end
64
64
 
65
65
  missing_headers = headers - csv.headers
66
- raise("unknown headers #{headers.join(', ')}") unless missing_headers.empty?
66
+ raise("unknown headers #{missing_headers.join(', ')}") unless missing_headers.empty?
67
67
 
68
68
  matching_row_proc = proc do |row|
69
69
  result = false
data/bin/csv-readline CHANGED
@@ -2,21 +2,10 @@
2
2
 
3
3
  require 'optparse'
4
4
 
5
- BYTE_ORDER_MARKS = {
6
- "\xEF\xBB\xBF".force_encoding('ASCII-8BIT') => 'UTF-8',
7
- "\xFE\xFF".force_encoding('ASCII-8BIT') => 'UTF-16',
8
- "\xFF\xFE".force_encoding('ASCII-8BIT') => 'UTF-16',
9
- "\x00\x00\xFE\xFF".force_encoding('ASCII-8BIT') => 'UTF-32',
10
- "\xFF\xFE\x00\x00".force_encoding('ASCII-8BIT') => 'UTF-32'
11
- }
5
+ require 'csv-utils'
12
6
 
13
7
  def strip_byte_order_mark(line)
14
- BYTE_ORDER_MARKS.keys.detect do |bom|
15
- if line =~ /\A#{bom}/
16
- return line.sub(/\A#{bom}/, '')
17
- end
18
- end
19
- line
8
+ CSVUtils::ByteOrderMark.strip(line)
20
9
  end
21
10
 
22
11
  def csv_parse_line(line)
@@ -87,7 +76,7 @@ end
87
76
 
88
77
  def parse_csv_row(file, lineno, number_of_lines)
89
78
  cnt = 1
90
- str = ''
79
+ str = +''
91
80
  file.each do |line|
92
81
  cnt += 1
93
82
  next if cnt < lineno
@@ -114,16 +103,15 @@ OptionParser.new do |opts|
114
103
  end
115
104
  end.parse!
116
105
 
117
- file = File.open(ARGV[0], 'rb')
118
106
  lineno = ARGV[1].to_i
119
107
  number_of_lines = (ARGV[2] || 1).to_i
120
108
 
121
109
  raise "no lineno specified" unless lineno > 0
122
110
 
123
- headers = strip_byte_order_mark(file.readline.strip).split(',')
124
-
125
- data = headers.zip(parse_csv_row(file, lineno, number_of_lines))
126
- file.close
111
+ data = File.open(ARGV[0], 'rb') do |file|
112
+ headers = strip_byte_order_mark(file.readline.strip).split(',')
113
+ headers.zip(parse_csv_row(file, lineno, number_of_lines))
114
+ end
127
115
 
128
116
  cnt = 0
129
117
  data.each do |k, (v, status)|
data/bin/csv-splitter CHANGED
@@ -15,6 +15,8 @@ OptionParser.new do |opts|
15
15
  end
16
16
 
17
17
  opts.on('-r', '--rows ROWS', Integer, 'Max number of rows per a CSV') do |v|
18
+ raise OptionParser::InvalidArgument, "--rows must be positive (#{v})" unless v.positive?
19
+
18
20
  options[:rows] = v
19
21
  end
20
22
 
@@ -36,20 +38,20 @@ def get_split_file_name(file, num_files, total_files)
36
38
  end
37
39
 
38
40
  csv_options = CSVUtils::CSVOptions.new(file)
41
+ # a single column file has no separator to detect
42
+ col_sep = csv_options.col_separator || ','
39
43
 
40
44
  csv = CSV.open(
41
45
  file,
42
46
  'rb',
43
- col_sep: csv_options.col_separator,
47
+ col_sep: col_sep,
44
48
  liberal_parsing: true
45
49
  )
46
50
 
47
51
  headers = options[:no_header] ? nil : csv.shift
48
52
 
49
53
  total_rows = 0
50
- while csv.shift
51
- total_rows +=1
52
- end
54
+ total_rows += 1 while csv.shift
53
55
 
54
56
  csv.rewind
55
57
  csv.shift if headers
@@ -66,7 +68,7 @@ append_row_proc = proc do |row|
66
68
  out.close if out
67
69
  num_rows = 0
68
70
  num_files += 1
69
- out = CSV.open(get_split_file_name(file, num_files, total_files), 'wb', col_sep: csv_options.col_separator)
71
+ out = CSV.open(get_split_file_name(file, num_files, total_files), 'wb', col_sep: col_sep)
70
72
  out << headers if headers
71
73
  end
72
74
 
@@ -78,4 +80,4 @@ while (row = csv.shift)
78
80
  append_row_proc.call(row)
79
81
  end
80
82
 
81
- out.close
83
+ out&.close
data/bin/csv-validator CHANGED
@@ -15,6 +15,8 @@ def utf8?(str)
15
15
  end
16
16
 
17
17
  def convert_to_utf8(str, current_encoding)
18
+ return nil unless current_encoding
19
+
18
20
  str.force_encoding(current_encoding)
19
21
  return nil unless str.valid_encoding?
20
22
 
@@ -29,53 +31,57 @@ def strip_bom!(col)
29
31
  col.sub!("\xEF\xBB\xBF".force_encoding('ASCII-8BIT'), '')
30
32
  end
31
33
 
32
- csv = CSV.open(ARGV[0], 'rb')
33
34
  id_column_name = ARGV[1]
35
+ # read bytes as is; reading as UTF-8 would raise on the values this script looks for
36
+ csv = CSV.open(ARGV[0] || abort('Usage: csv-validator <csv file> [<id header>]'), 'rb:BINARY')
37
+ out = nil
34
38
 
35
- headers = csv.shift
36
- strip_bom!(headers[0])
37
-
38
- id_column_name ||= headers[0]
39
- unless headers.include?(id_column_name)
40
- $stderr.puts("header #{id_column_name} not found in current set of headers")
41
- exit 1
42
- end
39
+ begin
40
+ headers = csv.shift || []
41
+ strip_bom!(headers[0]) if headers[0]
43
42
 
44
- id_column_num = headers.index(id_column_name)
43
+ id_column_name ||= headers[0]
44
+ unless headers.include?(id_column_name)
45
+ $stderr.puts("header #{id_column_name} not found in current set of headers")
46
+ exit 1
47
+ end
45
48
 
46
- out = nil
47
- out_proc = Proc.new do |row|
48
- out ||=
49
- begin
50
- out = CSV.open('utf8-correctsion.csv', 'wb')
51
- out << [id_column_name, 'Row', 'Col', 'Header', 'Value']
52
- out
53
- end
49
+ id_column_num = headers.index(id_column_name)
54
50
 
55
- out << row
56
- end
51
+ out_proc = proc do |row|
52
+ out ||=
53
+ begin
54
+ out = CSV.open('utf8-correction.csv', 'wb')
55
+ out << [id_column_name, 'Row', 'Col', 'Header', 'Value']
56
+ out
57
+ end
57
58
 
58
- csv_lineno = 1
59
+ out << row
60
+ end
59
61
 
60
- while (row = csv.shift)
61
- csv_lineno += 1
62
+ csv_lineno = 1
62
63
 
63
- unless row.size == headers.size
64
- $stderr.puts "row(#{csv_lineno}): invalid number of columns, expected #{headers.size} got #{row.size}"
65
- end
64
+ while (row = csv.shift)
65
+ csv_lineno += 1
66
66
 
67
- row.each_with_index do |col, idx|
68
- next if col.nil? || utf8?(col)
67
+ unless row.size == headers.size
68
+ $stderr.puts "row(#{csv_lineno}): invalid number of columns, expected #{headers.size} got #{row.size}"
69
+ end
69
70
 
70
- $stderr.puts "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: none UTF-8 characters found in \"#{col}\""
71
- if (col_utf8_encoded = convert_to_utf8(col, detect_encoding(col)))
72
- puts "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: converted to UTF-8 from #{detect_encoding(col)} \"#{col_utf8_encoded}\""
73
- out_proc.call [row[id_column_num], csv_lineno, (idx + 1), headers[idx], col_utf8_encoded]
74
- else
75
- $stderr.puts "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: unknown character encoding"
71
+ row.each_with_index do |col, idx|
72
+ next if col.nil? || utf8?(col)
73
+
74
+ $stderr.puts "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: non UTF-8 characters found in #{col.inspect}"
75
+ encoding = detect_encoding(col)
76
+ if (col_utf8_encoded = convert_to_utf8(col, encoding))
77
+ puts "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: converted to UTF-8 from #{encoding} \"#{col_utf8_encoded}\""
78
+ out_proc.call [row[id_column_num], csv_lineno, (idx + 1), headers[idx], col_utf8_encoded]
79
+ else
80
+ $stderr.puts "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: unknown character encoding"
81
+ end
76
82
  end
77
83
  end
84
+ ensure
85
+ csv.close
86
+ out&.close
78
87
  end
79
-
80
- csv.close
81
- out.close if out
data/lib/csv-utils.rb CHANGED
@@ -1,13 +1,18 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'csv'
2
4
 
3
- # Collection of tools for working with CSV files.
5
+ # Tools for comparing, sorting, transforming and debugging CSV files, including ones too large to load
6
+ # into memory. Each class is autoloaded on first use.
4
7
  module CSVUtils
8
+ autoload :ByteOrderMark, 'csv_utils/byte_order_mark'
5
9
  autoload :CSVCompare, 'csv_utils/csv_compare'
6
10
  autoload :CSVExtender, 'csv_utils/csv_extender'
7
11
  autoload :CSVIterator, 'csv_utils/csv_iterator'
8
12
  autoload :CSVOptions, 'csv_utils/csv_options'
9
13
  autoload :CSVReport, 'csv_utils/csv_report'
10
14
  autoload :CSVRow, 'csv_utils/csv_row'
15
+ autoload :CSVRowMatcher, 'csv_utils/csv_row_matcher'
11
16
  autoload :CSVSort, 'csv_utils/csv_sort'
12
17
  autoload :CSVTransformer, 'csv_utils/csv_transformer'
13
18
  autoload :CSVWrapper, 'csv_utils/csv_wrapper'
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # Detects and strips the byte order mark at the start of a CSV file.
5
+ module ByteOrderMark
6
+ # Byte order marks, as binary strings, to the encoding they indicate.
7
+ # This list is from https://en.wikipedia.org/wiki/Byte_order_mark.
8
+ # UTF-32 LE starts with the UTF-16 LE mark, so longer marks come first.
9
+ # @return [Hash{String => String}]
10
+ ENCODINGS = {
11
+ (+"\x00\x00\xFE\xFF").force_encoding('ASCII-8BIT').freeze => 'UTF-32',
12
+ (+"\xFF\xFE\x00\x00").force_encoding('ASCII-8BIT').freeze => 'UTF-32',
13
+ (+"\xEF\xBB\xBF").force_encoding('ASCII-8BIT').freeze => 'UTF-8',
14
+ (+"\xFE\xFF").force_encoding('ASCII-8BIT').freeze => 'UTF-16',
15
+ (+"\xFF\xFE").force_encoding('ASCII-8BIT').freeze => 'UTF-16'
16
+ }.freeze
17
+
18
+ # The byte order mark the string starts with. Compares bytes, so it works whatever the string's encoding is.
19
+ # @param str [String]
20
+ # @return [String, nil] the mark as a binary string
21
+ def self.detect(str)
22
+ prefix = str.byteslice(0, 4).b
23
+ ENCODINGS.keys.detect { |bom| prefix.start_with?(bom) }
24
+ end
25
+
26
+ # The string without its leading byte order mark, in its original encoding.
27
+ # @param str [String]
28
+ # @return [String]
29
+ def self.strip(str)
30
+ bom = detect(str)
31
+ bom ? str.byteslice(bom.bytesize..) : str
32
+ end
33
+ end
34
+ end
@@ -1,87 +1,117 @@
1
- # CSVUtils::CSVCompare purpose is to determine which rows in the secondary_data_file need to be created, deleted or updated
2
- # **requires both CSV files to be sorted on the same columns, CSVUtils::CSVSort can accomplish this
3
- # In order to receive updates, update_comparison_columns must configured or use inheritance and change the update_row? method
4
- class CSVUtils::CSVCompare
5
- # primary_data_file is the source of truth
6
- # compare_proc used to compare the id column(s)
7
- # update_comparison_columns column(s) to compare for equality, ex: updated_at, timestamp, hash
8
- # caveat: update_comparison_columns need to be in both csv files
9
- attr_reader :primary_data_file,
10
- :update_comparison_columns,
11
- :compare_proc
12
-
13
- def initialize(primary_data_file, update_comparison_columns=nil, &block)
14
- @primary_data_file = primary_data_file
15
- @update_comparison_columns = update_comparison_columns
16
- @compare_proc = block
17
- end
18
-
19
- def compare(secondary_data_file)
20
- src = CSV.open(primary_data_file, 'rb')
21
- src_headers = src.shift
22
- strip_bom!(src_headers[0])
23
- dest = CSV.open(secondary_data_file, 'rb')
24
- dest_headers = dest.shift
25
- strip_bom!(dest_headers[0])
26
-
27
- read_next_src = true
28
- read_next_dest = true
29
-
30
- while(!src.eof? || !dest.eof?)
31
- src_record = next_record_from_file(src_headers, src) if read_next_src
32
- dest_record = next_record_from_file(dest_headers, dest) if read_next_dest
33
-
34
- if ! src_record
35
- read_next_src = false
36
- read_next_dest = true
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # Compares two CSV files sorted on the same key and yields what it takes to make the secondary file
5
+ # match the primary one: records to create, update or delete. Sort both files first, {CSVSort} can do it.
6
+ #
7
+ # Updates are only reported for the update_comparison_columns; subclass and override update_row? for other rules.
8
+ #
9
+ # @example
10
+ # comparer = CSVUtils::CSVCompare.new('primary.csv', ['updated_at']) { |src, dest| src['id'].to_i <=> dest['id'].to_i }
11
+ # comparer.compare('secondary.csv') { |action, record| puts "#{action} #{record['id']}" }
12
+ class CSVCompare
13
+ # @return [String] path of the source of truth
14
+ attr_reader :primary_data_file
15
+ # @return [Array<String>, nil] columns, present in both files, that trigger an update when they differ
16
+ # (ex: updated_at, a timestamp or a hash of the row)
17
+ attr_reader :update_comparison_columns
18
+ # @return [Proc] compares the key columns of a primary and a secondary record, returning -1, 0 or 1
19
+ attr_reader :compare_proc
20
+ # @return [Hash] options passed to CSV.open for both files
21
+ attr_reader :csv_options
22
+
23
+ # @param primary_data_file [String] path of the source of truth
24
+ # @param update_comparison_columns [Array<String>, nil] columns compared on matching records;
25
+ # without them no updates are yielded
26
+ # @param csv_options [Hash] options passed to CSV.open for both files, ex: col_sep: "\t",
27
+ # or encoding: 'BINARY' to read bytes that aren't valid UTF-8
28
+ # @yieldparam src [Hash{String => String}] record from the primary file
29
+ # @yieldparam dest [Hash{String => String}] record from the secondary file
30
+ # @yieldreturn [Integer] negative, 0 or positive like <=>, using the same order both files are sorted by
31
+ def initialize(primary_data_file, update_comparison_columns = nil, csv_options = {}, &block)
32
+ @primary_data_file = primary_data_file
33
+ @update_comparison_columns = update_comparison_columns
34
+ @csv_options = csv_options
35
+ @compare_proc = block
36
+ end
37
37
 
38
- yield :delete, dest_record
39
- elsif ! dest_record
40
- read_next_src = true
41
- read_next_dest = false
38
+ # Walks both files and yields each difference.
39
+ # @param secondary_data_file [String] path of the file to bring in line with the primary file
40
+ # @yieldparam action [Symbol] :create (only in the primary file), :update (in both, with changed
41
+ # update_comparison_columns) or :delete (only in the secondary file)
42
+ # @yieldparam record [Hash{String => String}] the primary record for :create and :update,
43
+ # the secondary record for :delete
44
+ # @return [void]
45
+ def compare(secondary_data_file, &)
46
+ open_csv(primary_data_file) do |src, src_headers|
47
+ open_csv(secondary_data_file) do |dest, dest_headers|
48
+ compare_records(src, src_headers, dest, dest_headers, &)
49
+ end
50
+ end
51
+ end
42
52
 
43
- yield :create, src_record
44
- elsif compare_proc.call(src_record, dest_record) == 0
45
- read_next_src = true
46
- read_next_dest = true
53
+ private
54
+
55
+ # walks both sorted files at once, advancing whichever side is behind
56
+ def compare_records(src, src_headers, dest, dest_headers)
57
+ src_record = next_record(src_headers, src)
58
+ dest_record = next_record(dest_headers, dest)
59
+
60
+ while src_record || dest_record
61
+ case record_action(src_record, dest_record)
62
+ when :create
63
+ yield :create, src_record
64
+ src_record = next_record(src_headers, src)
65
+ when :delete
66
+ yield :delete, dest_record
67
+ dest_record = next_record(dest_headers, dest)
68
+ else
69
+ yield :update, src_record if update_row?(src_record, dest_record)
70
+ src_record = next_record(src_headers, src)
71
+ dest_record = next_record(dest_headers, dest)
72
+ end
73
+ end
74
+ end
47
75
 
48
- yield(:update, src_record) if update_row?(src_record, dest_record)
49
- elsif compare_proc.call(src_record, dest_record) > 0
50
- read_next_src = false
51
- read_next_dest = true
76
+ def record_action(src_record, dest_record)
77
+ return :delete unless src_record
78
+ return :create unless dest_record
52
79
 
53
- yield :delete, dest_record
80
+ result = compare_proc.call(src_record, dest_record)
81
+ if result.zero?
82
+ :match
83
+ elsif result.positive?
84
+ :delete
54
85
  else
55
- read_next_src = true
56
- read_next_dest = false
57
-
58
- yield :create, src_record
86
+ :create
59
87
  end
60
88
  end
61
89
 
62
- src.close
63
- dest.close
64
- end
65
-
66
- private
67
-
68
- def next_record_from_file(headers, file)
69
- return nil if file.eof?
90
+ def open_csv(file)
91
+ csv = CSV.open(file, 'rb', **csv_options)
92
+ yield csv, read_headers(csv)
93
+ ensure
94
+ csv&.close
95
+ end
70
96
 
71
- Hash[headers.zip(file.shift)]
72
- end
97
+ def read_headers(csv)
98
+ headers = csv.shift || []
99
+ headers[0] = ByteOrderMark.strip(headers[0]) if headers[0]
100
+ headers
101
+ end
73
102
 
74
- def update_row?(src_record, dest_record)
75
- return false unless update_comparison_columns
103
+ def next_record(headers, csv)
104
+ return unless (row = csv.shift)
76
105
 
77
- update_comparison_columns.each do |column_name|
78
- return true unless src_record[column_name] == dest_record[column_name]
106
+ headers.zip(row).to_h
79
107
  end
80
108
 
81
- false
82
- end
109
+ def update_row?(src_record, dest_record)
110
+ return false unless update_comparison_columns
83
111
 
84
- def strip_bom!(col)
85
- col.sub!("\xEF\xBB\xBF".force_encoding('ASCII-8BIT'), '')
112
+ update_comparison_columns.any? do |column_name|
113
+ src_record[column_name] != dest_record[column_name]
114
+ end
115
+ end
86
116
  end
87
117
  end
@@ -1,63 +1,88 @@
1
- # Utility class for appending data to a csv file.
2
- class CSVUtils::CSVExtender
3
- def initialize(src_csv, dest_csv, csv_options = {})
4
- @src_csv = CSVUtils::CSVWrapper.new(src_csv, 'rb', csv_options)
5
- @dest_csv = CSVUtils::CSVWrapper.new(dest_csv, 'wb', csv_options)
6
- end
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # Copies a CSV file to a new one with extra columns added to every row.
5
+ #
6
+ # @example
7
+ # extender = CSVUtils::CSVExtender.new('input.csv', 'output.csv')
8
+ # extender.append(['name_length']) { |row, headers| [row[headers.index('name')].size] }
9
+ class CSVExtender
10
+ # @param src_csv [String, CSV, #shift] path or CSV to read; paths are opened and closed by the extender
11
+ # @param dest_csv [String, CSV, #<<] path or CSV to write
12
+ # @param csv_options [Hash] options passed to CSV.open for paths
13
+ def initialize(src_csv, dest_csv, csv_options = {})
14
+ @src_csv = CSVUtils::CSVWrapper.new(src_csv, 'rb', csv_options)
15
+ @dest_csv = CSVUtils::CSVWrapper.new(dest_csv, 'wb', csv_options)
16
+ end
7
17
 
8
- def append(additional_headers)
9
- process(additional_headers) do |current_headers|
10
- while (row = @src_csv.shift)
11
- additional_columns = yield row, current_headers
12
- @dest_csv << (row + additional_columns)
18
+ # Appends columns one row at a time, then closes the files it opened.
19
+ # @param additional_headers [Array<String>, nil] headers to add; nil when the source has no header row
20
+ # @yieldparam row [Array<String>] the source row
21
+ # @yieldparam headers [Array<String>, nil] the source headers
22
+ # @yieldreturn [Array] values to append to the row
23
+ # @return [void]
24
+ def append(additional_headers)
25
+ process(additional_headers) do |current_headers|
26
+ while (row = @src_csv.shift)
27
+ additional_columns = yield row, current_headers
28
+ @dest_csv << (row + additional_columns)
29
+ end
13
30
  end
14
31
  end
15
- end
16
32
 
17
- def append_in_batches(additional_headers, batch_size = 1_000)
18
- process(additional_headers) do |current_headers|
19
- batch = []
33
+ # Appends columns a batch of rows at a time, for lookups that are cheaper in bulk
34
+ # (ex: one database query per batch), then closes the files it opened.
35
+ # @param additional_headers [Array<String>, nil] headers to add; nil when the source has no header row
36
+ # @param batch_size [Integer] rows per batch
37
+ # @yieldparam batch [Array<Array<String>>] the source rows
38
+ # @yieldparam headers [Array<String>, nil] the source headers
39
+ # @yieldreturn [Array<Array>] values to append, one array per row in the batch
40
+ # @return [void]
41
+ def append_in_batches(additional_headers, batch_size = 1_000)
42
+ process(additional_headers) do |current_headers|
43
+ batch = []
44
+
45
+ process_batch_proc = proc do
46
+ additional_rows = yield batch, current_headers
20
47
 
21
- process_batch_proc = Proc.new do
22
- additional_rows = yield batch, current_headers
48
+ batch.each_with_index do |row, idx|
49
+ @dest_csv << (row + additional_rows[idx])
50
+ end
23
51
 
24
- batch.each_with_index do |row, idx|
25
- @dest_csv << (row + additional_rows[idx])
52
+ batch = []
26
53
  end
27
54
 
28
- batch = []
29
- end
55
+ while (row = @src_csv.shift)
56
+ batch << row
30
57
 
31
- while (row = @src_csv.shift)
32
- batch << row
58
+ process_batch_proc.call if batch.size >= batch_size
59
+ end
33
60
 
34
- process_batch_proc.call if batch.size >= batch_size
61
+ process_batch_proc.call if batch.size.positive?
35
62
  end
36
-
37
- process_batch_proc.call if batch.size > 0
38
63
  end
39
- end
40
64
 
41
- private
65
+ private
42
66
 
43
- def process(additional_headers)
44
- current_headers = append_headers(additional_headers)
67
+ def process(additional_headers)
68
+ current_headers = append_headers(additional_headers)
45
69
 
46
- yield current_headers
47
-
48
- close
49
- end
70
+ yield current_headers
71
+ ensure
72
+ close
73
+ end
50
74
 
51
- def close
52
- @src_csv.close
53
- @dest_csv.close
54
- end
75
+ def close
76
+ @src_csv.close
77
+ @dest_csv.close
78
+ end
55
79
 
56
- def append_headers(additional_headers)
57
- return nil unless additional_headers
80
+ def append_headers(additional_headers)
81
+ return nil unless additional_headers
58
82
 
59
- current_headers = @src_csv.shift
60
- @dest_csv << (current_headers + additional_headers)
61
- current_headers
83
+ current_headers = @src_csv.shift
84
+ @dest_csv << (current_headers + additional_headers)
85
+ current_headers
86
+ end
62
87
  end
63
88
  end