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/bin/csv-find-error
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
|
|
3
3
|
require 'csv'
|
|
4
|
-
require '
|
|
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(
|
|
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
|
-
|
|
15
|
-
puts "running #{
|
|
16
|
-
system(
|
|
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
|
|
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
|
|
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 #{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
+
out << row
|
|
60
|
+
end
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
csv_lineno += 1
|
|
62
|
+
csv_lineno = 1
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
end
|
|
64
|
+
while (row = csv.shift)
|
|
65
|
+
csv_lineno += 1
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
read_next_dest = false
|
|
57
|
-
|
|
58
|
-
yield :create, src_record
|
|
86
|
+
:create
|
|
59
87
|
end
|
|
60
88
|
end
|
|
61
89
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
103
|
+
def next_record(headers, csv)
|
|
104
|
+
return unless (row = csv.shift)
|
|
76
105
|
|
|
77
|
-
|
|
78
|
-
return true unless src_record[column_name] == dest_record[column_name]
|
|
106
|
+
headers.zip(row).to_h
|
|
79
107
|
end
|
|
80
108
|
|
|
81
|
-
|
|
82
|
-
|
|
109
|
+
def update_row?(src_record, dest_record)
|
|
110
|
+
return false unless update_comparison_columns
|
|
83
111
|
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
48
|
+
batch.each_with_index do |row, idx|
|
|
49
|
+
@dest_csv << (row + additional_rows[idx])
|
|
50
|
+
end
|
|
23
51
|
|
|
24
|
-
|
|
25
|
-
@dest_csv << (row + additional_rows[idx])
|
|
52
|
+
batch = []
|
|
26
53
|
end
|
|
27
54
|
|
|
28
|
-
|
|
29
|
-
|
|
55
|
+
while (row = @src_csv.shift)
|
|
56
|
+
batch << row
|
|
30
57
|
|
|
31
|
-
|
|
32
|
-
|
|
58
|
+
process_batch_proc.call if batch.size >= batch_size
|
|
59
|
+
end
|
|
33
60
|
|
|
34
|
-
process_batch_proc.call if 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
|
-
|
|
65
|
+
private
|
|
42
66
|
|
|
43
|
-
|
|
44
|
-
|
|
67
|
+
def process(additional_headers)
|
|
68
|
+
current_headers = append_headers(additional_headers)
|
|
45
69
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
70
|
+
yield current_headers
|
|
71
|
+
ensure
|
|
72
|
+
close
|
|
73
|
+
end
|
|
50
74
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
75
|
+
def close
|
|
76
|
+
@src_csv.close
|
|
77
|
+
@dest_csv.close
|
|
78
|
+
end
|
|
55
79
|
|
|
56
|
-
|
|
57
|
-
|
|
80
|
+
def append_headers(additional_headers)
|
|
81
|
+
return nil unless additional_headers
|
|
58
82
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
83
|
+
current_headers = @src_csv.shift
|
|
84
|
+
@dest_csv << (current_headers + additional_headers)
|
|
85
|
+
current_headers
|
|
86
|
+
end
|
|
62
87
|
end
|
|
63
88
|
end
|