csv-utils 0.7.0 → 0.8.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.
@@ -4,11 +4,20 @@ require 'fileutils'
4
4
 
5
5
  module CSVUtils
6
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.
7
+ # sorted into temporary files, which are then merged up to {MERGE_WIDTH} at a time, so most files
8
+ # are sorted with a single merge pass.
8
9
  #
9
10
  # @example
10
11
  # CSVUtils::CSVSort.new('input.csv', 'sorted.csv').sort { |a, b| a[0].to_i <=> b[0].to_i }
12
+ # CSVUtils::CSVSort.new('input.csv', 'sorted.csv').sort_by { |row| row[0].to_i }
11
13
  class CSVSort
14
+ # @return [Integer] temporary files merged at once, which bounds the files open at the same time
15
+ MERGE_WIDTH = 64
16
+
17
+ ROW_KEY = proc { |row| row }
18
+ KEY_COMPARE = proc { |key1, key2| key1 <=> key2 }
19
+ private_constant :ROW_KEY, :KEY_COMPARE
20
+
12
21
  # @return [String] path of the file to sort
13
22
  attr_reader :csv_file
14
23
  # @return [String] path of the sorted file to write
@@ -30,127 +39,118 @@ module CSVUtils
30
39
  @new_csv_file = new_csv_file
31
40
  @has_headers = has_headers
32
41
  @csv_options = csv_options
33
- @csv_part_files = []
42
+ @temp_files = []
34
43
  end
35
44
 
36
- # Writes the sorted file. Temporary files are removed even when sorting fails.
45
+ # Writes the file sorted by comparing rows. Temporary files are removed even when sorting fails.
37
46
  # @param batch_size [Integer] rows held in memory and sorted at a time
47
+ # @param tmp_dir [String, nil] directory for the temporary files; the sorted file's directory by default
38
48
  # @yieldparam row1 [Array<String>]
39
49
  # @yieldparam row2 [Array<String>]
40
50
  # @yieldreturn [Integer] negative, 0 or positive like <=>; without a block rows are compared as arrays
41
51
  # @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
52
+ def sort(batch_size = 100_000, tmp_dir: nil, &block)
53
+ compare = block || KEY_COMPARE
54
+ run_sort(batch_size, tmp_dir, ROW_KEY, compare) { |batch| batch.sort!(&compare) }
55
+ end
56
+
57
+ # Writes the file sorted by a key computed once per row, which is faster than {#sort} when
58
+ # the comparison would otherwise convert values (ex: to_i) on every comparison.
59
+ # @param batch_size [Integer] rows held in memory and sorted at a time
60
+ # @param tmp_dir [String, nil] directory for the temporary files; the sorted file's directory by default
61
+ # @yieldparam row [Array<String>]
62
+ # @yieldreturn [Comparable] the sort key; keys are compared with <=>, so use arrays for several columns
63
+ # @return [void]
64
+ def sort_by(batch_size = 100_000, tmp_dir: nil, &key)
65
+ run_sort(batch_size, tmp_dir, key, KEY_COMPARE) { |batch| batch.sort_by!(&key) }
49
66
  end
50
67
 
51
68
  private
52
69
 
53
- # rubocop:disable-next Metrics/MethodLength
54
- def merge_sort_csv_files(src_csv_file1, src_csv_file2, dest_csv_file)
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)
57
- begin
58
- src2 = CSV.open(src_csv_file2, 'rb', **write_options)
59
- begin
60
- dest = CSV.open(dest_csv_file, 'wb', **write_options)
61
- begin
62
- if @headers
63
- dest << @headers
64
- src1.shift
65
- src2.shift
66
- end
67
-
68
- row1 = src1.shift
69
- row2 = src2.shift
70
-
71
- append_row1_proc = proc do
72
- dest << row1
73
- row1 = src1.shift
74
- end
75
-
76
- append_row2_proc = proc do
77
- dest << row2
78
- row2 = src2.shift
79
- end
80
-
81
- while row1 || row2
82
- if row1.nil?
83
- append_row2_proc.call
84
- elsif row2.nil?
85
- append_row1_proc.call
86
- elsif yield(row1, row2) <= 0
87
- append_row1_proc.call
88
- else
89
- append_row2_proc.call
90
- end
91
- end
92
- ensure
93
- dest.close
94
- end
95
- ensure
96
- src2.close
97
- end
98
- ensure
99
- src1.close
100
- end
70
+ def run_sort(batch_size, tmp_dir, key, compare, &)
71
+ @tmp_dir = tmp_dir || File.dirname(new_csv_file)
72
+ @temp_files = []
73
+ part_files = create_sorted_part_files(batch_size, &)
74
+ part_files = merge_pass(part_files, key, compare) while part_files.size > 1
75
+ write_sorted_file(part_files.first)
76
+ ensure
77
+ delete_temp_files
101
78
  end
102
79
 
