csv-utils 0.5.0 → 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 +92 -17
- 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 +3 -1
- data/lib/csv_utils/byte_order_mark.rb +34 -0
- data/lib/csv_utils/csv_compare.rb +89 -65
- data/lib/csv_utils/csv_extender.rb +23 -2
- data/lib/csv_utils/csv_iterator.rb +49 -22
- data/lib/csv_utils/csv_options.rb +65 -27
- data/lib/csv_utils/csv_report.rb +26 -3
- 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 +44 -18
- data/lib/csv_utils/csv_transformer.rb +79 -35
- data/lib/csv_utils/csv_wrapper.rb +24 -4
- data/lib/csv_utils/version.rb +6 -0
- metadata +27 -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
|
@@ -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
|
|
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', **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,26 @@
|
|
|
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
|
|
6
13
|
def initialize(src_csv, dest_csv, csv_options = {})
|
|
7
14
|
@src_csv = CSVUtils::CSVWrapper.new(src_csv, 'rb', csv_options)
|
|
8
15
|
@dest_csv = CSVUtils::CSVWrapper.new(dest_csv, 'wb', csv_options)
|
|
9
16
|
end
|
|
10
17
|
|
|
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]
|
|
11
24
|
def append(additional_headers)
|
|
12
25
|
process(additional_headers) do |current_headers|
|
|
13
26
|
while (row = @src_csv.shift)
|
|
@@ -17,6 +30,14 @@ module CSVUtils
|
|
|
17
30
|
end
|
|
18
31
|
end
|
|
19
32
|
|
|
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]
|
|
20
41
|
def append_in_batches(additional_headers, batch_size = 1_000)
|
|
21
42
|
process(additional_headers) do |current_headers|
|
|
22
43
|
batch = []
|
|
@@ -47,7 +68,7 @@ module CSVUtils
|
|
|
47
68
|
current_headers = append_headers(additional_headers)
|
|
48
69
|
|
|
49
70
|
yield current_headers
|
|
50
|
-
|
|
71
|
+
ensure
|
|
51
72
|
close
|
|
52
73
|
end
|
|
53
74
|
|
|
@@ -1,29 +1,38 @@
|
|
|
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
29
|
row_wrapper = RowWrapper[headers.zip(row)]
|
|
23
30
|
row_wrapper.lineno = lineno
|
|
24
31
|
row_wrapper
|
|
25
32
|
end
|
|
26
33
|
|
|
34
|
+
# Numbered "header: value" lines for the non-blank values, as csv-grep prints them.
|
|
35
|
+
# @return [String]
|
|
27
36
|
def to_pretty_s
|
|
28
37
|
reject { |_, v| v.nil? || v.strip.empty? }
|
|
29
38
|
.each_with_index
|
|
@@ -32,17 +41,25 @@ module CSVUtils
|
|
|
32
41
|
end
|
|
33
42
|
end
|
|
34
43
|
|
|
44
|
+
# @param src_csv [String, CSV] path or CSV to read; it must support rewind
|
|
45
|
+
# @param csv_options [Hash] options passed to CSV.open for a path
|
|
46
|
+
# @param mode [String] file mode for a path; 'rb:BINARY' reads bytes as is
|
|
35
47
|
def initialize(src_csv, csv_options = {}, mode = 'rb')
|
|
36
48
|
@src_csv = CSVUtils::CSVWrapper.new(src_csv, mode, csv_options)
|
|
37
49
|
end
|
|
38
50
|
|
|
51
|
+
# Yields each row from the start of the file.
|
|
52
|
+
# @param headers [Array<String>, nil] headers to use; by default the first row is read as the headers
|
|
53
|
+
# @yieldparam row [RowWrapper]
|
|
54
|
+
# @return [Enumerator, nil] an enumerator without a block
|
|
39
55
|
def each(headers = nil)
|
|
56
|
+
return enum_for(:each, headers) unless block_given?
|
|
57
|
+
|
|
40
58
|
@src_csv.rewind
|
|
41
59
|
|
|
42
60
|
lineno = 0
|
|
43
61
|
unless headers
|
|
44
|
-
headers =
|
|
45
|
-
strip_bom!(headers[0])
|
|
62
|
+
headers = read_headers
|
|
46
63
|
lineno += 1
|
|
47
64
|
end
|
|
48
65
|
|
|
@@ -54,13 +71,19 @@ module CSVUtils
|
|
|
54
71
|
end
|
|
55
72
|
end
|
|
56
73
|
|
|
74
|
+
# The first row, without the byte order mark.
|
|
75
|
+
# @return [Array<String>] empty for an empty file
|
|
57
76
|
def headers
|
|
58
77
|
@src_csv.rewind
|
|
59
|
-
|
|
60
|
-
strip_bom!(headers[0])
|
|
61
|
-
headers
|
|
78
|
+
read_headers
|
|
62
79
|
end
|
|
63
80
|
|
|
81
|
+
# Builds a lookup hash from every row.
|
|
82
|
+
# @param key [String] header whose value is the hash key
|
|
83
|
+
# @param value [String, nil] header whose value is the hash value
|
|
84
|
+
# @yieldparam row [RowWrapper] used instead of value to compute the hash value
|
|
85
|
+
# @return [Hash]
|
|
86
|
+
# @raise [RuntimeError] when key or value isn't a header
|
|
64
87
|
def to_hash(key, value = nil, &)
|
|
65
88
|
raise("header #{key} not found in #{headers}") unless headers.include?(key)
|
|
66
89
|
raise("headers #{value} not found in #{headers}") if value && !headers.include?(value)
|
|
@@ -72,11 +95,13 @@ module CSVUtils
|
|
|
72
95
|
proc(&)
|
|
73
96
|
end
|
|
74
97
|
|
|
75
|
-
|
|
76
|
-
|
|
98
|
+
to_h do |row|
|
|
99
|
+
[row[key], value_proc.call(row)]
|
|
77
100
|
end
|
|
78
101
|
end
|
|
79
102
|
|
|
103
|
+
# Number of rows after the header row.
|
|
104
|
+
# @return [Integer]
|
|
80
105
|
def size
|
|
81
106
|
@src_csv.rewind
|
|
82
107
|
@src_csv.shift
|
|
@@ -85,6 +110,10 @@ module CSVUtils
|
|
|
85
110
|
cnt
|
|
86
111
|
end
|
|
87
112
|
|
|
113
|
+
# Yields the rows in batches.
|
|
114
|
+
# @param batch_size [Integer] rows per batch; the last batch may be smaller
|
|
115
|
+
# @yieldparam batch [Array<RowWrapper>]
|
|
116
|
+
# @return [nil]
|
|
88
117
|
def each_batch(batch_size = 1_000)
|
|
89
118
|
batch = []
|
|
90
119
|
|
|
@@ -105,13 +134,11 @@ module CSVUtils
|
|
|
105
134
|
|
|
106
135
|
private
|
|
107
136
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
end
|
|
114
|
-
end
|
|
137
|
+
# an empty file has no headers, and an empty first header cell is nil
|
|
138
|
+
def read_headers
|
|
139
|
+
headers = @src_csv.shift || []
|
|
140
|
+
headers[0] = ByteOrderMark.strip(headers[0]) if headers[0]
|
|
141
|
+
headers
|
|
115
142
|
end
|
|
116
143
|
end
|
|
117
144
|
end
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Auto detect a csv files options
|
|
4
3
|
module CSVUtils
|
|
4
|
+
# Detects the separators, byte order mark, encoding and column count of a CSV file from its first line.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# options = CSVUtils::CSVOptions.new('data.csv')
|
|
8
|
+
# CSV.open('data.csv', 'rb', col_sep: options.col_separator) if options.valid?
|
|
5
9
|
class CSVOptions
|
|
6
|
-
#
|
|
7
|
-
BYTE_ORDER_MARKS =
|
|
8
|
-
(+"\xEF\xBB\xBF").force_encoding('ASCII-8BIT') => 'UTF-8',
|
|
9
|
-
(+"\xFE\xFF").force_encoding('ASCII-8BIT') => 'UTF-16',
|
|
10
|
-
(+"\xFF\xFE").force_encoding('ASCII-8BIT') => 'UTF-16',
|
|
11
|
-
(+"\x00\x00\xFE\xFF").force_encoding('ASCII-8BIT') => 'UTF-32',
|
|
12
|
-
(+"\xFF\xFE\x00\x00").force_encoding('ASCII-8BIT') => 'UTF-32'
|
|
13
|
-
}.freeze
|
|
10
|
+
# @return [Hash{String => String}] byte order marks, as binary strings, to the encoding they indicate
|
|
11
|
+
BYTE_ORDER_MARKS = ByteOrderMark::ENCODINGS
|
|
14
12
|
|
|
13
|
+
# @return [Array<String>] column separators to look for, the first one found in the line wins
|
|
15
14
|
COL_SEPARATORS = [
|
|
16
15
|
"\x02",
|
|
17
16
|
"\t",
|
|
@@ -19,25 +18,27 @@ module CSVUtils
|
|
|
19
18
|
','
|
|
20
19
|
].freeze
|
|
21
20
|
|
|
21
|
+
# @return [Array<String>] row separators to look for, the first one found in the line wins
|
|
22
22
|
ROW_SEPARATORS = [
|
|
23
23
|
"\r\n",
|
|
24
24
|
"\n",
|
|
25
25
|
"\r"
|
|
26
26
|
].freeze
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
# @return [Integer, nil] number of headers, nil without a column separator
|
|
29
|
+
attr_reader :columns
|
|
30
|
+
# @return [String, nil] the byte order mark the file starts with, as a binary string
|
|
31
|
+
attr_reader :byte_order_mark
|
|
32
|
+
# @return [String] 'UTF-8', 'UTF-16' or 'UTF-32', from the byte order mark; 'UTF-8' without one
|
|
33
|
+
attr_reader :encoding
|
|
34
|
+
# @return [String, nil] one of {COL_SEPARATORS}
|
|
35
|
+
attr_reader :col_separator
|
|
36
|
+
# @return [String, nil] one of {ROW_SEPARATORS}
|
|
37
|
+
attr_reader :row_separator
|
|
33
38
|
|
|
39
|
+
# @param io [String, IO] path of the file, or an IO positioned at its first line
|
|
34
40
|
def initialize(io)
|
|
35
|
-
line =
|
|
36
|
-
if io.is_a?(String)
|
|
37
|
-
File.open(io, 'rb', &:readline)
|
|
38
|
-
else
|
|
39
|
-
io.readline
|
|
40
|
-
end
|
|
41
|
+
line = read_first_line(io)
|
|
41
42
|
|
|
42
43
|
@col_separator = auto_detect_col_sep(line)
|
|
43
44
|
@row_separator = auto_detect_row_sep(line)
|
|
@@ -46,42 +47,79 @@ module CSVUtils
|
|
|
46
47
|
@columns = get_number_of_columns(line) if @col_separator
|
|
47
48
|
end
|
|
48
49
|
|
|
50
|
+
# Whether both separators were found. An empty file, or a single line without a newline, isn't valid.
|
|
51
|
+
# @return [Boolean]
|
|
49
52
|
def valid?
|
|
50
53
|
return false if @col_separator.nil? || @row_separator.nil?
|
|
51
54
|
|
|
52
55
|
true
|
|
53
56
|
end
|
|
54
57
|
|
|
58
|
+
# @api private
|
|
59
|
+
# @param line [String]
|
|
60
|
+
# @return [String, nil]
|
|
55
61
|
def auto_detect_col_sep(line)
|
|
56
62
|
COL_SEPARATORS.detect { |sep| line.include?(sep) }
|
|
57
63
|
end
|
|
58
64
|
|
|
65
|
+
# @api private
|
|
66
|
+
# @param line [String]
|
|
67
|
+
# @return [String, nil]
|
|
59
68
|
def auto_detect_row_sep(line)
|
|
60
69
|
ROW_SEPARATORS.detect { |sep| line.include?(sep) }
|
|
61
70
|
end
|
|
62
71
|
|
|
72
|
+
# Parses the header line like a CSV row, so quoted headers can contain the separator,
|
|
73
|
+
# and falls back to a plain split when the line is malformed.
|
|
74
|
+
# @param line [String] the first line of the file
|
|
75
|
+
# @return [Array<String, nil>] headers without the byte order mark or row separator
|
|
63
76
|
def get_headers(line)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
77
|
+
line = strip_byte_order_marks(line)
|
|
78
|
+
CSV.parse_line(line, **header_parse_options) || []
|
|
79
|
+
rescue CSV::MalformedCSVError
|
|
80
|
+
line.chomp(row_separator.to_s).split(col_separator, -1)
|
|
67
81
|
end
|
|
68
82
|
|
|
83
|
+
# @api private
|
|
84
|
+
# @param line [String]
|
|
85
|
+
# @return [Integer]
|
|
69
86
|
def get_number_of_columns(line)
|
|
70
87
|
get_headers(line).size
|
|
71
88
|
end
|
|
72
89
|
|
|
90
|
+
# @api private
|
|
91
|
+
# @param line [String]
|
|
92
|
+
# @return [String, nil]
|
|
73
93
|
def get_byte_order_mark(line)
|
|
74
|
-
|
|
75
|
-
line =~ /\A#{bom}/
|
|
76
|
-
end
|
|
94
|
+
ByteOrderMark.detect(line)
|
|
77
95
|
end
|
|
78
96
|
|
|
97
|
+
# @api private
|
|
98
|
+
# @param bom [String, nil]
|
|
99
|
+
# @return [String]
|
|
79
100
|
def get_character_encoding(bom)
|
|
80
101
|
BYTE_ORDER_MARKS[bom] || 'UTF-8'
|
|
81
102
|
end
|
|
82
103
|
|
|
104
|
+
# @api private
|
|
105
|
+
# @param header [String]
|
|
106
|
+
# @return [String]
|
|
83
107
|
def strip_byte_order_marks(header)
|
|
84
|
-
|
|
108
|
+
ByteOrderMark.strip(header)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# an empty file reads as an empty line, which makes the options invalid
|
|
114
|
+
def read_first_line(io)
|
|
115
|
+
line = io.is_a?(String) ? File.open(io, 'rb', &:gets) : io.gets
|
|
116
|
+
line || ''
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def header_parse_options
|
|
120
|
+
options = { col_sep: col_separator }
|
|
121
|
+
options[:row_sep] = row_separator if row_separator
|
|
122
|
+
options
|
|
85
123
|
end
|
|
86
124
|
end
|
|
87
125
|
end
|
data/lib/csv_utils/csv_report.rb
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Builds a csv file from csv rows
|
|
4
3
|
module CSVUtils
|
|
4
|
+
# Writes a CSV file from arrays or {CSVRow} objects.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# CSVUtils::CSVReport.new('users.csv', UserCSVRow) do |report|
|
|
8
|
+
# users.each { |user| report << user }
|
|
9
|
+
# end
|
|
5
10
|
class CSVReport
|
|
6
|
-
|
|
7
|
-
|
|
11
|
+
# @return [CSV, #<<] where rows are written
|
|
12
|
+
attr_reader :csv
|
|
13
|
+
# @return [Boolean] whether the report opened the file and closes it after {#generate}
|
|
14
|
+
attr_reader :must_close
|
|
8
15
|
|
|
16
|
+
# @param csv [String, CSV, #<<] path to write, opened in 'wb' mode by default, or a CSV to append to
|
|
17
|
+
# @param headers [Array<String>, Class, nil] header row, or a {CSVRow} class to take the headers from
|
|
18
|
+
# @param csv_options [Hash] options passed to CSV.open for a path; :mode overrides the file mode
|
|
19
|
+
# @yieldparam report [CSVReport] when given, the block is passed to {#generate}
|
|
9
20
|
def initialize(csv, headers = nil, csv_options = {}, &block)
|
|
10
21
|
@csv =
|
|
11
22
|
if csv.is_a?(String)
|
|
@@ -23,11 +34,18 @@ module CSVUtils
|
|
|
23
34
|
generate(&block) if block
|
|
24
35
|
end
|
|
25
36
|
|
|
37
|
+
# Yields the report, then closes the file if the report opened it, even when the block raises.
|
|
38
|
+
# @yieldparam report [CSVReport]
|
|
39
|
+
# @return [void]
|
|
26
40
|
def generate
|
|
27
41
|
yield self
|
|
42
|
+
ensure
|
|
28
43
|
close if @must_close
|
|
29
44
|
end
|
|
30
45
|
|
|
46
|
+
# Writes a row.
|
|
47
|
+
# @param csv_row [Array, #to_a] an array of values or a {CSVRow} object
|
|
48
|
+
# @return [CSV, #<<]
|
|
31
49
|
def append(csv_row)
|
|
32
50
|
@csv <<
|
|
33
51
|
if csv_row.is_a?(Array)
|
|
@@ -38,10 +56,15 @@ module CSVUtils
|
|
|
38
56
|
end
|
|
39
57
|
alias << append
|
|
40
58
|
|
|
59
|
+
# Writes the header row.
|
|
60
|
+
# @param csv_row [Array<String>, #csv_headers] headers, or a {CSVRow} class or object to take them from
|
|
61
|
+
# @return [CSV, #<<]
|
|
41
62
|
def add_headers(csv_row)
|
|
42
63
|
append(csv_row.is_a?(Array) ? csv_row : csv_row.csv_headers)
|
|
43
64
|
end
|
|
44
65
|
|
|
66
|
+
# Closes the underlying CSV.
|
|
67
|
+
# @return [void]
|
|
45
68
|
def close
|
|
46
69
|
@csv.close
|
|
47
70
|
end
|
data/lib/csv_utils/csv_row.rb
CHANGED
|
@@ -3,18 +3,44 @@
|
|
|
3
3
|
require 'inheritance-helper'
|
|
4
4
|
|
|
5
5
|
module CSVUtils
|
|
6
|
+
# Mixin that declares how an object turns into a CSV row, for use with {CSVReport}.
|
|
7
|
+
# Columns are inherited by subclasses.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# class UserCSVRow < User
|
|
11
|
+
# include CSVUtils::CSVRow
|
|
12
|
+
#
|
|
13
|
+
# csv_column :id, header: 'ID'
|
|
14
|
+
# csv_column :name
|
|
15
|
+
# csv_column(:email) { email.downcase }
|
|
16
|
+
# end
|
|
6
17
|
module CSVRow
|
|
18
|
+
# @api private
|
|
19
|
+
# @param base [Class]
|
|
20
|
+
# @return [void]
|
|
7
21
|
def self.included(base)
|
|
8
22
|
base.extend InheritanceHelper::Methods
|
|
9
23
|
base.extend ClassMethods
|
|
10
24
|
end
|
|
11
25
|
|
|
26
|
+
# Class level DSL for declaring columns.
|
|
12
27
|
module ClassMethods
|
|
28
|
+
# Declared columns, keyed by name, in declaration order.
|
|
29
|
+
# @return [Hash{Symbol => Hash}]
|
|
13
30
|
def csv_columns
|
|
14
31
|
{}
|
|
15
32
|
end
|
|
16
33
|
|
|
34
|
+
# Declares a column.
|
|
35
|
+
# @param header [Symbol, String] column name; also the default header and the method that provides the value
|
|
36
|
+
# @param options [Hash]
|
|
37
|
+
# @option options [String] :header header text
|
|
38
|
+
# @option options [Symbol] :method method that provides the value
|
|
39
|
+
# @option options [Proc] :proc evaluated on the object to provide the value
|
|
40
|
+
# @yield evaluated on the object to provide the value, like :proc
|
|
41
|
+
# @return [void]
|
|
17
42
|
def csv_column(header, options = {}, &block)
|
|
43
|
+
options = options.dup
|
|
18
44
|
options[:header] ||= header.to_s
|
|
19
45
|
|
|
20
46
|
if block
|
|
@@ -26,6 +52,7 @@ module CSVUtils
|
|
|
26
52
|
add_value_to_class_method(:csv_columns, header => options)
|
|
27
53
|
end
|
|
28
54
|
|
|
55
|
+
# @return [Array<String>] the header of each column
|
|
29
56
|
def csv_headers
|
|
30
57
|
csv_columns.values.map { |column_options| csv_column_header(column_options) }
|
|
31
58
|
end
|
|
@@ -37,11 +64,14 @@ module CSVUtils
|
|
|
37
64
|
end
|
|
38
65
|
end
|
|
39
66
|
|
|
67
|
+
# The value of each column.
|
|
68
|
+
# @return [Array]
|
|
40
69
|
def csv_row
|
|
41
70
|
self.class.csv_columns.values.map { |column_options| csv_column_value(column_options) }
|
|
42
71
|
end
|
|
43
72
|
alias to_a csv_row
|
|
44
73
|
|
|
74
|
+
# @return [Array<String>] the header of each column
|
|
45
75
|
def csv_headers
|
|
46
76
|
self.class.csv_headers
|
|
47
77
|
end
|