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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a303ee02020135b1ce25af2882b415847487b5ee10f5b3f7fc929586150c750d
4
- data.tar.gz: b7cde53364fd15eacca588c5df3ca2056a3abc40d552ba650b7fad1681f804f2
3
+ metadata.gz: 574dc6604faf705d2e8f54f778e8e27f7595d0aedd3db13b4a54c9adb857bfbf
4
+ data.tar.gz: 22e3ca9d324548d233a5bd86b5c087590ad78f1a871b9107b3f5ab6fb1cdbe57
5
5
  SHA512:
6
- metadata.gz: 98272067c382b0d1a89d4400886e4162913efa72fbc6677dc3445599bcc9bdba85177894b5a89fdc9a28bd44c440279afa8a254917b208d766e3aa703e4bdc6e
7
- data.tar.gz: ee0c236b83966c409af73445c43bb626330c7227964cba73054ac475b2cb3db7218f95ef4167901e9086f64c1af348f4adbc998abfdbbcc770472937fb80fa9f
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
- def initialize(records, delimiter: ',', quote_char: '"')
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 desc].include?(direction)
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
- CSV.generate(**csv_options) do |csv|
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.write(path, to_csv)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module CsvBuilder
5
- VERSION = '0.4.0'
5
+ VERSION = '0.6.0'
6
6
  end
7
7
  end
@@ -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(records, delimiter: delimiter, quote_char: quote_char)
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.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-10 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, TSV/PSV
16
+ shorthands, row validation, header transforms, and total rows.
16
17
  email:
17
18
  - me@philiprehberger.com
18
19
  executables: []