103
- def create_sorted_csv_part_files(batch_size, &block)
104
- src = CSV.open(csv_file, 'rb', **EncodingOptions.read(csv_options))
105
- begin
106
- @headers = src.shift if has_headers
107
-
108
- batch = []
109
- create_batch_part_proc = proc do
110
- batch.sort!(&block)
111
- @csv_part_files << "#{new_csv_file}.part.#{@csv_part_files.size}"
112
- CSV.open(@csv_part_files.last, 'wb', **write_options) do |csv|
113
- csv << @headers if @headers
114
- batch.each { |row| csv << row }
115
- end
116
- batch = []
80
+ def create_sorted_part_files(batch_size, &sort_batch)
81
+ CSV.open(csv_file, 'rb', **EncodingOptions.read(csv_options)) do |src|
82
+ @headers = (src.shift if has_headers)
83
+ src.each_slice(batch_size).map do |batch|
84
+ sorted = sort_batch.call(batch)
85
+ write_temp_file { |csv| sorted.each { |row| csv << row } }
117
86
  end
87
+ end
88
+ end
118
89
 
119
- while (row = src.shift)
120
- batch << row
121
- create_batch_part_proc.call if batch.size >= batch_size
122
- end
90
+ # merges consecutive groups of files, so each pass divides the number of files by MERGE_WIDTH
91
+ def merge_pass(files, key, compare)
92
+ files.each_slice(MERGE_WIDTH).map do |group|
93
+ next group.first if group.size == 1
123
94
 
124
- create_batch_part_proc.call if batch.size.positive?
125
- ensure
126
- src.close
95
+ merged = write_temp_file { |dest| merge_files(group, dest, key, compare) }
96
+ group.each { |file| File.unlink(file) }
97
+ merged
127
98
  end
128
99
  end
129
100
 
130
- def merge_csv_part_files(&)
131
- file_merge_cnt = 0
132
-
133
- while @csv_part_files.size > 1
134
- file_merge_cnt += 1
101
+ # keeps the next row of every file in a queue sorted by key, so each row written is the smallest left
102
+ def merge_files(files, dest, key, compare)
103
+ open_temp_files(files) do |sources|
104
+ queue = []
105
+ sources.each_with_index do |src, idx|
106
+ src.shift if @headers
107
+ enqueue(queue, src.shift, idx, key, compare)
108
+ end
135
109
 
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)
138
- @csv_part_files << "#{new_csv_file}.merge.#{file_merge_cnt}"
110
+ until queue.empty?
111
+ _, row, idx = queue.shift
112
+ dest << row
113
+ enqueue(queue, sources[idx].shift, idx, key, compare)
114
+ end
115
+ end
116
+ end
139
117
 
140
- merge_sort_csv_files(csv_part_file1, csv_part_file2, @csv_part_files.last, &)
118
+ # rows with equal keys keep the order of the files they came from
119
+ def enqueue(queue, row, idx, key, compare)
120
+ return unless row
141
121
 
142
- @csv_part_files.shift(2).each { |file| File.unlink(file) }
122
+ value = key.call(row)
123
+ pos = queue.bsearch_index do |other_value, _, other_idx|
124
+ result = compare.call(value, other_value)
125
+ result.negative? || (result.zero? && idx < other_idx)
143
126
  end
127
+ queue.insert(pos || queue.size, [value, row, idx])
128
+ end
129
+
130
+ def open_temp_files(files)
131
+ sources = []
132
+ # not map: sources has to hold the files opened before one fails to open, so ensure closes them
133
+ # rubocop:disable-next Style/MapIntoArray
134
+ files.each { |file| sources << CSV.open(file, 'rb', **write_options) }
135
+ yield sources
136
+ ensure
137
+ sources.each(&:close)
138
+ end
144
139
 
145
- if @csv_part_files.size.positive?
146
- FileUtils.mv(@csv_part_files.pop, new_csv_file)
147
- else
148
- write_headers_only
140
+ # temporary files are written with the write options, so reading them back with those changes nothing
141
+ def write_temp_file
142
+ @temp_files << File.join(@tmp_dir, "#{File.basename(new_csv_file)}.#{@temp_files.size}.tmp")
143
+ CSV.open(@temp_files.last, 'wb', **write_options) do |csv|
144
+ csv << @headers if @headers
145
+ yield csv
149
146
  end
147
+ @temp_files.last
150
148
  end
151
149
 
152
150
  # 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
151
+ def write_sorted_file(sorted_file)
152
+ return FileUtils.mv(sorted_file, new_csv_file) if sorted_file
153
+
154
154
  CSV.open(new_csv_file, 'wb', **write_options) do |csv|
155
155
  csv << @headers if @headers
156
156
  end
@@ -161,9 +161,9 @@ module CSVUtils
161
161
  end
162
162
 
163
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 = []
164
+ def delete_temp_files
165
+ @temp_files.each { |file| FileUtils.rm_f(file) }
166
+ @temp_files = []
167
167
  end
168
168
  end
169
169
  end
@@ -54,6 +54,16 @@ module CSVUtils
54
54
  csv.shift
55
55
  end
56
56
 
57
+ # @return [String, nil] the raw text of the last row read, with its row separator
58
+ def line
59
+ csv.line
60
+ end
61
+
62
+ # @return [String] the row separator, detected from the file when it was :auto
63
+ def row_sep
64
+ csv.row_sep
65
+ end
66
+
57
67
  # Moves back to the first row.
