csv-utils 0.5.0 → 0.7.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 +55 -0
- data/README.md +115 -18
- data/bin/csv-change-eol +4 -8
- data/bin/csv-diff +19 -35
- data/bin/csv-duplicate-finder +17 -14
- data/bin/csv-find-error +7 -5
- data/bin/csv-readline +3 -14
- data/bin/csv-splitter +7 -5
- data/bin/csv-validator +10 -6
- data/lib/csv-utils.rb +5 -1
- data/lib/csv_utils/byte_order_mark.rb +34 -0
- data/lib/csv_utils/character_encoding.rb +23 -0
- data/lib/csv_utils/csv_compare.rb +89 -65
- data/lib/csv_utils/csv_extender.rb +26 -4
- data/lib/csv_utils/csv_iterator.rb +111 -44
- data/lib/csv_utils/csv_options.rb +117 -30
- data/lib/csv_utils/csv_report.rb +28 -4
- data/lib/csv_utils/csv_row.rb +30 -0
- data/lib/csv_utils/csv_row_matcher.rb +16 -2
- data/lib/csv_utils/csv_sort.rb +63 -24
- data/lib/csv_utils/csv_transformer.rb +82 -37
- data/lib/csv_utils/csv_wrapper.rb +24 -4
- data/lib/csv_utils/encoding_options.rb +40 -0
- data/lib/csv_utils/version.rb +6 -0
- metadata +29 -23
- data/.github/workflows/ci.yml +0 -53
- data/.gitignore +0 -52
- data/.rubocop.yml +0 -81
- data/.ruby-gemset +0 -1
- data/.ruby-version +0 -1
- data/ARCHITECTURE.md +0 -154
- data/CLAUDE.md +0 -63
- data/Gemfile +0 -14
- data/Gemfile.lock +0 -77
- data/csv-utils.gemspec +0 -19
- data/script/console +0 -7
|
@@ -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
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CSVUtils
|
|
4
|
+
# Guesses the character encoding of a file without a byte order mark from a sample of its bytes.
|
|
5
|
+
module CharacterEncoding
|
|
6
|
+
# Bytes Windows-1252 leaves undefined; converting them to UTF-8 raises.
|
|
7
|
+
# @return [Regexp]
|
|
8
|
+
WINDOWS_1252_UNDEFINED = /[\x81\x8D\x8F\x90\x9D]/n
|
|
9
|
+
|
|
10
|
+
# 'UTF-8' when the sample is valid UTF-8. Otherwise 'Windows-1252', the usual encoding of Excel exports,
|
|
11
|
+
# or 'ISO-8859-1' when the sample has a byte Windows-1252 leaves undefined, since every ISO-8859-1 byte
|
|
12
|
+
# converts to UTF-8.
|
|
13
|
+
# @param sample [String] bytes from the start of the file, ending on a whole character
|
|
14
|
+
# @return [String]
|
|
15
|
+
def self.detect(sample)
|
|
16
|
+
bytes = sample.b
|
|
17
|
+
return 'UTF-8' if bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding?
|
|
18
|
+
return 'ISO-8859-1' if bytes.match?(WINDOWS_1252_UNDEFINED)
|
|
19
|
+
|
|
20
|
+
'Windows-1252'
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -1,93 +1,117 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# CSVUtils::CSVCompare purpose is to determine which rows in the secondary_data_file need to be created, deleted or updated
|
|
4
|
-
# **requires both CSV files to be sorted on the same columns, CSVUtils::CSVSort can accomplish this
|
|
5
|
-
# In order to receive updates, update_comparison_columns must configured or use inheritance and change the update_row? method
|
|
6
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']}" }
|
|
7
12
|
class CSVCompare
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
attr_reader :
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
15
22
|
|
|
16
|
-
|
|
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; the :encoding defaults to 'bom|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)
|
|
17
32
|
@primary_data_file = primary_data_file
|
|
18
33
|
@update_comparison_columns = update_comparison_columns
|
|
34
|
+
@csv_options = csv_options
|
|
19
35
|
@compare_proc = block
|
|
20
36
|
end
|
|
21
37
|
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
32
52
|
|
|
33
|
-
|
|
34
|
-
read_next_dest = true
|
|
53
|
+
private
|
|
35
54
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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)
|
|
39
59
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
elsif compare_proc.call(src_record, dest_record).positive?
|
|
53
|
-
read_next_src = false
|
|
54
|
-
read_next_dest = true
|
|
55
|
-
yield :delete, dest_record
|
|
56
|
-
else
|
|
57
|
-
read_next_src = true
|
|
58
|
-
read_next_dest = false
|
|
59
|
-
yield :create, src_record
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
ensure
|
|
63
|
-
dest.close
|
|
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)
|
|
64
72
|
end
|
|
65
|
-
ensure
|
|
66
|
-
src.close
|
|
67
73
|
end
|
|
68
74
|
end
|
|
69
|
-
# rubocop:enable Metrics/MethodLength
|
|
70
75
|
|
|
71
|
-
|
|
76
|
+
def record_action(src_record, dest_record)
|
|
77
|
+
return :delete unless src_record
|
|
78
|
+
return :create unless dest_record
|
|
72
79
|
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
result = compare_proc.call(src_record, dest_record)
|
|
81
|
+
if result.zero?
|
|
82
|
+
:match
|
|
83
|
+
elsif result.positive?
|
|
84
|
+
:delete
|
|
85
|
+
else
|
|
86
|
+
:create
|
|
87
|
+
end
|
|
88
|
+
end
|
|
75
89
|
|
|
76
|
-
|
|
90
|
+
def open_csv(file)
|
|
91
|
+
csv = CSV.open(file, 'rb', **EncodingOptions.read(csv_options))
|
|
92
|
+
yield csv, read_headers(csv)
|
|
93
|
+
ensure
|
|
94
|
+
csv&.close
|
|
77
95
|
end
|
|
78
96
|
|
|
79
|
-
def
|
|
80
|
-
|
|
97
|
+
def read_headers(csv)
|
|
98
|
+
headers = csv.shift || []
|
|
99
|
+
headers[0] = ByteOrderMark.strip(headers[0]) if headers[0]
|
|
100
|
+
headers
|
|
101
|
+
end
|
|
81
102
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
end
|
|
103
|
+
def next_record(headers, csv)
|
|
104
|
+
return unless (row = csv.shift)
|
|
85
105
|
|
|
86
|
-
|
|
106
|
+
headers.zip(row).to_h
|
|
87
107
|
end
|
|
88
108
|
|
|
89
|
-
def
|
|
90
|
-
|
|
109
|
+
def update_row?(src_record, dest_record)
|
|
110
|
+
return false unless update_comparison_columns
|
|
111
|
+
|
|
112
|
+
update_comparison_columns.any? do |column_name|
|
|
113
|
+
src_record[column_name] != dest_record[column_name]
|
|
114
|
+
end
|
|
91
115
|
end
|
|
92
116
|
end
|
|
93
117
|
end
|
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Utility class for appending data to a csv file.
|
|
4
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] }
|
|
5
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; the source is read with its :encoding,
|
|
13
|
+
# 'bom|utf-8' by default, and the output written in the encoding values were decoded to (see {EncodingOptions})
|
|
6
14
|
def initialize(src_csv, dest_csv, csv_options = {})
|
|
7
|
-
@src_csv = CSVUtils::CSVWrapper.new(src_csv, 'rb', csv_options)
|
|
8
|
-
@dest_csv = CSVUtils::CSVWrapper.new(dest_csv, 'wb', csv_options)
|
|
15
|
+
@src_csv = CSVUtils::CSVWrapper.new(src_csv, 'rb', EncodingOptions.read(csv_options))
|
|
16
|
+
@dest_csv = CSVUtils::CSVWrapper.new(dest_csv, 'wb', EncodingOptions.write(csv_options))
|
|
9
17
|
end
|
|
10
18
|
|
|
19
|
+
# Appends columns one row at a time, then closes the files it opened.
|
|
20
|
+
# @param additional_headers [Array<String>, nil] headers to add; nil when the source has no header row
|
|
21
|
+
# @yieldparam row [Array<String>] the source row
|
|
22
|
+
# @yieldparam headers [Array<String>, nil] the source headers
|
|
23
|
+
# @yieldreturn [Array] values to append to the row
|
|
24
|
+
# @return [void]
|
|
11
25
|
def append(additional_headers)
|
|
12
26
|
process(additional_headers) do |current_headers|
|
|
13
27
|
while (row = @src_csv.shift)
|
|
@@ -17,6 +31,14 @@ module CSVUtils
|
|
|
17
31
|
end
|
|
18
32
|
end
|
|
19
33
|
|
|
34
|
+
# Appends columns a batch of rows at a time, for lookups that are cheaper in bulk
|
|
35
|
+
# (ex: one database query per batch), then closes the files it opened.
|
|
36
|
+
# @param additional_headers [Array<String>, nil] headers to add; nil when the source has no header row
|
|
37
|
+
# @param batch_size [Integer] rows per batch
|
|
38
|
+
# @yieldparam batch [Array<Array<String>>] the source rows
|
|
39
|
+
# @yieldparam headers [Array<String>, nil] the source headers
|
|
40
|
+
# @yieldreturn [Array<Array>] values to append, one array per row in the batch
|
|
41
|
+
# @return [void]
|
|
20
42
|
def append_in_batches(additional_headers, batch_size = 1_000)
|
|
21
43
|
process(additional_headers) do |current_headers|
|
|
22
44
|
batch = []
|
|
@@ -47,7 +69,7 @@ module CSVUtils
|
|
|
47
69
|
current_headers = append_headers(additional_headers)
|
|
48
70
|
|
|
49
71
|
yield current_headers
|
|
50
|
-
|
|
72
|
+
ensure
|
|
51
73
|
close
|
|
52
74
|
end
|
|
53
75
|
|
|
@@ -1,29 +1,45 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Search a CSV given a series of steps
|
|
4
3
|
module CSVUtils
|
|
4
|
+
# Enumerates the rows of a CSV file as hashes keyed by header, with the line number of each row.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# iterator = CSVUtils::CSVIterator.new('data.csv')
|
|
8
|
+
# iterator.each { |row| puts "#{row.lineno}: #{row['name']}" }
|
|
9
|
+
# iterator.select { |row| row['status'] == 'active' }
|
|
5
10
|
class CSVIterator
|
|
6
11
|
include Enumerable
|
|
7
12
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
(+"\xFE\xFF").force_encoding('ASCII-8BIT'), # UTF-16 BE
|
|
11
|
-
(+"\xFF\xFE").force_encoding('ASCII-8BIT'), # UTF-16 LE
|
|
12
|
-
(+"\x00\x00\xFE\xFF").force_encoding('ASCII-8BIT'), # UTF-32 BE
|
|
13
|
-
(+"\xFF\xFE\x00\x00").force_encoding('ASCII-8BIT') # UTF-32 LE
|
|
14
|
-
].freeze
|
|
13
|
+
# @return [Array<String>] the byte order marks stripped from the first header, as binary strings
|
|
14
|
+
BYTE_ORDER_MARKS = ByteOrderMark::ENCODINGS.keys.freeze
|
|
15
15
|
|
|
16
|
+
# @return [Array<String>, nil] the raw row read before the current one, for debugging malformed rows
|
|
16
17
|
attr_reader :prev_row
|
|
17
18
|
|
|
19
|
+
# A row as a hash of header to value that knows its line number in the file.
|
|
18
20
|
class RowWrapper < Hash
|
|
21
|
+
# @return [Integer] line number of the row in the file, counting the header row as line 1
|
|
19
22
|
attr_accessor :lineno
|
|
20
23
|
|
|
24
|
+
# @param headers [Array<String>]
|
|
25
|
+
# @param row [Array<String>]
|
|
26
|
+
# @param lineno [Integer]
|
|
27
|
+
# @return [RowWrapper]
|
|
21
28
|
def self.create(headers, row, lineno)
|
|
22
|
-
row_wrapper =
|
|
29
|
+
row_wrapper = new
|
|
23
30
|
row_wrapper.lineno = lineno
|
|
31
|
+
# an index loop instead of headers.zip(row), which allocates an array per column for every row
|
|
32
|
+
idx = 0
|
|
33
|
+
size = headers.size
|
|
34
|
+
while idx < size
|
|
35
|
+
row_wrapper[headers[idx]] = row[idx]
|
|
36
|
+
idx += 1
|
|
37
|
+
end
|
|
24
38
|
row_wrapper
|
|
25
39
|
end
|
|
26
40
|
|
|
41
|
+
# Numbered "header: value" lines for the non-blank values, as csv-grep prints them.
|
|
42
|
+
# @return [String]
|
|
27
43
|
def to_pretty_s
|
|
28
44
|
reject { |_, v| v.nil? || v.strip.empty? }
|
|
29
45
|
.each_with_index
|
|
@@ -32,38 +48,52 @@ module CSVUtils
|
|
|
32
48
|
end
|
|
33
49
|
end
|
|
34
50
|
|
|
35
|
-
|
|
36
|
-
|
|
51
|
+
# An iterator for a path, with the separators and encoding detected by {CSVOptions}.
|
|
52
|
+
# @example
|
|
53
|
+
# CSVUtils::CSVIterator.auto_detect('export.tsv').each { |row| puts row['name'] }
|
|
54
|
+
# @param path [String]
|
|
55
|
+
# @return [CSVIterator]
|
|
56
|
+
def self.auto_detect(path)
|
|
57
|
+
options = CSVOptions.new(path)
|
|
58
|
+
new(path, options.to_csv_options)
|
|
37
59
|
end
|
|
38
60
|
|
|
39
|
-
|
|
40
|
-
|
|
61
|
+
# A path is opened by each method call and closed when the call returns,
|
|
62
|
+
# so an idle iterator holds no file handle.
|
|
63
|
+
# @param src_csv [String, CSV] path or CSV to read; a CSV must support rewind and is never closed
|
|
64
|
+
# @param csv_options [Hash] options passed to CSV.open for a path. The :encoding defaults to 'bom|utf-8'
|
|
65
|
+
# (see {EncodingOptions}); 'BINARY' reads bytes as is and 'Windows-1252:UTF-8' converts.
|
|
66
|
+
def initialize(src_csv, csv_options = {})
|
|
67
|
+
@src_csv = src_csv
|
|
68
|
+
@csv_options = EncodingOptions.read(csv_options)
|
|
69
|
+
end
|
|
41
70
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
71
|
+
# Yields each row from the start of the file.
|
|
72
|
+
# @param headers [Array<String>, nil] headers to use; by default the first row is read as the headers
|
|
73
|
+
# @yieldparam row [RowWrapper]
|
|
74
|
+
# @return [Enumerator, nil] an enumerator without a block
|
|
75
|
+
def each(headers = nil, &)
|
|
76
|
+
return enum_for(:each, headers) unless block_given?
|
|
48
77
|
|
|
49
|
-
|
|
50
|
-
while (row = @src_csv.shift)
|
|
51
|
-
lineno += 1
|
|
52
|
-
yield RowWrapper.create(headers, row, lineno)
|
|
53
|
-
@prev_row = row
|
|
54
|
-
end
|
|
78
|
+
open_csv { |csv| each_row(csv, headers, &) }
|
|
55
79
|
end
|
|
56
80
|
|
|
81
|
+
# The first row, without the byte order mark.
|
|
82
|
+
# @return [Array<String>] empty for an empty file
|
|
57
83
|
def headers
|
|
58
|
-
|
|
59
|
-
headers = @src_csv.shift
|
|
60
|
-
strip_bom!(headers[0])
|
|
61
|
-
headers
|
|
84
|
+
open_csv { |csv| read_headers(csv) }
|
|
62
85
|
end
|
|
63
86
|
|
|
87
|
+
# Builds a lookup hash from every row.
|
|
88
|
+
# @param key [String] header whose value is the hash key
|
|
89
|
+
# @param value [String, nil] header whose value is the hash value
|
|
90
|
+
# @yieldparam row [RowWrapper] used instead of value to compute the hash value
|
|
91
|
+
# @return [Hash]
|
|
92
|
+
# @raise [RuntimeError] when key or value isn't a header
|
|
64
93
|
def to_hash(key, value = nil, &)
|
|
65
|
-
|
|
66
|
-
raise("
|
|
94
|
+
file_headers = headers
|
|
95
|
+
raise("header #{key} not found in #{file_headers}") unless file_headers.include?(key)
|
|
96
|
+
raise("headers #{value} not found in #{file_headers}") if value && !file_headers.include?(value)
|
|
67
97
|
|
|
68
98
|
value_proc =
|
|
69
99
|
if value
|
|
@@ -72,19 +102,26 @@ module CSVUtils
|
|
|
72
102
|
proc(&)
|
|
73
103
|
end
|
|
74
104
|
|
|
75
|
-
|
|
76
|
-
|
|
105
|
+
to_h do |row|
|
|
106
|
+
[row[key], value_proc.call(row)]
|
|
77
107
|
end
|
|
78
108
|
end
|
|
79
109
|
|
|
110
|
+
# Number of rows after the header row.
|
|
111
|
+
# @return [Integer]
|
|
80
112
|
def size
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
113
|
+
open_csv do |csv|
|
|
114
|
+
csv.shift
|
|
115
|
+
cnt = 0
|
|
116
|
+
cnt += 1 while csv.shift
|
|
117
|
+
cnt
|
|
118
|
+
end
|
|
86
119
|
end
|
|
87
120
|
|
|
121
|
+
# Yields the rows in batches.
|
|
122
|
+
# @param batch_size [Integer] rows per batch; the last batch may be smaller
|
|
123
|
+
# @yieldparam batch [Array<RowWrapper>]
|
|
124
|
+
# @return [nil]
|
|
88
125
|
def each_batch(batch_size = 1_000)
|
|
89
126
|
batch = []
|
|
90
127
|
|
|
@@ -105,13 +142,43 @@ module CSVUtils
|
|
|
105
142
|
|
|
106
143
|
private
|
|
107
144
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
145
|
+
# Opens a path for the duration of the block. Only a CSV that was passed in is rewound:
|
|
146
|
+
# rewinding goes back to byte 0, in front of a byte order mark the 'bom|' encoding skipped on open.
|
|
147
|
+
def open_csv
|
|
148
|
+
CSVWrapper.open(@src_csv, 'rb', @csv_options) do |csv|
|
|
149
|
+
csv.rewind unless @src_csv.is_a?(String)
|
|
150
|
+
yield csv
|
|
114
151
|
end
|
|
115
152
|
end
|
|
153
|
+
|
|
154
|
+
def each_row(csv, headers)
|
|
155
|
+
@prev_row = nil
|
|
156
|
+
row = shift_first_row(csv)
|
|
157
|
+
lineno = 1
|
|
158
|
+
unless headers
|
|
159
|
+
headers = row || []
|
|
160
|
+
row = csv.shift
|
|
161
|
+
lineno += 1
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
while row
|
|
165
|
+
yield RowWrapper.create(headers, row, lineno)
|
|
166
|
+
@prev_row = row
|
|
167
|
+
row = csv.shift
|
|
168
|
+
lineno += 1
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# an empty file has no headers, and an empty first header cell is nil
|
|
173
|
+
def read_headers(csv)
|
|
174
|
+
shift_first_row(csv) || []
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# the first row without the byte order mark, whether it's read as headers or as data
|
|
178
|
+
def shift_first_row(csv)
|
|
179
|
+
row = csv.shift
|
|
180
|
+
row[0] = ByteOrderMark.strip(row[0]) if row && row[0]
|
|
181
|
+
row
|
|
182
|
+
end
|
|
116
183
|
end
|
|
117
184
|
end
|