philiprehberger-csv_builder 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 921263332f87142c3cfcdc4483e601a9ab51ce54cd53481988cc75b3b46eb7ed
4
- data.tar.gz: fa347e0127c702eca2a1c4104ca1b5eada5c43458660884c9b6f9028753a8f80
3
+ metadata.gz: fbaf1a09bda94dd3fff6302860abacf5ad60eb1376520f145f984da9ec8fb7b1
4
+ data.tar.gz: 625b0af93c07cc624d51a2aed8239c5bebd465981bea6cdc7b9ef91433a2c645
5
5
  SHA512:
6
- metadata.gz: a9fcc0e96415c5b1d5b55e257963c48881ec879809b652ae7604138d524df50264577650e60bb239cf6b838b5fbae2bd03e6b76d5d6cdb0b3225528dd6a5d9a6
7
- data.tar.gz: c7a65b052c99facd38baee4f16f2cca0bbd36ae5906ce6ca1302da36797c6b1b585066e096b1aec8432dacbd8d1167ab23f9cb522123e4253c3088b8643c14b4
6
+ metadata.gz: 562365a1e93a6d9386a31630011021186ecf45029f6764e05fa594ccd872dcbe0a932477897c469dade5a95a2ca02d0d9c7f2a656b48210ff5d4c71c6d7ff9cf
7
+ data.tar.gz: 7805ee034ea1c75bf1e8ccbea4854d1bbaab80ddbc0def7b7ba30b0be301280bfec22d8b9450e34da4e43fb9943b627c0a11a1397cd9fe5c1b04ded3a441f843
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ 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
+
23
+ ## [0.6.0] - 2026-04-14
24
+
25
+ ### Added
26
+ - `CsvBuilder.tsv` shorthand for tab-separated output
27
+ - `CsvBuilder.psv` shorthand for pipe-separated output
28
+ - `Builder#validate` registers a validation block; raises `CsvBuilder::ValidationError` on failure
29
+ - `Builder#transform_header` registers a proc applied to all column headers during rendering
30
+ - `Builder#total(column, &block)` shorthand for adding a footer row with computed total
31
+
10
32
  ## [0.5.0] - 2026-04-11
11
33
 
12
34
  ### Added
@@ -75,6 +97,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
75
97
  - Support for hash records with symbol and string keys
76
98
  - Proper CSV escaping for values with commas and quotes
77
99
 
100
+ [0.7.0]: https://github.com/philiprehberger/rb-csv-builder/releases/tag/v0.7.0
101
+ [0.6.0]: https://github.com/philiprehberger/rb-csv-builder/releases/tag/v0.6.0
78
102
  [0.5.0]: https://github.com/philiprehberger/rb-csv-builder/releases/tag/v0.5.0
79
103
  [0.4.0]: https://github.com/philiprehberger/rb-csv-builder/releases/tag/v0.4.0
80
104
  [0.3.0]: https://github.com/philiprehberger/rb-csv-builder/releases/tag/v0.3.0
data/README.md CHANGED
@@ -94,6 +94,82 @@ builder = Philiprehberger::CsvBuilder.build(records, quote_char: "'") do
94
94
  end
