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
|
@@ -1,102 +1,144 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
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' }
|
|
10
|
+
class CSVIterator
|
|
11
|
+
include Enumerable
|
|
12
|
+
|
|
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
|
+
|
|
16
|
+
# @return [Array<String>, nil] the raw row read before the current one, for debugging malformed rows
|
|
17
|
+
attr_reader :prev_row
|
|
18
|
+
|
|
19
|
+
# A row as a hash of header to value that knows its line number in the file.
|
|
20
|
+
class RowWrapper < Hash
|
|
21
|
+
# @return [Integer] line number of the row in the file, counting the header row as line 1
|
|
22
|
+
attr_accessor :lineno
|
|
23
|
+
|
|
24
|
+
# @param headers [Array<String>]
|
|
25
|
+
# @param row [Array<String>]
|
|
26
|
+
# @param lineno [Integer]
|
|
27
|
+
# @return [RowWrapper]
|
|
28
|
+
def self.create(headers, row, lineno)
|
|
29
|
+
row_wrapper = RowWrapper[headers.zip(row)]
|
|
30
|
+
row_wrapper.lineno = lineno
|
|
31
|
+
row_wrapper
|
|
32
|
+
end
|
|
9
33
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
34
|
+
# Numbered "header: value" lines for the non-blank values, as csv-grep prints them.
|
|
35
|
+
# @return [String]
|
|
36
|
+
def to_pretty_s
|
|
37
|
+
reject { |_, v| v.nil? || v.strip.empty? }
|
|
38
|
+
.each_with_index
|
|
39
|
+
.map { |(k, v), idx| format(' %-3d %s: %s', idx + 1, k, v) }
|
|
40
|
+
.join("\n") + "\n"
|
|
41
|
+
end
|
|
14
42
|
end
|
|
15
43
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
47
|
+
def initialize(src_csv, csv_options = {}, mode = 'rb')
|
|
48
|
+
@src_csv = CSVUtils::CSVWrapper.new(src_csv, mode, csv_options)
|
|
21
49
|
end
|
|
22
|
-
end
|
|
23
50
|
|
|
24
|
-
|
|
25
|
-
@
|
|
26
|
-
|
|
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
|
|
55
|
+
def each(headers = nil)
|
|
56
|
+
return enum_for(:each, headers) unless block_given?
|
|
27
57
|
|
|
28
|
-
|
|
29
|
-
@src_csv.rewind
|
|
58
|
+
@src_csv.rewind
|
|
30
59
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
end
|
|
60
|
+
lineno = 0
|
|
61
|
+
unless headers
|
|
62
|
+
headers = read_headers
|
|
63
|
+
lineno += 1
|
|
64
|
+
end
|
|
37
65
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
66
|
+
@prev_row = nil
|
|
67
|
+
while (row = @src_csv.shift)
|
|
68
|
+
lineno += 1
|
|
69
|
+
yield RowWrapper.create(headers, row, lineno)
|
|
70
|
+
@prev_row = row
|
|
71
|
+
end
|
|
43
72
|
end
|
|
44
|
-
end
|
|
45
73
|
|
|
46
|
-
|
|
47
|
-
@
|
|
48
|
-
headers
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def to_hash(key, value = nil)
|
|
54
|
-
raise("header #{key} not found in #{headers}") unless headers.include?(key)
|
|
55
|
-
raise("headers #{value} not found in #{headers}") if value && !headers.include?(value)
|
|
74
|
+
# The first row, without the byte order mark.
|
|
75
|
+
# @return [Array<String>] empty for an empty file
|
|
76
|
+
def headers
|
|
77
|
+
@src_csv.rewind
|
|
78
|
+
read_headers
|
|
79
|
+
end
|
|
56
80
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
87
|
+
def to_hash(key, value = nil, &)
|
|
88
|
+
raise("header #{key} not found in #{headers}") unless headers.include?(key)
|
|
89
|
+
raise("headers #{value} not found in #{headers}") if value && !headers.include?(value)
|
|
90
|
+
|
|
91
|
+
value_proc =
|
|
92
|
+
if value
|
|
93
|
+
proc { |row| row[value] }
|
|
94
|
+
else
|
|
95
|
+
proc(&)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
to_h do |row|
|
|
99
|
+
[row[key], value_proc.call(row)]
|
|
62
100
|
end
|
|
63
|
-
|
|
64
|
-
each_with_object({}) do |row, hsh|
|
|
65
|
-
hsh[row[key]] = value_proc.call(row)
|
|
66
101
|
end
|
|
67
|
-
end
|
|
68
102
|
|
|
69
|
-
|
|
70
|
-
@
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
cnt
|
|
103
|
+
# Number of rows after the header row.
|
|
104
|
+
# @return [Integer]
|
|
105
|
+
def size
|
|
106
|
+
@src_csv.rewind
|
|
107
|
+
@src_csv.shift
|
|
108
|
+
cnt = 0
|
|
109
|
+
cnt += 1 while @src_csv.shift
|
|
110
|
+
cnt
|
|
75
111
|
end
|
|
76
|
-
cnt
|
|
77
|
-
end
|
|
78
112
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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]
|
|
117
|
+
def each_batch(batch_size = 1_000)
|
|
84
118
|
batch = []
|
|
85
|
-
end
|
|
86
119
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
120
|
+
process_batch_proc = proc do
|
|
121
|
+
yield batch
|
|
122
|
+
batch = []
|
|
123
|
+
end
|
|
91
124
|
|
|
92
|
-
|
|
125
|
+
each do |row|
|
|
126
|
+
batch << row
|
|
127
|
+
process_batch_proc.call if batch.size >= batch_size
|
|
128
|
+
end
|
|
93
129
|
|
|
94
|
-
|
|
95
|
-
end
|
|
130
|
+
process_batch_proc.call if batch.size.positive?
|
|
96
131
|
|
|
97
|
-
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
98
136
|
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
142
|
+
end
|
|
101
143
|
end
|
|
102
144
|
end
|
|
@@ -1,43 +1,44 @@
|
|
|
1
|
-
#
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
2
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?
|
|
3
9
|
class CSVOptions
|
|
10
|
+
# @return [Hash{String => String}] byte order marks, as binary strings, to the encoding they indicate
|
|
11
|
+
BYTE_ORDER_MARKS = ByteOrderMark::ENCODINGS
|
|
4
12
|
|
|
5
|
-
#
|
|
6
|
-
BYTE_ORDER_MARKS = {
|
|
7
|
-
"\xEF\xBB\xBF".force_encoding('ASCII-8BIT') => 'UTF-8',
|
|
8
|
-
"\xFE\xFF".force_encoding('ASCII-8BIT') => 'UTF-16',
|
|
9
|
-
"\xFF\xFE".force_encoding('ASCII-8BIT') => 'UTF-16',
|
|
10
|
-
"\x00\x00\xFE\xFF".force_encoding('ASCII-8BIT') => 'UTF-32',
|
|
11
|
-
"\xFF\xFE\x00\x00".force_encoding('ASCII-8BIT') => 'UTF-32'
|
|
12
|
-
}
|
|
13
|
-
|
|
13
|
+
# @return [Array<String>] column separators to look for, the first one found in the line wins
|
|
14
14
|
COL_SEPARATORS = [
|
|
15
15
|
"\x02",
|
|
16
16
|
"\t",
|
|
17
17
|
'|',
|
|
18
18
|
','
|
|
19
|
-
]
|
|
19
|
+
].freeze
|
|
20
20
|
|
|
21
|
+
# @return [Array<String>] row separators to look for, the first one found in the line wins
|
|
21
22
|
ROW_SEPARATORS = [
|
|
22
23
|
"\r\n",
|
|
23
24
|
"\n",
|
|
24
25
|
"\r"
|
|
25
|
-
]
|
|
26
|
+
].freeze
|
|
26
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,15 +1,29 @@
|
|
|
1
|
-
#
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
2
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
|
|
3
10
|
class CSVReport
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
6
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}
|
|
7
20
|
def initialize(csv, headers = nil, csv_options = {}, &block)
|
|
8
21
|
@csv =
|
|
9
22
|
if csv.is_a?(String)
|
|
10
23
|
@must_close = true
|
|
11
|
-
|
|
12
|
-
|
|
24
|
+
opts = csv_options.dup
|
|
25
|
+
mode = opts.delete(:mode) || 'wb'
|
|
26
|
+
CSV.open(csv, mode, **opts)
|
|
13
27
|
else
|
|
14
28
|
@must_close = false
|
|
15
29
|
csv
|
|
@@ -20,11 +34,18 @@ module CSVUtils
|
|
|
20
34
|
generate(&block) if block
|
|
21
35
|
end
|
|
22
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]
|
|
23
40
|
def generate
|
|
24
41
|
yield self
|
|
42
|
+
ensure
|
|
25
43
|
close if @must_close
|
|
26
44
|
end
|
|
27
45
|
|
|
46
|
+
# Writes a row.
|
|
47
|
+
# @param csv_row [Array, #to_a] an array of values or a {CSVRow} object
|
|
48
|
+
# @return [CSV, #<<]
|
|
28
49
|
def append(csv_row)
|
|
29
50
|
@csv <<
|
|
30
51
|
if csv_row.is_a?(Array)
|
|
@@ -35,10 +56,15 @@ module CSVUtils
|
|
|
35
56
|
end
|
|
36
57
|
alias << append
|
|
37
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, #<<]
|
|
38
62
|
def add_headers(csv_row)
|
|
39
63
|
append(csv_row.is_a?(Array) ? csv_row : csv_row.csv_headers)
|
|
40
64
|
end
|
|
41
65
|
|
|
66
|
+
# Closes the underlying CSV.
|
|
67
|
+
# @return [void]
|
|
42
68
|
def close
|
|
43
69
|
@csv.close
|
|
44
70
|
end
|
data/lib/csv_utils/csv_row.rb
CHANGED
|
@@ -1,18 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'inheritance-helper'
|
|
2
4
|
|
|
3
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
|
|
4
17
|
module CSVRow
|
|
18
|
+
# @api private
|
|
19
|
+
# @param base [Class]
|
|
20
|
+
# @return [void]
|
|
5
21
|
def self.included(base)
|
|
6
22
|
base.extend InheritanceHelper::Methods
|
|
7
23
|
base.extend ClassMethods
|
|
8
24
|
end
|
|
9
25
|
|
|
26
|
+
# Class level DSL for declaring columns.
|
|
10
27
|
module ClassMethods
|
|
28
|
+
# Declared columns, keyed by name, in declaration order.
|
|
29
|
+
# @return [Hash{Symbol => Hash}]
|
|
11
30
|
def csv_columns
|
|
12
31
|
{}
|
|
13
32
|
end
|
|
14
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]
|
|
15
42
|
def csv_column(header, options = {}, &block)
|
|
43
|
+
options = options.dup
|
|
16
44
|
options[:header] ||= header.to_s
|
|
17
45
|
|
|
18
46
|
if block
|
|
@@ -24,6 +52,7 @@ module CSVUtils
|
|
|
24
52
|
add_value_to_class_method(:csv_columns, header => options)
|
|
25
53
|
end
|
|
26
54
|
|
|
55
|
+
# @return [Array<String>] the header of each column
|
|
27
56
|
def csv_headers
|
|
28
57
|
csv_columns.values.map { |column_options| csv_column_header(column_options) }
|
|
29
58
|
end
|
|
@@ -35,11 +64,14 @@ module CSVUtils
|
|
|
35
64
|
end
|
|
36
65
|
end
|
|
37
66
|
|
|
67
|
+
# The value of each column.
|
|
68
|
+
# @return [Array]
|
|
38
69
|
def csv_row
|
|
39
70
|
self.class.csv_columns.values.map { |column_options| csv_column_value(column_options) }
|
|
40
71
|
end
|
|
41
|
-
|
|
72
|
+
alias to_a csv_row
|
|
42
73
|
|
|
74
|
+
# @return [Array<String>] the header of each column
|
|
43
75
|
def csv_headers
|
|
44
76
|
self.class.csv_headers
|
|
45
77
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CSVUtils
|
|
4
|
+
# Matches rows (hashes of header to value) whose values match a regular expression.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# matcher = CSVUtils::CSVRowMatcher.new(/error/i, ['status', 'message'])
|
|
8
|
+
# CSVUtils::CSVIterator.new('logs.csv').select(&matcher)
|
|
9
|
+
class CSVRowMatcher
|
|
10
|
+
# @return [Regexp]
|
|
11
|
+
attr_accessor :regex
|
|
12
|
+
# @return [Array<String>, :all] headers to search, or :all for every value
|
|
13
|
+
attr_accessor :columns
|
|
14
|
+
|
|
15
|
+
# @param regex [Regexp]
|
|
16
|
+
# @param columns [Array<String>, :all] headers to search
|
|
17
|
+
def initialize(regex, columns = :all)
|
|
18
|
+
self.regex = regex
|
|
19
|
+
self.columns = columns
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Whether any searched value matches. Missing and nil values never match.
|
|
23
|
+
# @param row [Hash{String => String}]
|
|
24
|
+
# @return [Boolean]
|
|
25
|
+
def match?(row)
|
|
26
|
+
if columns == :all
|
|
27
|
+
row.each_value do |value|
|
|
28
|
+
return true if value&.match?(regex)
|
|
29
|
+
end
|
|
30
|
+
else
|
|
31
|
+
columns.each do |column_name|
|
|
32
|
+
value = row[column_name]
|
|
33
|
+
return true if value&.match?(regex)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Lets the matcher be passed to Enumerable methods as a block, as in rows.select(&matcher).
|
|
41
|
+
# @return [Proc]
|
|
42
|
+
def to_proc
|
|
43
|
+
proc do |row|
|
|
44
|
+
match?(row)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|