philiprehberger-csv_builder 0.4.0 → 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 +21 -0
- data/README.md +103 -1
- data/lib/philiprehberger/csv_builder/builder.rb +80 -4
- data/lib/philiprehberger/csv_builder/version.rb +1 -1
- data/lib/philiprehberger/csv_builder.rb +29 -2
- 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: 574dc6604faf705d2e8f54f778e8e27f7595d0aedd3db13b4a54c9adb857bfbf
|
|
4
|
+
data.tar.gz: 22e3ca9d324548d233a5bd86b5c087590ad78f1a871b9107b3f5ab6fb1cdbe57
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 91a8ce738a72a3e08276e50d66510ea0ff1c96b625207160471bf0985f2cc5e4a55cae26a2a978cf9a795eabd0d24f5e578a554255dfd294c7f56bc18963a6ab
|
|
7
|
+
data.tar.gz: a31eee12211d6453c4b87dc2cee21a7a14a6e0fda9189685d4165370a888b9dd88fac8a0a63716a66bda8553e2bc0dad6f2d98a1c6d806b1fefc8ef23ec79583
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-04-14
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `CsvBuilder.tsv` shorthand for tab-separated output
|
|
14
|
+
- `CsvBuilder.psv` shorthand for pipe-separated output
|
|
15
|
+
- `Builder#validate` registers a validation block; raises `CsvBuilder::ValidationError` on failure
|
|
16
|
+
- `Builder#transform_header` registers a proc applied to all column headers during rendering
|
|
17
|
+
- `Builder#total(column, &block)` shorthand for adding a footer row with computed total
|
|
18
|
+
|
|
19
|
+
## [0.5.0] - 2026-04-11
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- UTF-8 BOM support via `bom: true` option for Excel-compatible output
|
|
23
|
+
- Custom output encoding via `encoding:` option
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- Bug report template now requires Ruby version and gem version fields
|
|
27
|
+
- Feature request template now includes "Alternatives considered" field
|
|
28
|
+
|
|
10
29
|
## [0.4.0] - 2026-04-10
|
|
11
30
|
|
|
12
31
|
### Added
|
|
@@ -65,6 +84,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
65
84
|
- Support for hash records with symbol and string keys
|
|
66
85
|
- Proper CSV escaping for values with commas and quotes
|
|
67
86
|
|
|
87
|
+
[0.6.0]: https://github.com/philiprehberger/rb-csv-builder/releases/tag/v0.6.0
|
|
88
|
+
[0.5.0]: https://github.com/philiprehberger/rb-csv-builder/releases/tag/v0.5.0
|
|
68
89
|
[0.4.0]: https://github.com/philiprehberger/rb-csv-builder/releases/tag/v0.4.0
|
|
69
90
|
[0.3.0]: https://github.com/philiprehberger/rb-csv-builder/releases/tag/v0.3.0
|
|
70
91
|
[0.2.0]: https://github.com/philiprehberger/rb-csv-builder/releases/tag/v0.2.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
|
|
@@ -197,6 +273,27 @@ end
|
|
|
197
273
|
|
|
198
274
|
`offset` and `limit` are applied after filtering and sorting.
|
|
199
275
|
|
|
276
|
+
### Excel-Compatible Output (BOM)
|
|
277
|
+
|
|
278
|
+
Prepend a UTF-8 BOM so Excel opens the CSV with correct encoding:
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
builder = Philiprehberger::CsvBuilder.build(records, bom: true) do
|
|
282
|
+
column :name
|
|
283
|
+
column :email
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
builder.to_file('export.csv')
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Custom Encoding
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
builder = Philiprehberger::CsvBuilder.build(records, encoding: 'ISO-8859-1') do
|
|
293
|
+
column :name
|
|
294
|
+
end
|
|
295
|
+
```
|
|
296
|
+
|
|
200
297
|
### Streaming
|
|
201
298
|
|
|
202
299
|
```ruby
|
|
@@ -232,10 +329,15 @@ builder.headers # => ["name", "email"]
|
|
|
232
329
|
|
|
233
330
|
| Method | Description |
|
|
234
331
|
|--------|-------------|
|
|
235
|
-
| `CsvBuilder.build(records, delimiter:, quote_char:, &block)` | Build a CSV using the column DSL |
|
|
332
|
+
| `CsvBuilder.build(records, delimiter:, quote_char:, bom:, encoding:, &block)` | Build a CSV using the column DSL |
|
|
333
|
+
| `CsvBuilder.tsv(records, **options, &block)` | Shorthand for tab-separated output |
|
|
334
|
+
| `CsvBuilder.psv(records, **options, &block)` | Shorthand for pipe-separated output |
|
|
236
335
|
| `Builder#column(name, header:, &block)` | Define a column with optional alias and transform |
|
|
237
336
|
| `Builder#filter(&block)` | Filter records (block returns true to include) |
|
|
238
337
|
| `Builder#sort_by(direction:, &block)` | Sort records by block key (`:asc` or `:desc`) |
|
|
338
|
+
| `Builder#validate(&block)` | Register a row validation block; raises `ValidationError` on failure |
|
|
339
|
+
| `Builder#transform_header(&block)` | Register a proc applied to all column headers |
|
|
340
|
+
| `Builder#total(column, &block)` | Add a footer row with computed total for the named column |
|
|
239
341
|
| `Builder#footer(&block)` | Append a computed footer row (block receives filtered records) |
|
|
240
342
|
| `Builder#limit(n)` | Cap output to N rows |
|
|
241
343
|
| `Builder#offset(n)` | Skip first N filtered/sorted records |
|
|
@@ -16,10 +16,13 @@ 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
|
-
|
|
19
|
+
# @param bom [Boolean] prepend UTF-8 BOM (default: false)
|
|
20
|
+
# @param encoding [String] output encoding name (default: "UTF-8")
|
|
21
|
+
def initialize(records, delimiter: ',', quote_char: '"', bom: false, encoding: 'UTF-8')
|
|
20
22
|
@records = records
|
|
21
23
|
@columns = []
|
|
22
24
|
@filters = []
|
|
25
|
+
@validations = []
|
|
23
26
|
@row_number_header = nil
|
|
24
27
|
@delimiter = delimiter
|
|
25
28
|
@quote_char = quote_char
|
|
@@ -28,6 +31,9 @@ module Philiprehberger
|
|
|
28
31
|
@limit_count = nil
|
|
29
32
|
@offset_count = nil
|
|
30
33
|
@footer_block = nil
|
|
34
|
+
@header_transform = nil
|
|
35
|
+
@bom = bom
|
|
36
|
+
@encoding = encoding
|
|
31
37
|
end
|
|
32
38
|
|
|
33
39
|
# Sort records before CSV output
|
|
@@ -39,7 +45,8 @@ module Philiprehberger
|
|
|
39
45
|
# @raise [Error] if direction is not :asc or :desc
|
|
40
46
|
def sort_by(direction: :asc, &block)
|
|
41
47
|
raise Error, 'A block is required for sort_by' unless block
|
|
42
|
-
raise Error, "direction must be :asc or :desc (got #{direction.inspect})" unless %i[asc
|
|
48
|
+
raise Error, "direction must be :asc or :desc (got #{direction.inspect})" unless %i[asc
|
|
49
|
+
desc].include?(direction)
|
|
43
50
|
|
|
44
51
|
@sort_by = block
|
|
45
52
|
@sort_direction = direction
|
|
@@ -74,6 +81,46 @@ module Philiprehberger
|
|
|
74
81
|
self
|
|
75
82
|
end
|
|
76
83
|
|
|
84
|
+
# Register a validation block for rows
|
|
85
|
+
#
|
|
86
|
+
# @yield [row] block that validates the row hash
|
|
87
|
+
# @yieldparam row [Hash] column-name to value mapping
|
|
88
|
+
# @return [self]
|
|
89
|
+
def validate(&block)
|
|
90
|
+
@validations << block
|
|
91
|
+
self
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Register a proc applied to all column headers during rendering
|
|
95
|
+
#
|
|
96
|
+
# @yield [name] block that transforms a header name
|
|
97
|
+
# @yieldparam name [String] the original header label
|
|
98
|
+
# @return [self]
|
|
99
|
+
def transform_header(&block)
|
|
100
|
+
@header_transform = block
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Shorthand for adding a footer row with a computed total for the named column
|
|
105
|
+
#
|
|
106
|
+
# @param column_name [Symbol, String] the column to total
|
|
107
|
+
# @yield [values] optional block to compute the total (receives array of numeric values)
|
|
108
|
+
# @return [self]
|
|
109
|
+
def total(column_name, &block)
|
|
110
|
+
col_name = column_name.to_sym
|
|
111
|
+
@footer_block = lambda do |recs|
|
|
112
|
+
columns.map do |col|
|
|
113
|
+
if col.name == col_name
|
|
114
|
+
values = recs.map { |r| col.extract(r).to_f }
|
|
115
|
+
block ? block.call(values) : values.sum
|
|
116
|
+
else
|
|
117
|
+
''
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
self
|
|
122
|
+
end
|
|
123
|
+
|
|
77
124
|
# Define a column
|
|
78
125
|
#
|
|
79
126
|
# @param name [Symbol, String] the column name
|
|
@@ -110,6 +157,7 @@ module Philiprehberger
|
|
|
110
157
|
# @return [Array<String>]
|
|
111
158
|
def headers
|
|
112
159
|
base = @columns.map(&:header)
|
|
160
|
+
base = base.map { |h| @header_transform.call(h) } if @header_transform
|
|
113
161
|
@row_number_header ? [@row_number_header] + base : base
|
|
114
162
|
end
|
|
115
163
|
|
|
@@ -133,15 +181,19 @@ module Philiprehberger
|
|
|
133
181
|
# Generate the CSV as a string
|
|
134
182
|
#
|
|
135
183
|
# @return [String]
|
|
184
|
+
# @raise [ValidationError] if any row fails validation
|
|
136
185
|
def to_csv
|
|
137
186
|
recs = filtered_records
|
|
138
|
-
|
|
187
|
+
validate_rows!(recs) unless @validations.empty?
|
|
188
|
+
csv_string = CSV.generate(**csv_options) do |csv|
|
|
139
189
|
csv << headers
|
|
140
190
|
recs.each_with_index do |record, index|
|
|
141
191
|
csv << build_row(record, index)
|
|
142
192
|
end
|
|
143
193
|
csv << @footer_block.call(recs) if @footer_block
|
|
144
194
|
end
|
|
195
|
+
csv_string = csv_string.encode(@encoding) unless @encoding == 'UTF-8'
|
|
196
|
+
@bom ? "\xEF\xBB\xBF#{csv_string}" : csv_string
|
|
145
197
|
end
|
|
146
198
|
|
|
147
199
|
# Write the CSV to a file
|
|
@@ -149,15 +201,18 @@ module Philiprehberger
|
|
|
149
201
|
# @param path [String] the output file path
|
|
150
202
|
# @return [void]
|
|
151
203
|
def to_file(path)
|
|
152
|
-
File.
|
|
204
|
+
File.binwrite(path, to_csv)
|
|
153
205
|
end
|
|
154
206
|
|
|
155
207
|
# Stream CSV to any IO object
|
|
156
208
|
#
|
|
157
209
|
# @param io [IO, StringIO] the IO object to write to
|
|
158
210
|
# @return [void]
|
|
211
|
+
# @raise [ValidationError] if any row fails validation
|
|
159
212
|
def to_io(io)
|
|
213
|
+
io.write("\xEF\xBB\xBF") if @bom
|
|
160
214
|
recs = filtered_records
|
|
215
|
+
validate_rows!(recs) unless @validations.empty?
|
|
161
216
|
csv = CSV.new(io, **csv_options)
|
|
162
217
|
csv << headers
|
|
163
218
|
recs.each_with_index do |record, index|
|
|
@@ -173,6 +228,27 @@ module Philiprehberger
|
|
|
173
228
|
{ col_sep: @delimiter, quote_char: @quote_char }
|
|
174
229
|
end
|
|
175
230
|
|
|
231
|
+
# Validate all rows against registered validation blocks
|
|
232
|
+
#
|
|
233
|
+
# @param recs [Array] the filtered records
|
|
234
|
+
# @return [void]
|
|
235
|
+
# @raise [ValidationError] if any row fails validation
|
|
236
|
+
def validate_rows!(recs)
|
|
237
|
+
recs.each_with_index do |record, index|
|
|
238
|
+
row_hash = @columns.to_h do |col|
|
|
239
|
+
[col.name, col.extract(record)]
|
|
240
|
+
end
|
|
241
|
+
@validations.each do |v|
|
|
242
|
+
result = v.call(row_hash)
|
|
243
|
+
raise ValidationError, "Row #{index + 1} failed validation" unless result
|
|
244
|
+
rescue ValidationError
|
|
245
|
+
raise
|
|
246
|
+
rescue StandardError => e
|
|
247
|
+
raise ValidationError, "Row #{index + 1} failed validation: #{e.message}"
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
176
252
|
# Build a single row array for the given record
|
|
177
253
|
#
|
|
178
254
|
# @param record [Object] the source record
|
|
@@ -7,22 +7,49 @@ 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 bom [Boolean] prepend UTF-8 BOM for Excel compatibility (default: false)
|
|
18
|
+
# @param encoding [String] output encoding name (default: "UTF-8")
|
|
16
19
|
# @yield [builder] the builder instance for defining columns
|
|
17
20
|
# @yieldparam builder [Builder]
|
|
18
21
|
# @return [Builder] the configured builder
|
|
19
22
|
# @raise [Error] if no block is given
|
|
20
|
-
def self.build(records, delimiter: ',', quote_char: '"', &block)
|
|
23
|
+
def self.build(records, delimiter: ',', quote_char: '"', bom: false, encoding: 'UTF-8', &block)
|
|
21
24
|
raise Error, 'A block is required' unless block
|
|
22
25
|
|
|
23
|
-
builder = Builder.new(
|
|
26
|
+
builder = Builder.new(
|
|
27
|
+
records,
|
|
28
|
+
delimiter: delimiter, quote_char: quote_char,
|
|
29
|
+
bom: bom, encoding: encoding
|
|
30
|
+
)
|
|
24
31
|
builder.instance_eval(&block)
|
|
25
32
|
builder
|
|
26
33
|
end
|
|
34
|
+
|
|
35
|
+
# Shorthand for tab-separated output
|
|
36
|
+
#
|
|
37
|
+
# @param records [Array] the source records
|
|
38
|
+
# @param options [Hash] additional options passed to .build
|
|
39
|
+
# @yield [builder] the builder instance for defining columns
|
|
40
|
+
# @return [Builder] the configured builder
|
|
41
|
+
def self.tsv(records, **options, &)
|
|
42
|
+
build(records, **options, delimiter: "\t", &)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Shorthand for pipe-separated output
|
|
46
|
+
#
|
|
47
|
+
# @param records [Array] the source records
|
|
48
|
+
# @param options [Hash] additional options passed to .build
|
|
49
|
+
# @yield [builder] the builder instance for defining columns
|
|
50
|
+
# @return [Builder] the configured builder
|
|
51
|
+
def self.psv(records, **options, &)
|
|
52
|
+
build(records, **options, delimiter: '|', &)
|
|
53
|
+
end
|
|
27
54
|
end
|
|
28
55
|
end
|
metadata
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
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.6.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, TSV/PSV
|
|
16
|
+
shorthands, row validation, header transforms, and total rows.
|
|
16
17
|
email:
|
|
17
18
|
- me@philiprehberger.com
|
|
18
19
|
executables: []
|