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.
@@ -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
- # primary_data_file is the source of truth
9
- # compare_proc used to compare the id column(s)
10
- # update_comparison_columns column(s) to compare for equality, ex: updated_at, timestamp, hash
11
- # caveat: update_comparison_columns need to be in both csv files
12
- attr_reader :primary_data_file,
13
- :update_comparison_columns,
14
- :compare_proc
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
- def initialize(primary_data_file, update_comparison_columns = nil, &block)
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
- # rubocop:disable Metrics/MethodLength
23
- def compare(secondary_data_file)
24
- src = CSV.open(primary_data_file, 'rb')
25
- begin
26
- src_headers = src.shift
27
- strip_bom!(src_headers[0])
28
- dest = CSV.open(secondary_data_file, 'rb')
29
- begin
30
- dest_headers = dest.shift
31
- strip_bom!(dest_headers[0])
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
- read_next_src = true
34
- read_next_dest = true
53
+ private
35
54
 
36
- while !src.eof? || !dest.eof?
37
- src_record = next_record_from_file(src_headers, src) if read_next_src
38
- dest_record = next_record_from_file(dest_headers, dest) if read_next_dest
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
- if !src_record
41
- read_next_src = false
42
- read_next_dest = true
43
- yield :delete, dest_record
44
- elsif !dest_record
45
- read_next_src = true
46
- read_next_dest = false
47
- yield :create, src_record
48
- elsif compare_proc.call(src_record, dest_record).zero?
49
- read_next_src = true
50
- read_next_dest = true
51
- yield(:update, src_record) if update_row?(src_record, dest_record)
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
- private
76
+ def record_action(src_record, dest_record)
77
+ return :delete unless src_record
78
+ return :create unless dest_record
72
79
 
73
- def next_record_from_file(headers, file)
74
- return nil if file.eof?
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
- headers.zip(file.shift).to_h
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 update_row?(src_record, dest_record)
80
- return false unless update_comparison_columns
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
- update_comparison_columns.each do |column_name|
83
- return true unless src_record[column_name] == dest_record[column_name]
84
- end
103
+ def next_record(headers, csv)
104
+ return unless (row = csv.shift)
85
105
 
86
- false
106
+ headers.zip(row).to_h
87
107
  end
88
108
 
89
- def strip_bom!(col)
90
- col.sub!((+"\xEF\xBB\xBF").force_encoding('ASCII-8BIT'), '')
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
- BYTE_ORDER_MARKS = [
9
- (+"\xEF\xBB\xBF").force_encoding('ASCII-8BIT'), # UTF-8
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 = RowWrapper[headers.zip(row)]
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
- def initialize(src_csv, csv_options = {}, mode = 'rb')
36
- @src_csv = CSVUtils::CSVWrapper.new(src_csv, mode, csv_options)
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
- def each(headers = nil)
40
- @src_csv.rewind
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
- lineno = 0
43
- unless headers
44
- headers = @src_csv.shift
45
- strip_bom!(headers[0])
46
- lineno += 1
47
- end
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
- @prev_row = nil
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
- @src_csv.rewind
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
- raise("header #{key} not found in #{headers}") unless headers.include?(key)
66
- raise("headers #{value} not found in #{headers}") if value && !headers.include?(value)
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
- each_with_object({}) do |row, hsh|
76
- hsh[row[key]] = value_proc.call(row)
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
- @src_csv.rewind
82
- @src_csv.shift
83
- cnt = 0
84
- cnt += 1 while @src_csv.shift
85
- cnt
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
- def strip_bom!(col)
109
- BYTE_ORDER_MARKS.each do |bom|
110
- if col.start_with?(bom)
111
- col.sub!(bom, '')
112
- break
113
- end
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