95
95
  ```
96
96
 
97
+ ### TSV and PSV Output
98
+
99
+ Use the `tsv` and `psv` shorthands instead of passing `delimiter:` manually:
100
+
101
+ ```ruby
102
+ # Tab-separated
103
+ builder = Philiprehberger::CsvBuilder.tsv(records) do
104
+ column :name
105
+ column :email
106
+ end
107
+
108
+ puts builder.to_csv
109
+ # name email
110
+ # Alice alice@example.com
111
+
112
+ # Pipe-separated
113
+ builder = Philiprehberger::CsvBuilder.psv(records) do
114
+ column :name
115
+ column :email
116
+ end
117
+
118
+ puts builder.to_csv
119
+ # name|email
120
+ # Alice|alice@example.com
121
+ ```
122
+
123
+ Both accept the same options as `build` (e.g. `bom:`, `encoding:`).
124
+
125
+ ### Row Validation
126
+
127
+ Register one or more validation blocks. Rows are checked before `to_csv`, `to_file`, or `to_io`. If any block returns falsy or raises, a `CsvBuilder::ValidationError` is raised:
128
+
129
+ ```ruby
130
+ builder = Philiprehberger::CsvBuilder.build(records) do
131
+ column :name
132
+ column :email
133
+ validate { |row| row[:email].include?('@') }
134
+ end
135
+
136
+ builder.to_csv # raises ValidationError if any email is missing '@'
137
+ ```
138
+
139
+ ### Header Transforms
140
+
141
+ Apply a transformation to all column headers during rendering:
142
+
143
+ ```ruby
144
+ builder = Philiprehberger::CsvBuilder.build(records) do
145
+ column :first_name
146
+ column :last_name
147
+ transform_header { |h| h.upcase }
148
+ end
149
+
150
+ builder.headers # => ["FIRST_NAME", "LAST_NAME"]
151
+ ```
152
+
153
+ ### Total Rows
154
+
155
+ Add a footer row with a computed total for a named column:
156
+
157
+ ```ruby
158
+ builder = Philiprehberger::CsvBuilder.build(records) do
159
+ column :name
160
+ column :amount
161
+ total :amount
162
+ end
163
+
164
+ # Outputs a footer row: ,60.0
165
+ ```
166
+
167
+ Pass a block for custom aggregation:
168
+
169
+ ```ruby
170
+ total(:amount) { |values| values.max }
171
+ ```
172
+
97
173
  ### Column Aliases
98
174
 
99
175
  ```ruby
@@ -238,6 +314,62 @@ io = StringIO.new
238
314
  builder.to_io(io)
239
315
  ```
240
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
+
241
373
  ### Headers
242
374
 
243
375
  ```ruby
@@ -253,16 +385,25 @@ builder.headers # => ["name", "email"]
253
385
 
254
386
  | Method | Description |
255
387
  |--------|-------------|
256
- | `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 |
389
+ | `CsvBuilder.tsv(records, **options, &block)` | Shorthand for tab-separated output |
390
+ | `CsvBuilder.psv(records, **options, &block)` | Shorthand for pipe-separated output |
257
391
  | `Builder#column(name, header:, &block)` | Define a column with optional alias and transform |
258
392
  | `Builder#filter(&block)` | Filter records (block returns true to include) |
259
393
  | `Builder#sort_by(direction:, &block)` | Sort records by block key (`:asc` or `:desc`) |
394
+ | `Builder#validate(&block)` | Register a row validation block; raises `ValidationError` on failure |
395
+ | `Builder#transform_header(&block)` | Register a proc applied to all column headers |
396
+ | `Builder#total(column, &block)` | Add a footer row with computed total for the named column |
260
397
  | `Builder#footer(&block)` | Append a computed footer row (block receives filtered records) |
261
398
  | `Builder#limit(n)` | Cap output to N rows |
262
399
  | `Builder#offset(n)` | Skip first N filtered/sorted records |
263
400
  | `Builder#row_number(header:)` | Add auto-incrementing row number column |
264
401
  | `Builder#to_csv` | Generate CSV as a string |
265
- | `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 |
266
407
  | `Builder#to_io(io)` | Stream CSV to any IO object |
267
408
  | `Builder#headers` | Return column header names |
268
409
 
@@ -16,22 +16,29 @@ 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 = []
28
+ @validations = []
25
29
  @row_number_header = nil
26
30
  @delimiter = delimiter
27
31
  @quote_char = quote_char
32
+ @row_sep = row_sep
28
33
  @sort_by = nil
29
34
  @sort_direction = :asc
30
35
  @limit_count = nil
31
36
  @offset_count = nil
32
37
  @footer_block = nil
