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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 574dc6604faf705d2e8f54f778e8e27f7595d0aedd3db13b4a54c9adb857bfbf
4
- data.tar.gz: 22e3ca9d324548d233a5bd86b5c087590ad78f1a871b9107b3f5ab6fb1cdbe57
3
+ metadata.gz: fbaf1a09bda94dd3fff6302860abacf5ad60eb1376520f145f984da9ec8fb7b1
4
+ data.tar.gz: 625b0af93c07cc624d51a2aed8239c5bebd465981bea6cdc7b9ef91433a2c645
5
5
  SHA512:
6
- metadata.gz: 91a8ce738a72a3e08276e50d66510ea0ff1c96b625207160471bf0985f2cc5e4a55cae26a2a978cf9a795eabd0d24f5e578a554255dfd294c7f56bc18963a6ab
7
- data.tar.gz: a31eee12211d6453c4b87dc2cee21a7a14a6e0fda9189685d4165370a888b9dd88fac8a0a63716a66bda8553e2bc0dad6f2d98a1c6d806b1fefc8ef23ec79583
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#to_file(path)` | Write CSV to a file |
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
- def initialize(records, delimiter: ',', quote_char: '"', bom: false, encoding: 'UTF-8')
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
- record[@name] || record[@name.to_s]
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.to_s
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module CsvBuilder
5
- VERSION = '0.6.0'
5
+ VERSION = '0.7.0'
6
6
  end
7
7
  end
@@ -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: '"', bom: false, encoding: 'UTF-8', &block)
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.6.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, TSV/PSV
16
- shorthands, row validation, header transforms, and total rows.
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: []