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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 423eacb18512c595abe83765ccfd4d945f1e2e0d18a6370b6841997eb428ddb7
|
|
4
|
+
data.tar.gz: 3d2df7f1ee62eaebc25d00281401bf49f0fccdb809ec03cbac90e6bf5ba2fd62
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
+
[](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml)
|
|
6
|
+
[](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml)
|
|
7
|
+
[](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
|
|
9
|
-
- **CSV Sorting**: Sort CSV files
|
|
10
|
-
- **CSV Reporting**: Generate reports from
|
|
11
|
-
- **CSV
|
|
12
|
-
- **CSV
|
|
13
|
-
- **CSV
|
|
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 '
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 `
|
|
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/
|
|
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
|
|
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
|
-
|
|
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', '
|
|
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', '
|
|
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
|
-
|
|
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
|
|
37
|
-
$stderr.puts("headers do not match #{ARGV[0]} headers #{
|
|
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] -
|
|
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] -
|
|
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] = [
|
|
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 =
|
|
64
|
-
|
|
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]
|
|
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
|
-
|
|
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'] +
|
|
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
|
data/bin/csv-duplicate-finder
CHANGED
|
@@ -13,31 +13,34 @@ OptionParser.new do |opts|
|
|
|
13
13
|
exit
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
opts.on('-i', '--ignore HEADERS', '
|
|
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
|
-
|
|
24
|
+
file = ARGV[0] || raise('no CSV file specified')
|
|
26
25
|
|
|
27
|
-
|
|
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("
|
|
31
|
+
raise("unknown headers #{missing_headers.join(', ')} in configured ignore headers")
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
hashed_rows = {}
|
|
33
35
|
|
|
34
|
-
|
|
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.
|
|
41
|
+
key = Digest::SHA256.hexdigest(row.values.to_csv)
|
|
39
42
|
hashed_rows[key] ||= []
|
|
40
|
-
hashed_rows[key] <<
|
|
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.
|
|
56
|
-
next unless (key = duplicate_rows[
|
|
58
|
+
csv.each do |row|
|
|
59
|
+
next unless (key = duplicate_rows[row.lineno])
|
|
57
60
|
|
|
58
|
-
hashed_rows[key][
|
|
61
|
+
hashed_rows[key][row.lineno] = row
|
|
59
62
|
end
|
|
60
63
|
|
|
61
|
-
CSV.open('duplicates-' + File.basename(
|
|
62
|
-
out << ['duplicate_key', '
|
|
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 |
|
|
66
|
-
out << [key,
|
|
68
|
+
rows.each do |lineno, row|
|
|
69
|
+
out << [key, lineno] + row.values
|
|
67
70
|
end
|
|
68
71
|
end
|
|
69
72
|
end
|