38
+ @header_transform = nil
33
39
  @bom = bom
34
40
  @encoding = encoding
41
+ @empty_value = empty_value
35
42
  end
36
43
 
37
44
  # Sort records before CSV output
@@ -79,6 +86,46 @@ module Philiprehberger
79
86
  self
80
87
  end
81
88
 
89
+ # Register a validation block for rows
90
+ #
91
+ # @yield [row] block that validates the row hash
92
+ # @yieldparam row [Hash] column-name to value mapping
93
+ # @return [self]
94
+ def validate(&block)
95
+ @validations << block
96
+ self
97
+ end
98
+
99
+ # Register a proc applied to all column headers during rendering
100
+ #
101
+ # @yield [name] block that transforms a header name
102
+ # @yieldparam name [String] the original header label
103
+ # @return [self]
104
+ def transform_header(&block)
105
+ @header_transform = block
106
+ self
107
+ end
108
+
109
+ # Shorthand for adding a footer row with a computed total for the named column
110
+ #
111
+ # @param column_name [Symbol, String] the column to total
112
+ # @yield [values] optional block to compute the total (receives array of numeric values)
113
+ # @return [self]
114
+ def total(column_name, &block)
115
+ col_name = column_name.to_sym
116
+ @footer_block = lambda do |recs|
117
+ columns.map do |col|
118
+ if col.name == col_name
119
+ values = recs.map { |r| col.extract(r, empty_value: @empty_value).to_f }
120
+ block ? block.call(values) : values.sum
121
+ else
122
+ ''
123
+ end
124
+ end
125
+ end
126
+ self
127
+ end
128
+
82
129
  # Define a column
83
130
  #
84
131
  # @param name [Symbol, String] the column name
@@ -115,6 +162,7 @@ module Philiprehberger
115
162
  # @return [Array<String>]
116
163
  def headers
117
164
  base = @columns.map(&:header)
165
+ base = base.map { |h| @header_transform.call(h) } if @header_transform
118
166
  @row_number_header ? [@row_number_header] + base : base
119
167
  end
120
168
 
@@ -138,8 +186,10 @@ module Philiprehberger
138
186
  # Generate the CSV as a string
139
187
  #
140
188
  # @return [String]
189
+ # @raise [ValidationError] if any row fails validation
141
190
  def to_csv
142
191
  recs = filtered_records
192
+ validate_rows!(recs) unless @validations.empty?
143
193
  csv_string = CSV.generate(**csv_options) do |csv|
144
194
  csv << headers
145
195
  recs.each_with_index do |record, index|
@@ -151,6 +201,13 @@ module Philiprehberger
151
201
  @bom ? "\xEF\xBB\xBF#{csv_string}" : csv_string
152
202
  end
153
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
+
154
211
  # Write the CSV to a file
155
212
  #
156
213
  # @param path [String] the output file path
@@ -159,13 +216,41 @@ module Philiprehberger
159
216
  File.binwrite(path, to_csv)
160
217
  end
161
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
+
162
245
  # Stream CSV to any IO object
163
246
  #
164
247
  # @param io [IO, StringIO] the IO object to write to
165
248
  # @return [void]
249
+ # @raise [ValidationError] if any row fails validation
166
250
  def to_io(io)
167
251
  io.write("\xEF\xBB\xBF") if @bom
168
252
  recs = filtered_records
253
+ validate_rows!(recs) unless @validations.empty?
169
254
  csv = CSV.new(io, **csv_options)
170
255
  csv << headers
171
256
  recs.each_with_index do |record, index|
@@ -174,11 +259,59 @@ module Philiprehberger
174
259
  csv << @footer_block.call(recs) if @footer_block
175
260
  end
176
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
+
177
275
  private
178
276
 
179
277
  # @return [Hash] CSV library options
180
278
  def csv_options
