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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b30f6d77e44b6d4abca4ee199142b4efa6cb73895df87ba630b82f50b964fbc
4
- data.tar.gz: 10337ebbb358c3e60f5f4d47756297577468b45bbaeaf4e1b0b355b12c9083a4
3
+ metadata.gz: 423eacb18512c595abe83765ccfd4d945f1e2e0d18a6370b6841997eb428ddb7
4
+ data.tar.gz: 3d2df7f1ee62eaebc25d00281401bf49f0fccdb809ec03cbac90e6bf5ba2fd62
5
5
  SHA512:
6
- metadata.gz: 84432b88f8fc0aee4422fe46a36bab6e99cca1d604469608e2e4ced97c1f748be66796bc7f0c161b660d31e6424f5b155b38bfb034e9cee234fd8c71459bb2d3
7
- data.tar.gz: ad864bb734d1c9f7d6a99d97aa2567811c66f8bf317571a947d56196776c61abf229d3d3aad0f5485f1a39b64d257e5be234ffa19dcc9c8a975a045fffcd95b1
6
+ metadata.gz: 2f9856120036ae6a87e2e758186221b7c0b48aa8d94365f7bdf4bffdf8f292868e43cf3c378eff7460902d710de2859d5406ac9ef5384c07f9728f2f1bad4ef4
7
+ data.tar.gz: 9e96e672cb1f4da44ae67de48d3e783cf1d2b180c0d8f4a9706ce1ace07b9ac456a689653dfe84537c0cab71e6c58cdb24e5caeec0bcc86bc73a374615ac4e0a
data/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ ## [0.6.0](https://github.com/dougyouch/csv-utils/compare/v0.5.0...v0.6.0) (2026-10-05)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * **gem:** Ruby 3.2 reached end of life in March 2026 and is no longer supported. The gemspec now declares required_ruby_version >= 3.3.
9
+
10
+ ### Features
11
+
12
+ * **compare:** accept csv options for both files ([4336edf](https://github.com/dougyouch/csv-utils/commit/4336edf8ca928ebc73c7b58a533ee7280267907e))
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * **bom:** detect utf-32 le and strip marks from text in any encoding ([7c68517](https://github.com/dougyouch/csv-utils/commit/7c6851755b9ddfb6ca49b459d9ada578c35a634d))
18
+ * **cli:** keep trailing whitespace and raw bytes in csv-change-eol ([a61e53a](https://github.com/dougyouch/csv-utils/commit/a61e53a3add068ebc49c1f6025f06185e8b8a702))
19
+ * **cli:** match csv-diff rows on the --unique headers and allow empty cells ([3e0d1ae](https://github.com/dougyouch/csv-utils/commit/3e0d1ae3763090ade46068e056ee909711a2d4df))
20
+ * **cli:** read raw bytes in csv-validator ([80c5002](https://github.com/dougyouch/csv-utils/commit/80c5002eb91474d58d7999f41385743c4f719c78))
21
+ * **cli:** report line numbers and handle any bytes in csv-duplicate-finder ([f87144d](https://github.com/dougyouch/csv-utils/commit/f87144d07727c9a418c0a37a6e82ceedf55ecf59))
22
+ * **cli:** run the bundled csv-readline from csv-find-error ([e6736c6](https://github.com/dougyouch/csv-utils/commit/e6736c6b2682b7611079a1bb5d10ea5df85f3f92))
23
+ * **cli:** split single column files with csv-splitter ([ee00da6](https://github.com/dougyouch/csv-utils/commit/ee00da6f9128a744345583541850348490cabdfc))
24
+ * **compare:** yield the last record when the other file ends first ([947d2b7](https://github.com/dougyouch/csv-utils/commit/947d2b73d4aa404c52b147782f5bc3c02f5afd42))
25
+ * **io:** close files when a block raises ([5ffa159](https://github.com/dougyouch/csv-utils/commit/5ffa159255e6a134721075536f7b70de9f7c1244))
26
+ * **iterator:** return an enumerator from each without a block ([751d034](https://github.com/dougyouch/csv-utils/commit/751d034d325279f4eaeca76b726e4a63a6443042))
27
+ * **options:** count quoted headers correctly and handle empty files ([abbd084](https://github.com/dougyouch/csv-utils/commit/abbd084a4a2cf84bf27410d616c63e3679f07a14))
28
+ * **row:** stop csv_column from changing the options hash passed in ([6a9f916](https://github.com/dougyouch/csv-utils/commit/6a9f916d1e3c9b0f27410a4cae795f80e5968cec))
29
+ * **sort:** sort without a block and clean up temporary files on failure ([bc2d21a](https://github.com/dougyouch/csv-utils/commit/bc2d21ae63973323d12084de2710d07e06e70dbb))
30
+ * **transformer:** allow append when headers were not read ([9d1e042](https://github.com/dougyouch/csv-utils/commit/9d1e04259e5819eaf5865895674b8aafc8d51c8f))
31
+
32
+
33
+ ### Build System
34
+
35
+ * **gem:** require ruby 3.3 and add gem metadata ([e34a89a](https://github.com/dougyouch/csv-utils/commit/e34a89a473159fa4c70f5279d63ca95499a43c90))
36
+
37
+ ## Changelog
data/README.md CHANGED
@@ -1,20 +1,30 @@
1
1
  # CSV Utils
2
2
 
3
- A Ruby library providing a comprehensive set of utilities for manipulating and processing CSV files. This library offers a robust set of tools for comparing, transforming, sorting, and managing CSV data efficiently.
3
+ Streaming tools for CSV files that are too big or too broken to load into memory. Compare two sorted files into create, update and delete actions, sort with an external merge sort, transform and extend rows in batches, and build reports from Ruby objects. Command line tools pinpoint malformed rows and diff, grep, split and validate CSV files.
4
+
5
+ [![CI](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml)
6
+ [![Coverage](https://raw.githubusercontent.com/dougyouch/csv-utils/badges/coverage.svg)](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml)
7
+ [![Branch Coverage](https://raw.githubusercontent.com/dougyouch/csv-utils/badges/branches.svg)](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml)
8
+
9
+ [API reference](https://rubydoc.info/gems/csv-utils) · [Changelog](CHANGELOG.md) · [Architecture](ARCHITECTURE.md)
4
10
 
5
11
  ## Features
6
12
 
7
13
  - **CSV Comparison**: Compare two CSV files and identify differences (creates, updates, and deletes)
8
- - **CSV Transformation**: Transform CSV data with customizable rules
9
- - **CSV Sorting**: Sort CSV files based on specified columns
10
- - **CSV Reporting**: Generate reports from CSV data
11
- - **CSV Iteration**: Efficient iteration over CSV files
12
- - **CSV Extension**: Extend CSV files with additional data
13
- - **CSV Wrapper**: Convenient wrapper for CSV operations
14
+ - **CSV Transformation**: Transform CSV data with a chainable pipeline
15
+ - **CSV Sorting**: Sort large CSV files using external merge sort
16
+ - **CSV Reporting**: Generate CSV reports from Ruby objects
17
+ - **CSV Row**: Mixin for defining CSV-serializable objects
18
+ - **CSV Row Matcher**: Filter CSV rows using regex patterns across columns
19
+ - **CSV Iteration**: Efficient iteration over CSV files with batch support
20
+ - **CSV Extension**: Extend CSV files with additional columns
21
+ - **CSV Options**: Auto-detect CSV file properties (separators, encoding, BOM)
22
+ - **Byte Order Marks**: UTF-8, UTF-16 and UTF-32 byte order marks are stripped from the first header
23
+ - **CLI Tools**: Command-line utilities for CSV debugging and manipulation
14
24
 
15
25
  ## Installation
16
26
 
17
- Add this line to your application's Gemfile:
27
+ Requires Ruby 3.3 or newer. Add this line to your application's Gemfile:
18
28
 
19
29
  ```ruby
20
30
  gem 'csv-utils'
@@ -36,8 +46,10 @@ $ gem install csv-utils
36
46
 
37
47
  ### Comparing CSV Files
38
48
 
49
+ Compare two sorted CSV files to identify creates, updates, and deletes:
50
+
39
51
  ```ruby
40
- require 'csv_utils'
52
+ require 'csv-utils'
41
53
 
42
54
  comparator = CSVUtils::CSVCompare.new('primary.csv', ['updated_at']) do |src, dest|
43
55
  src['id'] <=> dest['id']
@@ -55,32 +67,317 @@ comparator.compare('secondary.csv') do |action, record|
55
67
  end
56
68
  ```
57
69
 
70
+ Pass CSV options as the third argument to read both files with them, e.g. tab separated files:
71
+
72
+ ```ruby
73
+ CSVUtils::CSVCompare.new('primary.tsv', ['updated_at'], col_sep: "\t") { |src, dest| src['id'] <=> dest['id'] }
74
+ ```
75
+
76
+ **Note**: Both CSV files must be sorted by the same key columns for comparison to work correctly, and the block must compare them in that same order.
77
+
78
+ The block compares the key columns of a primary and a secondary record like `<=>`. Records only in the primary file are yielded as `:create`, records only in the secondary file as `:delete`, and matching records as `:update` when any of the update comparison columns differ. Without update comparison columns, no updates are yielded.
79
+
58
80
  ### Sorting CSV Files
59
81
 
82
+ Sort large CSV files using external merge sort:
83
+
60
84
  ```ruby
61
- sorter = CSVUtils::CSVSort.new('input.csv')
62
- sorter.sort('output.csv', ['id', 'name'])
85
+ require 'csv-utils'
86
+
87
+ sorter = CSVUtils::CSVSort.new('input.csv', 'output.csv', true) # true = has headers
88
+ sorter.sort(100_000) { |a, b| a.first.to_i <=> b.first.to_i } # batch size, comparison block
89
+
90
+ # without a block, rows are compared as arrays of strings
91
+ sorter.sort
63
92
  ```
64
93
 
94
+ Batches are sorted into temporary `output.csv.part.N` files next to the output, which are merged in pairs. The temporary files are removed when the sort finishes or fails.
95
+
65
96
  ### Transforming CSV Data
66
97
 
98
+ Transform CSV data using a chainable pipeline:
99
+
100
+ ```ruby
101
+ require 'csv-utils'
102
+
103
+ CSVUtils::CSVTransformer.new('input.csv', 'output.csv')
104
+ .read_headers
105
+ .select { |row, headers, _| row[0].to_i > 100 } # filter rows
106
+ .map(['ID', 'Name']) { |row, headers, _| [row[0], row[1].upcase] } # transform rows
107
+ .append(['Email']) { |row, headers, _| ["#{row[1].downcase}@example.com"] }
108
+ .process(10_000) # batch size
109
+ ```
110
+
111
+ Available pipeline methods:
112
+ - `select { |row, headers, additional_data| }` - Keep rows where block returns true
113
+ - `reject { |row, headers, additional_data| }` - Remove rows where block returns true
114
+ - `map(new_headers) { |row, headers, additional_data| }` - Transform rows
115
+ - `append(additional_headers) { |row, headers, additional_data| }` - Add columns
116
+ - `additional_data { |batch, headers| }` - Compute batch-level data for use in other steps
117
+ - `each { |row, headers, additional_data| }` - Side effects without modification
118
+ - `set_headers(headers)` - Override output headers
119
+
120
+ Without `read_headers`, every row is treated as data and the output has no header row. `process` closes the files it opened, even when a step raises.
121
+
122
+ ### CSV Row and Report
123
+
124
+ Define CSV-serializable objects and generate reports:
125
+
126
+ ```ruby
127
+ require 'csv-utils'
128
+
129
+ class User
130
+ include CSVUtils::CSVRow
131
+
132
+ attr_accessor :id, :name, :email
133
+
134
+ csv_column :id, header: 'ID'
135
+ csv_column :name
136
+ csv_column(:email) { email.downcase }
137
+
138
+ def initialize(id, name, email)
139
+ @id = id
140
+ @name = name
141
+ @email = email
142
+ end
143
+ end
144
+
145
+ users = [
146
+ User.new(1, 'Alice', 'ALICE@example.com'),
147
+ User.new(2, 'Bob', 'BOB@example.com')
148
+ ]
149
+
150
+ # Generate CSV report
151
+ CSVUtils::CSVReport.new('users.csv', User) do |report|
152
+ users.each { |user| report << user }
153
+ end
154
+ ```
155
+
156
+ The `csv_column` method accepts:
157
+ - A symbol referencing a method: `csv_column :name`
158
+ - A custom header: `csv_column :id, header: 'ID'`
159
+ - A block for computed values: `csv_column(:email) { email.downcase }`
160
+ - A proc: `csv_column :count, proc: Proc.new { data[:count] }`
161
+
162
+ #### Generating Reports from ActiveRecord Models
163
+
164
+ A powerful pattern is to subclass an ActiveRecord model with `CSVRow` for generating reports directly from database records:
165
+
67
166
  ```ruby
68
- transformer = CSVUtils::CSVTransformer.new('input.csv')
69
- transformer.transform('output.csv') do |row|
70
- # Transform row data
71
- row['new_column'] = row['old_column'].upcase
72
- row
167
+ require 'csv-utils'
168
+
169
+ class UserCSVRow < User
170
+ include CSVUtils::CSVRow
171
+
172
+ csv_column :id
173
+ csv_column :name
174
+ csv_column :email
175
+ csv_column :num_orders # computed column
176
+ csv_column :total_revenue # computed column
177
+
178
+ def num_orders
179
+ orders.count
180
+ end
181
+
182
+ def total_revenue
183
+ orders.sum(:amount)
184
+ end
185
+
186
+ # free up memory during large iterations
187
+ def clear!
188
+ @association_cache = {}
189
+ end
190
+ end
191
+
192
+ # Generate report using ActiveRecord scopes
193
+ CSVUtils::CSVReport.new('user_report.csv', UserCSVRow) do |report|
194
+ UserCSVRow.where(active: true).find_each do |user|
195
+ report << user
196
+ user.clear!
197
+ end
73
198
  end
74
199
  ```
75
200
 
201
+ This pattern provides:
202
+ - **Inherited attributes**: All model columns available without redefinition
203
+ - **Association access**: Query related tables for computed columns
204
+ - **ActiveRecord scopes**: Use `.where`, `.includes`, `.find_each` directly
205
+ - **Memory efficiency**: The `clear!` method frees association cache during iteration
206
+
207
+ ### Iterating CSV Files
208
+
209
+ Efficiently iterate over CSV files:
210
+
211
+ ```ruby
212
+ require 'csv-utils'
213
+
214
+ iterator = CSVUtils::CSVIterator.new('data.csv')
215
+
216
+ # Iterate row by row
217
+ iterator.each do |row|
218
+ puts "Line #{row.lineno}: #{row['name']}"
219
+ end
220
+
221
+ # Process in batches
222
+ iterator.each_batch(1_000) do |batch|
223
+ # Process batch of rows
224
+ end
225
+
226
+ # Build a lookup hash
227
+ lookup = iterator.to_hash('id', 'name') # { 'id_value' => 'name_value', ... }
228
+
229
+ # Without a block, each returns an Enumerator
230
+ iterator.each.with_index { |row, idx| puts "#{idx}: #{row['name']}" }
231
+ ```
232
+
233
+ Line numbers count the header row as line 1, so they match what an editor shows. `headers` returns `[]` for an empty file.
234
+
235
+ ### Matching CSV Rows
236
+
237
+ Filter CSV rows using regex patterns:
238
+
239
+ ```ruby
240
+ require 'csv-utils'
241
+
242
+ # Match against all columns
243
+ matcher = CSVUtils::CSVRowMatcher.new(/error/i)
244
+
245
+ # Or match only specific columns
246
+ matcher = CSVUtils::CSVRowMatcher.new(/error/i, ['status', 'message'])
247
+
248
+ # Use with iteration
249
+ iterator = CSVUtils::CSVIterator.new('logs.csv')
250
+ error_rows = iterator.select(&matcher)
251
+
252
+ # Use directly
253
+ row = { 'id' => '123', 'status' => 'Error', 'message' => 'Connection failed' }
254
+ matcher.match?(row) # => true
255
+ ```
256
+
257
+ The matcher can be used with any Enumerable method via `to_proc`:
258
+
259
+ ```ruby
260
+ rows.select(&matcher) # rows matching the pattern
261
+ rows.reject(&matcher) # rows not matching the pattern
262
+ rows.find(&matcher) # first matching row
263
+ ```
264
+
265
+ ### Extending CSV Files
266
+
267
+ Add columns to an existing CSV:
268
+
269
+ ```ruby
270
+ require 'csv-utils'
271
+
272
+ extender = CSVUtils::CSVExtender.new('input.csv', 'output.csv')
273
+
274
+ # Row by row
275
+ extender.append(['new_column']) do |row, headers|
276
+ [row[0].upcase] # return array of new column values
277
+ end
278
+
279
+ # Or in batches (useful for external lookups)
280
+ extender.append_in_batches(['status'], 1_000) do |batch, headers|
281
+ # Return array of arrays, one per row in batch
282
+ batch.map { |row| ['active'] }
283
+ end
284
+ ```
285
+
286
+ ### Auto-detecting CSV Options
287
+
288
+ Detect CSV file properties automatically:
289
+
290
+ ```ruby
291
+ require 'csv-utils'
292
+
293
+ options = CSVUtils::CSVOptions.new('data.csv')
294
+
295
+ options.valid? # true if separators detected
296
+ options.col_separator # detected column separator
297
+ options.row_separator # detected row separator
298
+ options.encoding # detected encoding (UTF-8, UTF-16, UTF-32)
299
+ options.columns # number of columns
300
+ options.byte_order_mark # BOM if present
301
+ ```
302
+
303
+ Supported column separators: `\x02`, `\t`, `|`, `,` (the first one found in the header line wins)
304
+ Supported row separators: `\r\n`, `\n`, `\r`
305
+
306
+ Headers are parsed as a CSV row, so a quoted header like `"Last, First"` counts as one column. An empty file isn't valid.
307
+
308
+ ### Encodings and Byte Order Marks
309
+
310
+ Files are opened with mode `'rb'`. With csv 3.3 and later, when Ruby's default external encoding is UTF-8 (the usual case), CSV reads `'rb'` files as UTF-8 and raises `CSV::InvalidEncodingError` on bytes that aren't valid UTF-8. To read a file's bytes as is, pass an explicit encoding:
311
+
312
+ ```ruby
313
+ iterator = CSVUtils::CSVIterator.new('latin1.csv', {}, 'rb:BINARY')
314
+ comparator = CSVUtils::CSVCompare.new('primary.csv', ['updated_at'], encoding: 'BINARY') { |src, dest| src['id'] <=> dest['id'] }
315
+ sorter = CSVUtils::CSVSort.new('input.csv', 'output.csv', true, encoding: 'BINARY')
316
+ ```
317
+
318
+ `CSVSort`, `CSVExtender` and `CSVTransformer` take CSV options as their last argument and `CSVIterator` takes them second; they're passed to `CSV.open` for file paths.
319
+
320
+ `CSVUtils::ByteOrderMark` detects and strips UTF-8, UTF-16 and UTF-32 byte order marks; `CSVIterator`, `CSVCompare` and `CSVOptions` use it to clean the first header.
321
+
322
+ ## CLI Tools
323
+
324
+ The gem installs command-line utilities for CSV debugging:
325
+
326
+ | Command | Description |
327
+ |---------|-------------|
328
+ | `csv-find-error` | Locate the first malformed row and show it with `csv-readline` |
329
+ | `csv-readline` | Print the columns of a line, flagging stray quotes |
330
+ | `csv-validator` | Report rows with the wrong number of columns and values that aren't UTF-8 |
331
+ | `csv-diff` | Compare two CSV files by a unique key |
332
+ | `csv-grep` | Search columns for a pattern |
333
+ | `csv-splitter` | Split a large CSV file into parts |
334
+ | `csv-explorer` | Open an IRB session with the file loaded as a `CSVIterator` |
335
+ | `csv-duplicate-finder` | Find duplicate rows |
336
+ | `csv-change-eol` | Rewrite a file with a different line ending |
337
+
338
+ ```bash
339
+ # find the first malformed row
340
+ csv-find-error data.csv
341
+
342
+ # print the row on line 1042, read as 3 lines for values with embedded newlines, including empty columns
343
+ csv-readline --all data.csv 1042 3
344
+
345
+ # compare by the id column, ignoring updated_at; writes diff-results-old.csv
346
+ csv-diff -u id -i updated_at old.csv new.csv
347
+
348
+ # case-insensitive search of the email and name columns, first 10 matches
349
+ csv-grep -s 'smith' -c email,name -i -l 10 data.csv
350
+
351
+ # split into files of 100,000 rows, each with the header
352
+ csv-splitter -r 100000 data.csv
353
+
354
+ # find rows that are duplicates apart from id; writes duplicates-data.csv
355
+ csv-duplicate-finder -i id data.csv
356
+
357
+ # end every row with |^| and a newline
358
+ csv-change-eol data.csv 7C5E7C0A
359
+ ```
360
+
361
+ `csv-validator` needs the `rchardet` gem (`gem install rchardet`) to guess the encoding of values that aren't UTF-8. It writes the converted values to `utf8-correction.csv`.
362
+
76
363
  ## Development
77
364
 
78
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
365
+ After checking out the repo, run `bundle install` to install dependencies. Then:
366
+
367
+ ```bash
368
+ bundle exec rspec # run the tests
369
+ bundle exec rubocop # run the linter
370
+ bundle exec yard stats --list-undoc # check the API docs
371
+ ```
372
+
373
+ CI runs RuboCop, requires every public class, module, constant and method to have a YARD doc comment, and runs the specs on Ruby 3.3 and the `.ruby-version` Ruby with 100% line and branch coverage (`CI=1 bundle exec rspec` enforces it locally).
374
+
375
+ Releases are automated by [release-please](https://github.com/googleapis/release-please) from [conventional commits](https://www.conventionalcommits.org/): merging its release PR tags the version and publishes the gem.
79
376
 
80
377
  ## Contributing
81
378
 
82
- Bug reports and pull requests are welcome on GitHub at https://github.com/yourusername/csv-utils.
379
+ Bug reports and pull requests are welcome on GitHub at https://github.com/dougyouch/csv-utils.
83
380
 
84
381
  ## License
85
382
 
86
- The gem is available as open source under the terms of the MIT License.
383
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/bin/csv-change-eol CHANGED
@@ -28,12 +28,11 @@ csv_file = ARGV.shift || exit_on_error('no csv file specified')
28
28
  eol_sequence = ARGV.shift || exit_on_error('no EOL character sequence specified')
29
29
 
30
30
  exit_on_error("file #{csv_file} not found") unless File.exist?(csv_file)
31
- exit_on_error("not a HEX sequece (#{eol_sequence})") unless eol_sequence =~ /\A[0-9a-f]+\z/i
31
+ exit_on_error("not a HEX sequence (#{eol_sequence})") unless eol_sequence =~ /\A[0-9a-f]+\z/i
32
32
  exit_on_error("incorrect number of characters in (#{eol_sequence}), should be even") unless eol_sequence.size.even?
33
33
 
34
34
  eol_sequence = [eol_sequence].pack('H*')
35
35
 
36
-
37
36
  escaped_csv_file =
38
37
  if csv_file =~ /\.csv$/i
39
38
  csv_file.sub(/(\.csv)$/i, '.escaped-eol\1')
@@ -41,13 +40,10 @@ escaped_csv_file =
41
40
  csv_file + '.escaped-eol'
42
41
  end
43
42
 
44
-
43
+ # read and write bytes as is, so values in any encoding pass through unchanged
45
44
  File.open(escaped_csv_file, 'wb') do |out|
46
- CSV.foreach(csv_file) do |row|
47
- line = row.to_csv
48
- line.rstrip!
49
- line.concat(eol_sequence)
50
- out.write line
45
+ CSV.foreach(csv_file, 'rb:BINARY') do |row|
46
+ out.write row.to_csv(row_sep: eol_sequence)
51
47
  end
52
48
  end
53
49
 
data/bin/csv-diff CHANGED
@@ -8,72 +8,64 @@ options = {
8
8
  sort_batch_size: 1_000_000
9
9
  }
10
10
  OptionParser.new do |opts|
11
- opts.banner = 'Usage: ' + File.basename(__FILE__) + ' [options] <csv file>'
11
+ opts.banner = 'Usage: ' + File.basename(__FILE__) + ' [options] <csv file 1> <csv file 2>'
12
12
 
13
13
  opts.on('-h', '--help', 'Prints this help') do
14
14
  puts opts
15
15
  exit
16
16
  end
17
17
 
18
- opts.on('-u', '--unique HEADERS', 'Comman separated list of headers that genrate a unique key per a row, use 1st column by default') do |v|
18
+ opts.on('-u', '--unique HEADERS', 'Comma separated list of headers that generate a unique key per row, uses 1st column by default') do |v|
19
19
  options[:unique_headers] = v.split(',')
20
20
  end
21
21
 
22
- opts.on('-i', '--ignore HEADERS', 'Comman separated list of headers to ignore during row comparison') do |v|
22
+ opts.on('-i', '--ignore HEADERS', 'Comma separated list of headers to ignore during row comparison') do |v|
23
23
  options[:ignore_headers] = v.split(',')
24
24
  end
25
25
 
26
26
  opts.on('--sort-batch-size SIZE', Integer, 'Number of rows to load into memory while sorting') do |v|
27
- opts[:sort_batch_size] = v
27
+ options[:sort_batch_size] = v
28
28
  end
29
29
  end.parse!
30
30
 
31
+ unless ARGV.size == 2
32
+ $stderr.puts('Usage: ' + File.basename(__FILE__) + ' [options] <csv file 1> <csv file 2>')
33
+ exit 1
34
+ end
35
+
31
36
  require 'csv-utils'
32
37
 
33
38
  csv1 = CSVUtils::CSVIterator.new(ARGV[0])
34
39
  csv2 = CSVUtils::CSVIterator.new(ARGV[1])
40
+ headers = csv1.headers
35
41
 
36
- unless csv1.first.keys == csv2.first.keys
37
- $stderr.puts("headers do not match #{ARGV[0]} headers #{csv1.first.keys}, #{ARGV[1]} headers #{csv2.first.keys}")
42
+ unless headers == csv2.headers
43
+ $stderr.puts("headers do not match #{ARGV[0]} headers #{headers}, #{ARGV[1]} headers #{csv2.headers}")
38
44
  exit 1
39
45
  end
40
46
 
41
- unknown_unique_headers = options[:unique_headers] - csv1.first.keys
47
+ unknown_unique_headers = options[:unique_headers] - headers
42
48
  unless unknown_unique_headers.empty?
43
49
  $stderr.puts("specified unique headers are unknown #{unknown_unique_headers}")
44
50
  exit 1
45
51
  end
46
52
 
47
- unknown_ignore_headers = options[:ignore_headers] - csv1.first.keys
53
+ unknown_ignore_headers = options[:ignore_headers] - headers
48
54
  unless unknown_ignore_headers.empty?
49
55
  $stderr.puts("specified headers to ignore are unknown #{unknown_ignore_headers}")
50
56
  exit 1
51
57
  end
52
58
 
53
- options[:unique_headers] = [csv1.first.keys.first] if options[:unique_headers].empty?
59
+ options[:unique_headers] = [headers.first] if options[:unique_headers].empty?
54
60
 
55
61
  puts "uniqueness header(s) are #{options[:unique_headers].join(', ')}"
56
62
 
57
- unique_header_indexes = []
58
- csv1.first.keys.each_with_index do |header, idx|
59
- unique_header_indexes << idx if options[:unique_headers].include?(header)
60
- end
63
+ unique_header_indexes = options[:unique_headers].map { |header| headers.index(header) }
61
64
 
65
+ # empty cells are nil, so values are compared as strings
62
66
  sort_compare_proc = proc do |csv1_row, csv2_row|
63
- result = 0
64
- unique_header_indexes.each do |idx|
65
- result = csv1_row[idx] <=> csv2_row[idx]
66
- break unless result == 0
67
- end
68
-
69
- if result == 0
70
- csv1_row.each_with_index do |csv1_col, idx|
71
- csv2_col = csv2_row[idx]
72
- result = csv1_col <=> csv2_col
73
- break unless result == 0
74
- end
75
- end
76
-
67
+ result = unique_header_indexes.map { |idx| csv1_row[idx].to_s } <=> unique_header_indexes.map { |idx| csv2_row[idx].to_s }
68
+ result = csv1_row.map(&:to_s) <=> csv2_row.map(&:to_s) if result.zero?
77
69
  result
78
70
  end
79
71
 
@@ -88,25 +80,17 @@ puts "sorting #{ARGV[1]}"
88
80
  sorter = CSVUtils::CSVSort.new(ARGV[1], csv2_sorted_file_name)
89
81
  sorter.sort(options[:sort_batch_size], &sort_compare_proc)
90
82
 
91
- options[:unique_headers] = [csv1.first.keys.first]
92
- update_comparison_columns = csv1.first.keys - options[:unique_headers]
93
- update_comparison_columns -= options[:ignore_headers]
83
+ update_comparison_columns = headers - options[:unique_headers] - options[:ignore_headers]
94
84
 
95
85
  comparer = CSVUtils::CSVCompare.new(csv1_sorted_file_name, update_comparison_columns) do |csv1_row, csv2_row|
96
- result = 0
97
- options[:unique_headers].each do |header|
98
- result = csv1_row[header] <=> csv2_row[header]
99
- break unless result == 0
100
- end
101
-
102
- result
86
+ options[:unique_headers].map { |header| csv1_row[header].to_s } <=> options[:unique_headers].map { |header| csv2_row[header].to_s }
103
87
  end
104
88
 
105
89
  stats = Hash.new(0)
106
90
  puts "comparing #{ARGV[0]} with #{ARGV[1]}"
107
91
  diff_file_name = 'diff-results-' + File.basename(ARGV[0])
108
92
  CSV.open(diff_file_name, 'wb') do |out|
109
- out << ['Result'] + csv1.first.keys
93
+ out << ['Result'] + headers
110
94
  comparer.compare(csv2_sorted_file_name) do |action, record|
111
95
  stats[action] += 1
112
96
  out << [action] + record.values
@@ -13,31 +13,34 @@ OptionParser.new do |opts|
13
13
  exit
14
14
  end
15
15
 
16
- opts.on('-i', '--ignore HEADERS', 'Comman separated list of headers to ignore') do |v|
16
+ opts.on('-i', '--ignore HEADERS', 'Comma separated list of headers to ignore') do |v|
17
17
  options[:ignore_columns] = v.split(',')
18
18
  end
19
19
  end.parse!
20
20
 
21
21
  require 'digest/sha2'
22
- require 'json'
23
22
  require 'csv-utils'
24
23
 
25
- csv = CSVUtils::CSVIterator.new(ARGV[0])
24
+ file = ARGV[0] || raise('no CSV file specified')
26
25
 
27
- missing_headers = options[:ignore_columns] - csv.first.keys
26
+ # read bytes as is, so values that aren't valid UTF-8 can still be compared
27
+ csv = CSVUtils::CSVIterator.new(file, {}, 'rb:BINARY')
28
+
29
+ missing_headers = options[:ignore_columns] - csv.headers
28
30
  unless missing_headers.empty?
29
- raise("unkown headers #{missing_headers.join(', ')} configured ingnore headers")
31
+ raise("unknown headers #{missing_headers.join(', ')} in configured ignore headers")
30
32
  end
31
33
 
32
34
  hashed_rows = {}
33
35
 
34
- csv.each_with_index do |row, idx|
36
+ # rows are keyed by their line number in the file
37
+ csv.each do |row|
35
38
  options[:ignore_columns].each do |ignore_column|
36
39
  row[ignore_column] = ''
37
40
  end
38
- key = Digest::SHA256.hexdigest(row.to_json)
41
+ key = Digest::SHA256.hexdigest(row.values.to_csv)
39
42
  hashed_rows[key] ||= []
40
- hashed_rows[key] << idx
43
+ hashed_rows[key] << row.lineno
41
44
  end
42
45
 
43
46
  duplicate_rows = {}
@@ -52,18 +55,18 @@ hashed_rows.each do |key, row_numbers|
52
55
  end
53
56
  end
54
57
 
55
- csv.each_with_index do |row, idx|
56
- next unless (key = duplicate_rows[idx])
58
+ csv.each do |row|
59
+ next unless (key = duplicate_rows[row.lineno])
57
60
 
58
- hashed_rows[key][idx] = row
61
+ hashed_rows[key][row.lineno] = row
59
62
  end
60
63
 
61
- CSV.open('duplicates-' + File.basename(ARGV[0]), 'wb') do |out|
62
- out << ['duplicate_key', 'row_no'] + csv.first.keys
64
+ CSV.open('duplicates-' + File.basename(file), 'wb:BINARY') do |out|
65
+ out << ['duplicate_key', 'line_no'] + csv.headers
63
66
 
64
67
  hashed_rows.each do |key, rows|
65
- rows.each do |idx, row|
66
- out << [key, idx] + row.values
68
+ rows.each do |lineno, row|
69
+ out << [key, lineno] + row.values
67
70
  end
68
71
  end
69
72
  end