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
|
@@ -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,69 +18,157 @@ 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 [Hash{String => String}] UTF-16 and UTF-32 byte order marks, as binary strings, to the :encoding
|
|
29
|
+
# option that decodes them to UTF-8
|
|
30
|
+
WIDE_ENCODINGS = {
|
|
31
|
+
(+"\x00\x00\xFE\xFF").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-32BE:UTF-8',
|
|
32
|
+
(+"\xFF\xFE\x00\x00").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-32LE:UTF-8',
|
|
33
|
+
(+"\xFE\xFF").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-16BE:UTF-8',
|
|
34
|
+
(+"\xFF\xFE").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-16LE:UTF-8'
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# @return [Integer] bytes read to detect the encoding of a file without a byte order mark
|
|
38
|
+
SAMPLE_SIZE = 1024 * 1024
|
|
33
39
|
|
|
40
|
+
# @return [Integer, nil] number of headers, nil without a column separator
|
|
41
|
+
attr_reader :columns
|
|
42
|
+
# @return [String, nil] the byte order mark the file starts with, as a binary string
|
|
43
|
+
attr_reader :byte_order_mark
|
|
44
|
+
# @return [String] 'UTF-8', 'UTF-16' or 'UTF-32' from the byte order mark; without one, from the bytes of
|
|
45
|
+
# the first {SAMPLE_SIZE} bytes: 'UTF-8', 'Windows-1252' or 'ISO-8859-1' (see {CharacterEncoding.detect})
|
|
46
|
+
attr_reader :encoding
|
|
47
|
+
# @return [String, nil] one of {COL_SEPARATORS}
|
|
48
|
+
attr_reader :col_separator
|
|
49
|
+
# @return [String, nil] one of {ROW_SEPARATORS}
|
|
50
|
+
attr_reader :row_separator
|
|
51
|
+
|
|
52
|
+
# @param io [String, IO] path of the file, or an IO positioned at its first line; up to {SAMPLE_SIZE}
|
|
53
|
+
# bytes are read from it
|
|
34
54
|
def initialize(io)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
File.open(io, 'rb', &:readline)
|
|
38
|
-
else
|
|
39
|
-
io.readline
|
|
40
|
-
end
|
|
55
|
+
sample = read_sample(io)
|
|
56
|
+
line = first_line(sample)
|
|
41
57
|
|
|
42
58
|
@col_separator = auto_detect_col_sep(line)
|
|
43
59
|
@row_separator = auto_detect_row_sep(line)
|
|
44
60
|
@byte_order_mark = get_byte_order_mark(line)
|
|
45
|
-
@encoding = get_character_encoding(@byte_order_mark)
|
|
61
|
+
@encoding = get_character_encoding(@byte_order_mark, sample)
|
|
46
62
|
@columns = get_number_of_columns(line) if @col_separator
|
|
47
63
|
end
|
|
48
64
|
|
|
65
|
+
# Whether both separators were found. An empty file, or a single line without a newline, isn't valid.
|
|
66
|
+
# @return [Boolean]
|
|
49
67
|
def valid?
|
|
50
68
|
return false if @col_separator.nil? || @row_separator.nil?
|
|
51
69
|
|
|
52
70
|
true
|
|
53
71
|
end
|
|
54
72
|
|
|
73
|
+
# Options for CSV.open, or any class in this library, with the detected separators and an :encoding that
|
|
74
|
+
# reads values as UTF-8 strings: 'bom|utf-8', 'Windows-1252:UTF-8', 'BOM|UTF-16LE:UTF-8' and so on.
|
|
75
|
+
# A separator that wasn't detected is left to CSV, and so is the row separator of UTF-16 and UTF-32 files,
|
|
76
|
+
# since it's detected from raw bytes.
|
|
77
|
+
# @example
|
|
78
|
+
# CSV.open(path, 'rb', **options.to_csv_options)
|
|
79
|
+
# CSVUtils::CSVSort.new(path, 'sorted.csv', true, options.to_csv_options).sort
|
|
80
|
+
# @return [Hash{Symbol => String}]
|
|
81
|
+
def to_csv_options
|
|
82
|
+
options = { encoding: encoding_option }
|
|
83
|
+
options[:col_sep] = col_separator if col_separator
|
|
84
|
+
options[:row_sep] = row_separator if row_separator && !wide_encoding?
|
|
85
|
+
options
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @api private
|
|
89
|
+
# @param line [String]
|
|
90
|
+
# @return [String, nil]
|
|
55
91
|
def auto_detect_col_sep(line)
|
|
56
92
|
COL_SEPARATORS.detect { |sep| line.include?(sep) }
|
|
57
93
|
end
|
|
58
94
|
|
|
95
|
+
# @api private
|
|
96
|
+
# @param line [String]
|
|
97
|
+
# @return [String, nil]
|
|
59
98
|
def auto_detect_row_sep(line)
|
|
60
99
|
ROW_SEPARATORS.detect { |sep| line.include?(sep) }
|
|
61
100
|
end
|
|
62
101
|
|
|
102
|
+
# Parses the header line like a CSV row, so quoted headers can contain the separator,
|
|
103
|
+
# and falls back to a plain split when the line is malformed.
|
|
104
|
+
# @param line [String] the first line of the file
|
|
105
|
+
# @return [Array<String, nil>] headers without the byte order mark or row separator
|
|
63
106
|
def get_headers(line)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
107
|
+
line = strip_byte_order_marks(line)
|
|
108
|
+
CSV.parse_line(line, **header_parse_options) || []
|
|
109
|
+
rescue CSV::MalformedCSVError
|
|
110
|
+
line.chomp(row_separator.to_s).split(col_separator, -1)
|
|
67
111
|
end
|
|
68
112
|
|
|
113
|
+
# @api private
|
|
114
|
+
# @param line [String]
|
|
115
|
+
# @return [Integer]
|
|
69
116
|
def get_number_of_columns(line)
|
|
70
117
|
get_headers(line).size
|
|
71
118
|
end
|
|
72
119
|
|
|
120
|
+
# @api private
|
|
121
|
+
# @param line [String]
|
|
122
|
+
# @return [String, nil]
|
|
73
123
|
def get_byte_order_mark(line)
|
|
74
|
-
|
|
75
|
-
line =~ /\A#{bom}/
|
|
76
|
-
end
|
|
124
|
+
ByteOrderMark.detect(line)
|
|
77
125
|
end
|
|
78
126
|
|
|
79
|
-
|
|
80
|
-
|
|
127
|
+
# @api private
|
|
128
|
+
# @param bom [String, nil]
|
|
129
|
+
# @param sample [String] bytes from the start of the file
|
|
130
|
+
# @return [String]
|
|
131
|
+
def get_character_encoding(bom, sample)
|
|
132
|
+
BYTE_ORDER_MARKS[bom] || CharacterEncoding.detect(sample)
|
|
81
133
|
end
|
|
82
134
|
|
|
135
|
+
# @api private
|
|
136
|
+
# @param header [String]
|
|
137
|
+
# @return [String]
|
|
83
138
|
def strip_byte_order_marks(header)
|
|
84
|
-
|
|
139
|
+
ByteOrderMark.strip(header)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# an empty file reads as an empty sample, which makes the options invalid
|
|
145
|
+
def read_sample(io)
|
|
146
|
+
sample = (io.is_a?(String) ? File.open(io, 'rb') { |file| file.read(SAMPLE_SIZE) } : io.read(SAMPLE_SIZE)) || ''
|
|
147
|
+
sample.bytesize < SAMPLE_SIZE ? sample : drop_last_multibyte_char(sample)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# a full sample can end mid-character, so its last multibyte character, whole or not, is dropped
|
|
151
|
+
def drop_last_multibyte_char(sample)
|
|
152
|
+
sample.sub(/[\xC0-\xFF][\x80-\xBF]*\z/n, '')
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def first_line(sample)
|
|
156
|
+
line_end = sample.index("\n")
|
|
157
|
+
line_end ? sample.byteslice(0, line_end + 1) : sample
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def wide_encoding?
|
|
161
|
+
WIDE_ENCODINGS.key?(byte_order_mark)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def encoding_option
|
|
165
|
+
WIDE_ENCODINGS.fetch(byte_order_mark) { encoding == 'UTF-8' ? EncodingOptions::DEFAULT_ENCODING : "#{encoding}:UTF-8" }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def header_parse_options
|
|
169
|
+
options = { col_sep: col_separator }
|
|
170
|
+
options[:row_sep] = row_separator if row_separator
|
|
171
|
+
options
|
|
85
172
|
end
|
|
86
173
|
end
|
|
87
174
|
end
|
data/lib/csv_utils/csv_report.rb
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
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 and
|
|
19
|
+
# :encoding, UTF-8 by default, is the encoding of the file written
|
|
20
|
+
# @yieldparam report [CSVReport] when given, the block is passed to {#generate}
|
|
9
21
|
def initialize(csv, headers = nil, csv_options = {}, &block)
|
|
10
22
|
@csv =
|
|
11
23
|
if csv.is_a?(String)
|
|
12
24
|
@must_close = true
|
|
13
|
-
opts = csv_options
|
|
25
|
+
opts = EncodingOptions.write(csv_options)
|
|
14
26
|
mode = opts.delete(:mode) || 'wb'
|
|
15
27
|
CSV.open(csv, mode, **opts)
|
|
16
28
|
else
|
|
@@ -23,11 +35,18 @@ module CSVUtils
|
|
|
23
35
|
generate(&block) if block
|
|
24
36
|
end
|
|
25
37
|
|
|
38
|
+
# Yields the report, then closes the file if the report opened it, even when the block raises.
|
|
39
|
+
# @yieldparam report [CSVReport]
|
|
40
|
+
# @return [void]
|
|
26
41
|
def generate
|
|
27
42
|
yield self
|
|
43
|
+
ensure
|
|
28
44
|
close if @must_close
|
|
29
45
|
end
|
|
30
46
|
|
|
47
|
+
# Writes a row.
|
|
48
|
+
# @param csv_row [Array, #to_a] an array of values or a {CSVRow} object
|
|
49
|
+
# @return [CSV, #<<]
|
|
31
50
|
def append(csv_row)
|
|
32
51
|
@csv <<
|
|
33
52
|
if csv_row.is_a?(Array)
|
|
@@ -38,10 +57,15 @@ module CSVUtils
|
|
|
38
57
|
end
|
|
39
58
|
alias << append
|
|
40
59
|
|
|
60
|
+
# Writes the header row.
|
|
61
|
+
# @param csv_row [Array<String>, #csv_headers] headers, or a {CSVRow} class or object to take them from
|
|
62
|
+
# @return [CSV, #<<]
|
|
41
63
|
def add_headers(csv_row)
|
|
42
64
|
append(csv_row.is_a?(Array) ? csv_row : csv_row.csv_headers)
|
|
43
65
|
end
|
|
44
66
|
|
|
67
|
+
# Closes the underlying CSV.
|
|
68
|
+
# @return [void]
|
|
45
69
|
def close
|
|
46
70
|
@csv.close
|
|
47
71
|
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
|
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
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)
|
|
4
9
|
class CSVRowMatcher
|
|
5
|
-
|
|
6
|
-
|
|
10
|
+
# @return [Regexp]
|
|
11
|
+
attr_accessor :regex
|
|
12
|
+
# @return [Array<String>, :all] headers to search, or :all for every value
|
|
13
|
+
attr_accessor :columns
|
|
7
14
|
|
|
15
|
+
# @param regex [Regexp]
|
|
16
|
+
# @param columns [Array<String>, :all] headers to search
|
|
8
17
|
def initialize(regex, columns = :all)
|
|
9
18
|
self.regex = regex
|
|
10
19
|
self.columns = columns
|
|
11
20
|
end
|
|
12
21
|
|
|
22
|
+
# Whether any searched value matches. Missing and nil values never match.
|
|
23
|
+
# @param row [Hash{String => String}]
|
|
24
|
+
# @return [Boolean]
|
|
13
25
|
def match?(row)
|
|
14
26
|
if columns == :all
|
|
15
27
|
row.each_value do |value|
|
|
@@ -25,6 +37,8 @@ module CSVUtils
|
|
|
25
37
|
false
|
|
26
38
|
end
|
|
27
39
|
|
|
40
|
+
# Lets the matcher be passed to Enumerable methods as a block, as in rows.select(&matcher).
|
|
41
|
+
# @return [Proc]
|
|
28
42
|
def to_proc
|
|
29
43
|
proc do |row|
|
|
30
44
|
match?(row)
|
data/lib/csv_utils/csv_sort.rb
CHANGED
|
@@ -2,38 +2,62 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
|
|
5
|
-
# Utility class for sorting the rows for a csv file
|
|
6
5
|
module CSVUtils
|
|
6
|
+
# Sorts a CSV file too large to load into memory with an external merge sort: batches of rows are
|
|
7
|
+
# sorted into temporary part files next to the new file, which are then merged in pairs.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# CSVUtils::CSVSort.new('input.csv', 'sorted.csv').sort { |a, b| a[0].to_i <=> b[0].to_i }
|
|
7
11
|
class CSVSort
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
# @return [String] path of the file to sort
|
|
13
|
+
attr_reader :csv_file
|
|
14
|
+
# @return [String] path of the sorted file to write
|
|
15
|
+
attr_reader :new_csv_file
|
|
16
|
+
# @return [Boolean] whether the first row is a header row, kept at the top
|
|
17
|
+
attr_reader :has_headers
|
|
18
|
+
# @return [Hash] options passed to CSV.open
|
|
19
|
+
attr_reader :csv_options
|
|
20
|
+
# @return [Array<String>, nil] the header row, once {#sort} has read it
|
|
21
|
+
attr_reader :headers
|
|
22
|
+
|
|
23
|
+
# @param csv_file [String] path of the file to sort
|
|
24
|
+
# @param new_csv_file [String] path of the sorted file to write
|
|
25
|
+
# @param has_headers [Boolean] whether the first row is a header row
|
|
26
|
+
# @param csv_options [Hash] options passed to CSV.open; the file is read with its :encoding, 'bom|utf-8' by
|
|
27
|
+
# default, and the sorted file written in the encoding values were decoded to (see {EncodingOptions})
|
|
14
28
|
def initialize(csv_file, new_csv_file, has_headers = true, csv_options = {})
|
|
15
29
|
@csv_file = csv_file
|
|
16
30
|
@new_csv_file = new_csv_file
|
|
17
31
|
@has_headers = has_headers
|
|
18
32
|
@csv_options = csv_options
|
|
19
33
|
@csv_part_files = []
|
|
20
|
-
@files_to_delete = []
|
|
21
34
|
end
|
|
22
35
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
36
|
+
# Writes the sorted file. Temporary files are removed even when sorting fails.
|
|
37
|
+
# @param batch_size [Integer] rows held in memory and sorted at a time
|
|
38
|
+
# @yieldparam row1 [Array<String>]
|
|
39
|
+
# @yieldparam row2 [Array<String>]
|
|
40
|
+
# @yieldreturn [Integer] negative, 0 or positive like <=>; without a block rows are compared as arrays
|
|
41
|
+
# @return [void]
|
|
42
|
+
def sort(batch_size = 100_000, &block)
|
|
43
|
+
block ||= proc { |row1, row2| row1 <=> row2 }
|
|
44
|
+
@csv_part_files = []
|
|
45
|
+
create_sorted_csv_part_files(batch_size, &block)
|
|
46
|
+
merge_csv_part_files(&block)
|
|
47
|
+
ensure
|
|
48
|
+
delete_csv_part_files
|
|
26
49
|
end
|
|
27
50
|
|
|
28
51
|
private
|
|
29
52
|
|
|
30
|
-
# rubocop:disable Metrics/MethodLength
|
|
53
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
31
54
|
def merge_sort_csv_files(src_csv_file1, src_csv_file2, dest_csv_file)
|
|
32
|
-
|
|
55
|
+
# part files were written with the write options, so reading them back with those changes nothing
|
|
56
|
+
src1 = CSV.open(src_csv_file1, 'rb', **write_options)
|
|
33
57
|
begin
|
|
34
|
-
src2 = CSV.open(src_csv_file2, 'rb', **
|
|
58
|
+
src2 = CSV.open(src_csv_file2, 'rb', **write_options)
|
|
35
59
|
begin
|
|
36
|
-
dest = CSV.open(dest_csv_file, 'wb', **
|
|
60
|
+
dest = CSV.open(dest_csv_file, 'wb', **write_options)
|
|
37
61
|
begin
|
|
38
62
|
if @headers
|
|
39
63
|
dest << @headers
|
|
@@ -75,10 +99,9 @@ module CSVUtils
|
|
|
75
99
|
src1.close
|
|
76
100
|
end
|
|
77
101
|
end
|
|
78
|
-
# rubocop:enable Metrics/MethodLength
|
|
79
102
|
|
|
80
103
|
def create_sorted_csv_part_files(batch_size, &block)
|
|
81
|
-
src = CSV.open(csv_file, 'rb', **csv_options)
|
|
104
|
+
src = CSV.open(csv_file, 'rb', **EncodingOptions.read(csv_options))
|
|
82
105
|
begin
|
|
83
106
|
@headers = src.shift if has_headers
|
|
84
107
|
|
|
@@ -86,7 +109,7 @@ module CSVUtils
|
|
|
86
109
|
create_batch_part_proc = proc do
|
|
87
110
|
batch.sort!(&block)
|
|
88
111
|
@csv_part_files << "#{new_csv_file}.part.#{@csv_part_files.size}"
|
|
89
|
-
CSV.open(@csv_part_files.last, 'wb', **
|
|
112
|
+
CSV.open(@csv_part_files.last, 'wb', **write_options) do |csv|
|
|
90
113
|
csv << @headers if @headers
|
|
91
114
|
batch.each { |row| csv << row }
|
|
92
115
|
end
|
|
@@ -110,21 +133,37 @@ module CSVUtils
|
|
|
110
133
|
while @csv_part_files.size > 1
|
|
111
134
|
file_merge_cnt += 1
|
|
112
135
|
|
|
113
|
-
|
|
114
|
-
csv_part_file2 = @csv_part_files.
|
|
136
|
+
# inputs stay in the list until merged, so a failed merge still cleans them up
|
|
137
|
+
csv_part_file1, csv_part_file2 = @csv_part_files.first(2)
|
|
115
138
|
@csv_part_files << "#{new_csv_file}.merge.#{file_merge_cnt}"
|
|
116
139
|
|
|
117
140
|
merge_sort_csv_files(csv_part_file1, csv_part_file2, @csv_part_files.last, &)
|
|
118
141
|
|
|
119
|
-
File.unlink(
|
|
120
|
-
File.unlink(csv_part_file2)
|
|
142
|
+
@csv_part_files.shift(2).each { |file| File.unlink(file) }
|
|
121
143
|
end
|
|
122
144
|
|
|
123
145
|
if @csv_part_files.size.positive?
|
|
124
|
-
FileUtils.mv(@csv_part_files.
|
|
146
|
+
FileUtils.mv(@csv_part_files.pop, new_csv_file)
|
|
125
147
|
else
|
|
126
|
-
|
|
148
|
+
write_headers_only
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# a file without rows is written rather than copied, so it's in the same encoding as a sorted one
|
|
153
|
+
def write_headers_only
|
|
154
|
+
CSV.open(new_csv_file, 'wb', **write_options) do |csv|
|
|
155
|
+
csv << @headers if @headers
|
|
127
156
|
end
|
|
128
157
|
end
|
|
158
|
+
|
|
159
|
+
def write_options
|
|
160
|
+
EncodingOptions.write(csv_options)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# removes the temporary files left behind when sorting fails part way through
|
|
164
|
+
def delete_csv_part_files
|
|
165
|
+
@csv_part_files.each { |file| FileUtils.rm_f(file) }
|
|
166
|
+
@csv_part_files = []
|
|
167
|
+
end
|
|
129
168
|
end
|
|
130
169
|
end
|