181
- { 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
294
+ end
295
+
296
+ # Validate all rows against registered validation blocks
297
+ #
298
+ # @param recs [Array] the filtered records
299
+ # @return [void]
300
+ # @raise [ValidationError] if any row fails validation
301
+ def validate_rows!(recs)
302
+ recs.each_with_index do |record, index|
303
+ row_hash = @columns.to_h do |col|
304
+ [col.name, col.extract(record, empty_value: @empty_value)]
305
+ end
306
+ @validations.each do |v|
307
+ result = v.call(row_hash)
308
+ raise ValidationError, "Row #{index + 1} failed validation" unless result
309
+ rescue ValidationError
310
+ raise
311
+ rescue StandardError => e
312
+ raise ValidationError, "Row #{index + 1} failed validation: #{e.message}"
313
+ end
314
+ end
182
315
  end
183
316
 
184
317
  # Build a single row array for the given record
@@ -187,7 +320,7 @@ module Philiprehberger
187
320
  # @param index [Integer] zero-based row index
188
321
  # @return [Array]
189
322
  def build_row(record, index)
190
- row = @columns.map { |col| col.extract(record) }
323
+ row = @columns.map { |col| col.extract(record, empty_value: @empty_value) }
191
324
  @row_number_header ? [index + 1] + row : row
192
325
  end
193
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.5.0'
5
+ VERSION = '0.7.0'
6
6
  end
7
7
  end
@@ -7,28 +7,52 @@ require_relative 'csv_builder/builder'
7
7
  module Philiprehberger
8
8
  module CsvBuilder
9
9
  class Error < StandardError; end
10
+ class ValidationError < Error; end
10
11
 
11
12
  # Build a CSV from records using a declarative DSL
12
13
  #
13
14
  # @param records [Array] the source records
14
15
  # @param delimiter [String] the column separator (default: ",")
15
16
  # @param quote_char [String] the quote character (default: '"')
17
+ # @param row_sep [String] the line separator (default: "\n")
16
18
  # @param bom [Boolean] prepend UTF-8 BOM for Excel compatibility (default: false)
17
19
  # @param encoding [String] output encoding name (default: "UTF-8")
20
+ # @param empty_value [String] string used to represent nil/empty values (default: "")
18
21
  # @yield [builder] the builder instance for defining columns
19
22
  # @yieldparam builder [Builder]
20
23
  # @return [Builder] the configured builder
21
24
  # @raise [Error] if no block is given
22
- 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)
23
27
  raise Error, 'A block is required' unless block
24
28
 
25
29
  builder = Builder.new(
26
30
  records,
27
- delimiter: delimiter, quote_char: quote_char,
28
- bom: bom, encoding: encoding
31
+ delimiter: delimiter, quote_char: quote_char, row_sep: row_sep,
32
+ bom: bom, encoding: encoding, empty_value: empty_value
29
33
  )
30
34
  builder.instance_eval(&block)
31
35
  builder
32
36
  end
37
+
38
+ # Shorthand for tab-separated output
39
+ #
40
+ # @param records [Array] the source records
41
+ # @param options [Hash] additional options passed to .build
42
+ # @yield [builder] the builder instance for defining columns
43
+ # @return [Builder] the configured builder
44
+ def self.tsv(records, **options, &)
45
+ build(records, **options, delimiter: "\t", &)
46
+ end
47
+
48
+ # Shorthand for pipe-separated output
49
+ #
50
+ # @param records [Array] the source records
51
+ # @param options [Hash] additional options passed to .build
52
+ # @yield [builder] the builder instance for defining columns
53
+ # @return [Builder] the configured builder
54
+ def self.psv(records, **options, &)
55
+ build(records, **options, delimiter: '|', &)
56
+ end
33
57
  end
34
58
  end
metadata CHANGED
@@ -1,18 +1,20 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-csv_builder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-11 00:00:00.000000000 Z
11
+ 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, and custom delimiters.
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.
16
18
  email:
17
19
  - me@philiprehberger.com
18
20
  executables: []