58
68
  # @return [void]
59
69
  def rewind
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # Included in every exception this library raises, so `rescue CSVUtils::Error` catches all of them.
5
+ # Each exception also subclasses the error Ruby or CSV would raise, so existing rescues keep working.
6
+ module Error
7
+ end
8
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # Raised when a header the caller named isn't in the file. A RuntimeError, as before this class existed.
5
+ class HeaderNotFoundError < RuntimeError
6
+ include Error
7
+
8
+ # @return [String] the header that wasn't found
9
+ attr_reader :header
10
+ # @return [Array<String>] the headers of the file
11
+ attr_reader :headers
12
+
13
+ # @param header [String]
14
+ # @param headers [Array<String>]
15
+ def initialize(header, headers)
16
+ @header = header
17
+ @headers = headers
18
+ super("header #{header} not found in #{headers}")
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # A CSV::MalformedCSVError with the physical line the bad row starts on, rather than CSV's row count,
5
+ # and the row read before it.
6
+ class MalformedRowError < CSV::MalformedCSVError
7
+ include Error
8
+
9
+ # @return [Array<String>, nil] the last row read successfully
10
+ attr_reader :prev_row
11
+
12
+ # @param message [String] CSV's message without its "in line N." suffix
13
+ # @param line_number [Integer] physical line the bad row starts on
14
+ # @param prev_row [Array<String>, nil]
15
+ def initialize(message, line_number, prev_row)
16
+ @prev_row = prev_row
17
+ super(message, line_number)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # Reads rows from a CSV positioned at its first line, keeping the physical line each row starts on.
5
+ # CSV counts rows, so a quoted value with a line break would make its line numbers fall behind.
6
+ # The byte order mark is stripped from the first row, and rows CSV can't parse raise {MalformedRowError}.
7
+ #
8
+ # @example
9
+ # reader = CSVUtils::RowReader.new(csv)
10
+ # headers = reader.shift
11
+ # while (row = reader.shift)
12
+ # puts "#{reader.lineno}: #{row.inspect}"
13
+ # end
14
+ class RowReader
15
+ # @return [Integer, nil] line the last row read starts on, counting the first line as 1
16
+ attr_reader :lineno
17
+ # @return [Array<String>, nil] the row read before the last one
18
+ attr_reader :prev_row
19
+
20
+ # @param csv [CSV, CSVWrapper] positioned at the first line
21
+ def initialize(csv)
22
+ @csv = csv
23
+ @last_line = 0
24
+ @row = nil
25
+ @prev_row = nil
26
+ end
27
+
28
+ # @return [Array<String>, nil] the next row, nil at the end
29
+ # @raise [MalformedRowError] for a row CSV can't parse, with the line it starts on
30
+ # @raise [CSV::InvalidEncodingError] for bytes that don't match the encoding; CSV raises it when it buffers
31
+ # them, ahead of the rows before them, with the right line already
32
+ def shift
33
+ row = @csv.shift
34
+ @lineno = @last_line + 1
35
+ count_lines(row) if row
36
+ @prev_row = @row
37
+ @row = row
38
+ rescue CSV::InvalidEncodingError
39
+ raise
40
+ rescue CSV::MalformedCSVError => e
41
+ raise MalformedRowError.new(e.message.delete_suffix(" in line #{e.line_number}."), @last_line + 1, @row)
42
+ end
43
+
44
+ private
45
+
46
+ def count_lines(row)
47
+ row[0] = ByteOrderMark.strip(row[0]) if @last_line.zero? && row[0]
48
+ @last_line += @csv.line.count(@csv.row_sep[-1])
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # Raised by {CSVCompare} when a file isn't sorted the way its compare block expects.
5
+ class UnsortedFileError < RuntimeError
6
+ include Error
7
+
8
+ # @return [String] path of the file
9
+ attr_reader :file
10
+ # @return [Integer] physical line of the first record out of order
11
+ attr_reader :lineno
12
+
13
+ # @param file [String]
14
+ # @param lineno [Integer]
15
+ def initialize(file, lineno)
16
+ @file = file
17
+ @lineno = lineno
18
+ super("#{file} isn't sorted: the record on line #{lineno} sorts before the one above it")
19
+ end
20
+ end
21
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CSVUtils
4
4
  # Gem version, bumped by release-please
5
- VERSION = '0.7.0'
5
+ VERSION = '0.8.0'
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: csv-utils
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Doug Youch
@@ -88,6 +88,11 @@ files:
88
88
  - lib/csv_utils/csv_transformer.rb
89
89
  - lib/csv_utils/csv_wrapper.rb
90
90
  - lib/csv_utils/encoding_options.rb
91
+ - lib/csv_utils/error.rb
92
+ - lib/csv_utils/header_not_found_error.rb
93
+ - lib/csv_utils/malformed_row_error.rb
94
+ - lib/csv_utils/row_reader.rb
95
+ - lib/csv_utils/unsorted_file_error.rb
91
96
  - lib/csv_utils/version.rb
92
97
  homepage: https://github.com/dougyouch/csv-utils
93
98
  licenses: