philiprehberger-csv_builder 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +62 -2
- data/lib/philiprehberger/csv_builder/builder.rb +70 -5
- data/lib/philiprehberger/csv_builder/column.rb +11 -3
- data/lib/philiprehberger/csv_builder/version.rb +1 -1
- data/lib/philiprehberger/csv_builder.rb +6 -3
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fbaf1a09bda94dd3fff6302860abacf5ad60eb1376520f145f984da9ec8fb7b1
|
|
4
|
+
data.tar.gz: 625b0af93c07cc624d51a2aed8239c5bebd465981bea6cdc7b9ef91433a2c645
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 562365a1e93a6d9386a31630011021186ecf45029f6764e05fa594ccd872dcbe0a932477897c469dade5a95a2ca02d0d9c7f2a656b48210ff5d4c71c6d7ff9cf
|
|
7
|
+
data.tar.gz: 7805ee034ea1c75bf1e8ccbea4854d1bbaab80ddbc0def7b7ba30b0be301280bfec22d8b9450e34da4e43fb9943b627c0a11a1397cd9fe5c1b04ded3a441f843
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.0] - 2026-04-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `Builder#write_to(path, mode:)` for writing CSV with an explicit file mode
|
|
14
|
+
- `Builder#append_to(path)` shorthand that appends data rows (no header, no BOM) to an existing file
|
|
15
|
+
- `row_sep:` option on `CsvBuilder.build` for custom line separators (e.g. `"\r\n"`)
|
|
16
|
+
- `empty_value:` option on `CsvBuilder.build` to replace `nil` / empty values with a placeholder
|
|
17
|
+
- `Builder#to_s` alias for `to_csv` (enables string interpolation)
|
|
18
|
+
- `Builder#to_a` returns the CSV as `[headers, *rows, footer?]`
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- `Column#extract` no longer silently drops `false` and `0` values when looking up symbol keys in a hash record (hash lookup now uses `Hash#key?` instead of `||`)
|
|
22
|
+
|
|
10
23
|
## [0.6.0] - 2026-04-14
|
|
11
24
|
|
|
12
25
|
### Added
|
|
@@ -84,6 +97,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
84
97
|
- Support for hash records with symbol and string keys
|
|
85
98
|
- Proper CSV escaping for values with commas and quotes
|
|
86
99
|
|
|
100
|
+
[0.7.0]: https://github.com/philiprehberger/rb-csv-builder/releases/tag/v0.7.0
|
|
87
101
|
[0.6.0]: https://github.com/philiprehberger/rb-csv-builder/releases/tag/v0.6.0
|
|
88
102
|
[0.5.0]: https://github.com/philiprehberger/rb-csv-builder/releases/tag/v0.5.0
|
|
89
103
|
[0.4.0]: https://github.com/philiprehberger/rb-csv-builder/releases/tag/v0.4.0
|
data/README.md
CHANGED
|
@@ -314,6 +314,62 @@ io = StringIO.new
|
|
|
314
314
|
builder.to_io(io)
|
|
315
315
|
```
|
|
316
316
|
|
|
317
|
+
### Writing and Appending
|
|
318
|
+
|
|
319
|
+
`write_to` accepts an explicit open mode so you can overwrite or append:
|
|
320
|
+
|
|
321
|
+
```ruby
|
|
322
|
+
# Overwrite (default behaviour, same as to_file)
|
|
323
|
+
builder.write_to('output.csv')
|
|
324
|
+
|
|
325
|
+
# Append data rows only (no header, no BOM) to an existing file
|
|
326
|
+
builder.append_to('output.csv')
|
|
327
|
+
|
|
328
|
+
# Or explicitly
|
|
329
|
+
builder.write_to('output.csv', mode: 'ab')
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
`append_to` is handy for combining multiple builders into one file while keeping a single header row.
|
|
333
|
+
|
|
334
|
+
### Custom Line Separator
|
|
335
|
+
|
|
336
|
+
Use `row_sep:` to switch between Unix (`\n`, default), Windows (`\r\n`) or any other line ending:
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
builder = Philiprehberger::CsvBuilder.build(records, row_sep: "\r\n") do
|
|
340
|
+
column :name
|
|
341
|
+
column :email
|
|
342
|
+
end
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Custom Empty Value
|
|
346
|
+
|
|
347
|
+
Replace `nil` and empty string values with a placeholder:
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
records = [{ name: 'Alice', email: nil }]
|
|
351
|
+
|
|
352
|
+
builder = Philiprehberger::CsvBuilder.build(records, empty_value: 'N/A') do
|
|
353
|
+
column :name
|
|
354
|
+
column :email
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
puts builder.to_csv
|
|
358
|
+
# name,email
|
|
359
|
+
# Alice,N/A
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### String and Array Conversion
|
|
363
|
+
|
|
364
|
+
```ruby
|
|
365
|
+
# to_s is an alias for to_csv (handy for string interpolation)
|
|
366
|
+
puts "CSV:\n#{builder}"
|
|
367
|
+
|
|
368
|
+
# to_a returns [headers, *rows, footer?] for programmatic use
|
|
369
|
+
builder.to_a
|
|
370
|
+
# => [["name", "email"], ["Alice", "alice@example.com"], ["Bob", "bob@example.com"]]
|
|
371
|
+
```
|
|
372
|
+
|
|
317
373
|
### Headers
|
|
318
374
|
|
|
319
375
|
```ruby
|
|
@@ -329,7 +385,7 @@ builder.headers # => ["name", "email"]
|
|
|
329
385
|
|
|
330
386
|
| Method | Description |
|
|
331
387
|
|--------|-------------|
|
|
332
|
-
| `CsvBuilder.build(records, delimiter:, quote_char:, bom:, encoding:, &block)` | Build a CSV using the column DSL |
|
|
388
|
+
| `CsvBuilder.build(records, delimiter:, quote_char:, row_sep:, bom:, encoding:, empty_value:, &block)` | Build a CSV using the column DSL |
|
|
333
389
|
| `CsvBuilder.tsv(records, **options, &block)` | Shorthand for tab-separated output |
|
|
334
390
|
| `CsvBuilder.psv(records, **options, &block)` | Shorthand for pipe-separated output |
|
|
335
391
|
| `Builder#column(name, header:, &block)` | Define a column with optional alias and transform |
|
|
@@ -343,7 +399,11 @@ builder.headers # => ["name", "email"]
|
|
|
343
399
|
| `Builder#offset(n)` | Skip first N filtered/sorted records |
|
|
344
400
|
| `Builder#row_number(header:)` | Add auto-incrementing row number column |
|
|
345
401
|
| `Builder#to_csv` | Generate CSV as a string |
|
|
346
|
-
| `Builder#
|
|
402
|
+
| `Builder#to_s` | Alias for `to_csv` (enables string interpolation) |
|
|
403
|
+
| `Builder#to_a` | Return CSV as an array of row arrays (headers + data + footer) |
|
|
404
|
+
| `Builder#to_file(path)` | Write CSV to a file (overwrites) |
|
|
405
|
+
| `Builder#write_to(path, mode:)` | Write CSV with an explicit file mode (`'wb'` default, `'ab'` to append body only) |
|
|
406
|
+
| `Builder#append_to(path)` | Append data rows (no header, no BOM) to an existing CSV file |
|
|
347
407
|
| `Builder#to_io(io)` | Stream CSV to any IO object |
|
|
348
408
|
| `Builder#headers` | Return column header names |
|
|
349
409
|
|
|
@@ -16,9 +16,12 @@ module Philiprehberger
|
|
|
16
16
|
# @param records [Array] the source records
|
|
17
17
|
# @param delimiter [String] the column separator (default: ",")
|
|
18
18
|
# @param quote_char [String] the quote character (default: '"')
|
|
19
|
+
# @param row_sep [String] the line separator (default: "\n")
|
|
19
20
|
# @param bom [Boolean] prepend UTF-8 BOM (default: false)
|
|
20
21
|
# @param encoding [String] output encoding name (default: "UTF-8")
|
|
21
|
-
|
|
22
|
+
# @param empty_value [String] placeholder for nil/empty values (default: "")
|
|
23
|
+
def initialize(records, delimiter: ',', quote_char: '"', row_sep: "\n",
|
|
24
|
+
bom: false, encoding: 'UTF-8', empty_value: '')
|
|
22
25
|
@records = records
|
|
23
26
|
@columns = []
|
|
24
27
|
@filters = []
|
|
@@ -26,6 +29,7 @@ module Philiprehberger
|
|
|
26
29
|
@row_number_header = nil
|
|
27
30
|
@delimiter = delimiter
|
|
28
31
|
@quote_char = quote_char
|
|
32
|
+
@row_sep = row_sep
|
|
29
33
|
@sort_by = nil
|
|
30
34
|
@sort_direction = :asc
|
|
31
35
|
@limit_count = nil
|
|
@@ -34,6 +38,7 @@ module Philiprehberger
|
|
|
34
38
|
@header_transform = nil
|
|
35
39
|
@bom = bom
|
|
36
40
|
@encoding = encoding
|
|
41
|
+
@empty_value = empty_value
|
|
37
42
|
end
|
|
38
43
|
|
|
39
44
|
# Sort records before CSV output
|
|
@@ -111,7 +116,7 @@ module Philiprehberger
|
|
|
111
116
|
@footer_block = lambda do |recs|
|
|
112
117
|
columns.map do |col|
|
|
113
118
|
if col.name == col_name
|
|
114
|
-
values = recs.map { |r| col.extract(r).to_f }
|
|
119
|
+
values = recs.map { |r| col.extract(r, empty_value: @empty_value).to_f }
|
|
115
120
|
block ? block.call(values) : values.sum
|
|
116
121
|
else
|
|
117
122
|
''
|
|
@@ -196,6 +201,13 @@ module Philiprehberger
|
|
|
196
201
|
@bom ? "\xEF\xBB\xBF#{csv_string}" : csv_string
|
|
197
202
|
end
|
|
198
203
|
|
|
204
|
+
# Alias for {#to_csv} so instances behave nicely with string interpolation.
|
|
205
|
+
#
|
|
206
|
+
# @return [String]
|
|
207
|
+
def to_s
|
|
208
|
+
to_csv
|
|
209
|
+
end
|
|
210
|
+
|
|
199
211
|
# Write the CSV to a file
|
|
200
212
|
#
|
|
201
213
|
# @param path [String] the output file path
|
|
@@ -204,6 +216,32 @@ module Philiprehberger
|
|
|
204
216
|
File.binwrite(path, to_csv)
|
|
205
217
|
end
|
|
206
218
|
|
|
219
|
+
# Write the CSV to a file with an explicit mode. Useful for appending
|
|
220
|
+
# to existing files or combining multiple builders into one file.
|
|
221
|
+
#
|
|
222
|
+
# When appending (`mode: 'ab'` / `'a'`), the header row and BOM from
|
|
223
|
+
# subsequent writes are suppressed so the file keeps a single header.
|
|
224
|
+
#
|
|
225
|
+
# @param path [String] the output file path
|
|
226
|
+
# @param mode [String] file open mode (default: "wb")
|
|
227
|
+
# @return [void]
|
|
228
|
+
def write_to(path, mode: 'wb')
|
|
229
|
+
appending = mode.start_with?('a')
|
|
230
|
+
if appending
|
|
231
|
+
File.open(path, mode) { |f| write_body_rows(f) }
|
|
232
|
+
else
|
|
233
|
+
to_file(path)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Append data rows (no header, no BOM) to an existing CSV file.
|
|
238
|
+
#
|
|
239
|
+
# @param path [String] the output file path
|
|
240
|
+
# @return [void]
|
|
241
|
+
def append_to(path)
|
|
242
|
+
write_to(path, mode: 'ab')
|
|
243
|
+
end
|
|
244
|
+
|
|
207
245
|
# Stream CSV to any IO object
|
|
208
246
|
#
|
|
209
247
|
# @param io [IO, StringIO] the IO object to write to
|
|
@@ -221,11 +259,38 @@ module Philiprehberger
|
|
|
221
259
|
csv << @footer_block.call(recs) if @footer_block
|
|
222
260
|
end
|
|
223
261
|
|
|
262
|
+
# Return the CSV as an array of row arrays (headers + data + footer).
|
|
263
|
+
#
|
|
264
|
+
# @return [Array<Array>]
|
|
265
|
+
# @raise [ValidationError] if any row fails validation
|
|
266
|
+
def to_a
|
|
267
|
+
recs = filtered_records
|
|
268
|
+
validate_rows!(recs) unless @validations.empty?
|
|
269
|
+
rows = [headers]
|
|
270
|
+
recs.each_with_index { |record, index| rows << build_row(record, index) }
|
|
271
|
+
rows << @footer_block.call(recs) if @footer_block
|
|
272
|
+
rows
|
|
273
|
+
end
|
|
274
|
+
|
|
224
275
|
private
|
|
225
276
|
|
|
226
277
|
# @return [Hash] CSV library options
|
|
227
278
|
def csv_options
|
|
228
|
-
{ col_sep: @delimiter, quote_char: @quote_char }
|
|
279
|
+
{ col_sep: @delimiter, quote_char: @quote_char, row_sep: @row_sep }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Write only the data rows (and footer) to the given IO. Used by
|
|
283
|
+
# {#write_to} / {#append_to} so subsequent appends don't duplicate
|
|
284
|
+
# the header row.
|
|
285
|
+
#
|
|
286
|
+
# @param io [IO] the IO object to write to
|
|
287
|
+
# @return [void]
|
|
288
|
+
def write_body_rows(io)
|
|
289
|
+
recs = filtered_records
|
|
290
|
+
validate_rows!(recs) unless @validations.empty?
|
|
291
|
+
csv = CSV.new(io, **csv_options)
|
|
292
|
+
recs.each_with_index { |record, index| csv << build_row(record, index) }
|
|
293
|
+
csv << @footer_block.call(recs) if @footer_block
|
|
229
294
|
end
|
|
230
295
|
|
|
231
296
|
# Validate all rows against registered validation blocks
|
|
@@ -236,7 +301,7 @@ module Philiprehberger
|
|
|
236
301
|
def validate_rows!(recs)
|
|
237
302
|
recs.each_with_index do |record, index|
|
|
238
303
|
row_hash = @columns.to_h do |col|
|
|
239
|
-
[col.name, col.extract(record)]
|
|
304
|
+
[col.name, col.extract(record, empty_value: @empty_value)]
|
|
240
305
|
end
|
|
241
306
|
@validations.each do |v|
|
|
242
307
|
result = v.call(row_hash)
|
|
@@ -255,7 +320,7 @@ module Philiprehberger
|
|
|
255
320
|
# @param index [Integer] zero-based row index
|
|
256
321
|
# @return [Array]
|
|
257
322
|
def build_row(record, index)
|
|
258
|
-
row = @columns.map { |col| col.extract(record) }
|
|
323
|
+
row = @columns.map { |col| col.extract(record, empty_value: @empty_value) }
|
|
259
324
|
@row_number_header ? [index + 1] + row : row
|
|
260
325
|
end
|
|
261
326
|
end
|
|
@@ -22,17 +22,25 @@ module Philiprehberger
|
|
|
22
22
|
# Extract the value for this column from a record
|
|
23
23
|
#
|
|
24
24
|
# @param record [Hash, Object] the source record
|
|
25
|
+
# @param empty_value [String] placeholder for nil / missing values
|
|
25
26
|
# @return [String] the extracted and converted value
|
|
26
|
-
def extract(record)
|
|
27
|
+
def extract(record, empty_value: '')
|
|
27
28
|
value = if @transform
|
|
28
29
|
@transform.call(record)
|
|
29
30
|
elsif record.is_a?(Hash)
|
|
30
|
-
|
|
31
|
+
if record.key?(@name)
|
|
32
|
+
record[@name]
|
|
33
|
+
else
|
|
34
|
+
record[@name.to_s]
|
|
35
|
+
end
|
|
31
36
|
elsif record.respond_to?(@name)
|
|
32
37
|
record.send(@name)
|
|
33
38
|
end
|
|
34
39
|
|
|
35
|
-
value.
|
|
40
|
+
return empty_value if value.nil?
|
|
41
|
+
|
|
42
|
+
str = value.to_s
|
|
43
|
+
str.empty? ? empty_value : str
|
|
36
44
|
end
|
|
37
45
|
|
|
38
46
|
# Return the header label for this column
|
|
@@ -14,19 +14,22 @@ module Philiprehberger
|
|
|
14
14
|
# @param records [Array] the source records
|
|
15
15
|
# @param delimiter [String] the column separator (default: ",")
|
|
16
16
|
# @param quote_char [String] the quote character (default: '"')
|
|
17
|
+
# @param row_sep [String] the line separator (default: "\n")
|
|
17
18
|
# @param bom [Boolean] prepend UTF-8 BOM for Excel compatibility (default: false)
|
|
18
19
|
# @param encoding [String] output encoding name (default: "UTF-8")
|
|
20
|
+
# @param empty_value [String] string used to represent nil/empty values (default: "")
|
|
19
21
|
# @yield [builder] the builder instance for defining columns
|
|
20
22
|
# @yieldparam builder [Builder]
|
|
21
23
|
# @return [Builder] the configured builder
|
|
22
24
|
# @raise [Error] if no block is given
|
|
23
|
-
def self.build(records, delimiter: ',', quote_char: '"',
|
|
25
|
+
def self.build(records, delimiter: ',', quote_char: '"', row_sep: "\n",
|
|
26
|
+
bom: false, encoding: 'UTF-8', empty_value: '', &block)
|
|
24
27
|
raise Error, 'A block is required' unless block
|
|
25
28
|
|
|
26
29
|
builder = Builder.new(
|
|
27
30
|
records,
|
|
28
|
-
delimiter: delimiter, quote_char: quote_char,
|
|
29
|
-
bom: bom, encoding: encoding
|
|
31
|
+
delimiter: delimiter, quote_char: quote_char, row_sep: row_sep,
|
|
32
|
+
bom: bom, encoding: encoding, empty_value: empty_value
|
|
30
33
|
)
|
|
31
34
|
builder.instance_eval(&block)
|
|
32
35
|
builder
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: philiprehberger-csv_builder
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Philip Rehberger
|
|
@@ -12,8 +12,9 @@ date: 2026-04-15 00:00:00.000000000 Z
|
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Build CSV files from record collections using a declarative DSL with
|
|
14
14
|
column definitions, custom transforms, filtering, sorting, pagination via limit/offset,
|
|
15
|
-
computed footer rows, row numbers, streaming output, custom delimiters
|
|
16
|
-
shorthands, row validation, header transforms,
|
|
15
|
+
computed footer rows, row numbers, streaming output, custom delimiters and line
|
|
16
|
+
separators, TSV/PSV shorthands, row validation, header transforms, total rows, append-to-file
|
|
17
|
+
support, and custom empty-value placeholders.
|
|
17
18
|
email:
|
|
18
19
|
- me@philiprehberger.com
|
|
19
20
|
executables: []
|