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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +143 -2
- data/lib/philiprehberger/csv_builder/builder.rb +136 -3
- data/lib/philiprehberger/csv_builder/column.rb +11 -3
- data/lib/philiprehberger/csv_builder/version.rb +1 -1
- data/lib/philiprehberger/csv_builder.rb +27 -3
- metadata +5 -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,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#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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: '"',
|
|
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.
|
|
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
|
+
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,
|
|
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: []
|