csb 0.15.0 → 0.17.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: cad937e169f1330223f51cefcd7c636c8132eee9223ea86c99e465bde7140e14
4
- data.tar.gz: 529f7c0fdbd85a7ed4332587ae09cc1ef45730f5a6819f6b389da048219fc446
3
+ metadata.gz: f05cd902f17175e076c30da2f46b027e9306a8027c535a592bb6b177e6075247
4
+ data.tar.gz: 863d4f9abbdf7489a78494065c6b6f50f3cef74865e692002d8f0214f5a2b3d4
5
5
  SHA512:
6
- metadata.gz: 765f40840055d26afbca395312836480017ad08679d96fe295f6cf088a62ab1e09d019fbff41e2200cc9d6364beaef59ba26688f5290249787b83e9311f1e6bd
7
- data.tar.gz: 07535155a1c0788a148b5fc8faadd7b03e76a661b134c5c47ecc5b2283e980ffc50ff0d4e5dbcf4fc19ae25bb5abcefbbd217484ff1da26d7f760e416ea6482e
6
+ metadata.gz: 2733b58f2e976bc92affbc510392949f12795d7d78f417fcee413255724f3d313088731869f0ed3179cc5ba6674cfb96133ae21fd44870d283004f26e6262d39
7
+ data.tar.gz: 1c7231a8a755593232e033d503d16951b2d39c998ce010c2063317460582cc9ae08a4a665594c2ddd2f38f514a293567ca968dbcd8cfdf0b373cce0a0c8aa5ee
@@ -16,15 +16,8 @@ jobs:
16
16
  strategy:
17
17
  fail-fast: false
18
18
  matrix:
19
- ruby: ["3.1", "3.2", "3.3", "3.4", "4.0"]
20
- gemfile: ["rails71", "rails72", "rails80", "rails81"]
21
- exclude:
22
- # rails 8.0: support ruby 3.2+
23
- - ruby: "3.1"
24
- gemfile: "rails80"
25
- # rails 8.1: support ruby 3.2+
26
- - ruby: "3.1"
27
- gemfile: "rails81"
19
+ ruby: ["3.3", "3.4", "4.0"]
20
+ gemfile: ["rails72", "rails80", "rails81"]
28
21
  steps:
29
22
  - uses: actions/checkout@v6
30
23
 
data/.gitignore CHANGED
@@ -12,3 +12,6 @@
12
12
  Gemfile.lock
13
13
  /vendor/bundle
14
14
  .vscode/settings.json
15
+
16
+ # Local-only Japanese skill drafts (not distributed)
17
+ skills/**/*-ja.md
data/README.md CHANGED
@@ -3,20 +3,39 @@
3
3
 
4
4
  # Csb
5
5
 
6
- A simple and streaming support CSV template engine for Ruby on Rails.
6
+ A simple, streaming CSV template engine for Ruby on Rails. (The name is short for **CSV builder**.)
7
7
 
8
- ## Features
8
+ ## Why csb?
9
9
 
10
- - Support for streaming downloads
11
- - Output in UTF-8 with BOM
12
- - Readable code
13
- - High testability
10
+ Writing CSV downloads in Rails by hand looks easy, but the naive approach has recurring problems:
11
+
12
+ ```ruby
13
+ # app/views/posts/index.csv.erb (the typical hand-written version)
14
+ CSV.generate do |csv|
15
+ csv << %w[Date Category Title Content]
16
+ @posts.each do |post|
17
+ csv << [l(post.created_at.to_date), post.category.name, post.title, post.content]
18
+ end
19
+ end
20
+ ```
21
+
22
+ - **Garbled in Excel** — UTF-8 without a BOM shows up as mojibake.
23
+ - **Memory / timeout errors** — loading and building the whole CSV in memory breaks on large datasets.
24
+ - **Hard to maintain** — headers and values are defined far apart, so adding columns hurts readability.
25
+ - **Hard to test** — the export logic is buried in a view, leaving you stuck with slow system tests.
26
+
27
+ csb solves each of these:
28
+
29
+ - **Excel-friendly** — output UTF-8 with a BOM so Excel opens it without garbling.
30
+ - **Streaming download** — stream row by row to handle hundreds of thousands of records without memory or timeout errors.
31
+ - **Readable** — define each column's header and value together on one line.
32
+ - **Testable** — extract column definitions into a model and unit-test them directly.
14
33
 
15
34
  ## Usage
16
35
 
17
36
  ### Template handler
18
37
 
19
- In app/controllers/reports_controller.rb:
38
+ In `app/controllers/reports_controller.rb`:
20
39
 
21
40
  ```ruby
22
41
  def index
@@ -24,61 +43,66 @@ def index
24
43
  end
25
44
  ```
26
45
 
27
- In app/views/reports/index.csv.csb:
46
+ In `app/views/reports/index.csv.csb`:
28
47
 
29
48
  ```ruby
30
49
  csv.items = @reports
31
50
 
32
- # When there are many records
51
+ # For large datasets, pass an Enumerator so streaming starts immediately
52
+ # instead of waiting for every record to load:
33
53
  # csv.items = @reports.find_each
34
54
 
35
- # When there are many records with decorator
55
+ # Combine with a decorator (e.g. Draper) while keeping it lazy:
36
56
  # csv.items = @reports.find_each.lazy.map(&:decorate)
37
57
 
58
+ # Optional per-view overrides:
38
59
  # csv.filename = "reports_#{Time.current.to_i}.csv"
39
60
  # csv.streaming = false
40
61
  # csv.csv_options = { col_sep: "\t" }
41
62
 
42
- csv.cols.add('Update date') { |r| l(r.updated_at.to_date) }
63
+ csv.cols.add('Update date') { |r| l(r.updated_at.to_date) } # block receives the record
43
64
  csv.cols.add('Categories') { |r| r.categories.pluck(:name).join(' ') }
44
- csv.cols.add('Content', :content)
45
- csv.cols.add('Empty')
46
- csv.cols.add('Static', 'dummy')
65
+ csv.cols.add('Content', :content) # a Symbol calls the method on the record
66
+ csv.cols.add('Static', 'dummy') # a String is output verbatim
67
+ csv.cols.add('Empty') # no value -> empty column
68
+ csv.cols.add('Dup', :col1) # the same header may be added more than once
69
+ csv.cols.add('Dup', :col2) # (columns are output in definition order)
47
70
  ```
48
71
 
49
72
  Output:
50
73
 
51
74
  ```csv
52
- Update date,Categories,Content,Empty,Static
53
- 2019/06/01,category1 category2,content1,,dummy
54
- 2019/06/02,category3,content2,,dummy
75
+ Update date,Categories,Content,Static,Empty,Dup,Dup
76
+ 2019/06/01,category1 category2,content1,dummy,,a,b
77
+ 2019/06/02,category3,content2,dummy,,c,d
55
78
  ```
56
79
 
80
+ A link such as `link_to 'Download CSV', reports_path(format: :csv)` triggers the streaming download automatically.
81
+
57
82
  ### Directly
58
83
 
84
+ When you want to generate the CSV outside of a request (e.g. in a background job), use `Csb::Builder`:
85
+
59
86
  ```ruby
60
87
  csv = Csb::Builder.new(items: items)
61
88
  csv.cols.add('Update date') { |r| l(r.updated_at.to_date) }
62
89
  csv.cols.add('Categories') { |r| r.categories.pluck(:name).join(' ') }
63
90
  csv.cols.add('Content', :content)
64
- csv.cols.add('Empty')
65
- csv.cols.add('Static', 'dummy')
66
- csv.build
67
-
68
- # =>
69
- # Update date,Categories,Content,Empty,Static
70
- # 2019/06/01,category1 category2,content1,,dummy
71
- # 2019/06/02,category3,content2,,dummy
91
+ csv.build # => returns the CSV string
92
+
93
+ # File.write('reports.csv', csv.build)
72
94
  ```
73
95
 
74
96
  ### Testing
75
97
 
98
+ Move the column definitions out of the view so you can unit-test them:
99
+
76
100
  ```ruby
77
- # Your view
101
+ # app/views/articles/index.csv.csb
78
102
  csv.items = @articles
79
103
  csv.cols = Article.csb_cols
80
104
 
81
- # Your Model
105
+ # app/models/article.rb
82
106
  def self.csb_cols
83
107
  Csb::Cols.new do |cols|
84
108
  cols.add('Update date') { |r| I18n.l(r.updated_at.to_date) }
@@ -86,16 +110,20 @@ def self.csb_cols
86
110
  cols.add('Title', :title)
87
111
  end
88
112
  end
113
+ ```
89
114
 
90
- # Your test
91
- require 'csb/testing'
115
+ ```ruby
116
+ # spec/models/article_spec.rb
117
+ require 'csb/testing' # adds col_pairs and as_table
92
118
 
119
+ # Assert a single record, row by row:
93
120
  expect(Article.csb_cols.col_pairs(article)).to eq [
94
121
  ['Update date', '2020-01-01'],
95
122
  ['Categories', 'test rspec'],
96
123
  ['Title', 'Testing'],
97
124
  ]
98
125
 
126
+ # Assert the whole table (header row + value rows):
99
127
  expect(Article.csb_cols.as_table(articles)).to eq [
100
128
  ['Update date', 'Categories', 'Title'],
101
129
  ['2020-01-01', 'test rspec', 'Testing'],
@@ -132,13 +160,32 @@ Csb.configure do |config|
132
160
  config.utf8_bom = true # default: false
133
161
  config.streaming = false # default: true
134
162
  config.csv_options = { col_sep: "\t" } # default: {}
163
+
164
+ # Called when an error is raised during streaming. Without this, errors that
165
+ # happen mid-stream are not reported even if you use a tool like Bugsnag.
135
166
  config.after_streaming_error = ->(error) do # default: nil
136
167
  Rails.logger.error(error)
137
168
  Bugsnag.notify(error)
138
169
  end
170
+
171
+ # Error classes to ignore (not re-raise) during streaming, e.g. when the
172
+ # client disconnects before the download finishes.
173
+ config.ignore_class_names = %w[Puma::ConnectionError] # default: %w[Puma::ConnectionError]
139
174
  end
140
175
  ```
141
176
 
177
+ ## Agent skill
178
+
179
+ This gem ships an [agent skill](skills/csb/) (`SKILL.md`) so AI coding agents (e.g. Claude Code) understand how to use csb. Install it into your project with [apm (Agent Package Manager)](https://github.com/microsoft/apm):
180
+
181
+ ```sh
182
+ apm install aki77/csb/skills/csb
183
+ ```
184
+
185
+ apm deploys the skill to each agent's directory (e.g. `.claude/skills/`) and locks the version in its lockfile.
186
+
187
+ Alternatively, if your project already pulls csb in via Bundler, the [bundler-skills](https://github.com/aki77/bundler-skills) plugin auto-syncs this skill on `bundle install` — keeping the skill version locked to the gem version.
188
+
142
189
  ## Contributing
143
190
 
144
191
  Bug reports and pull requests are welcome on GitHub at https://github.com/aki77/csb. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
data/csb.gemspec CHANGED
@@ -20,15 +20,17 @@ Gem::Specification.new do |spec|
20
20
  # Specify which files should be added to the gem when it is released.
21
21
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22
22
  spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
23
- `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
23
+ `git ls-files -z`.split("\x0").reject do |f|
24
+ f.match(%r{^(test|spec|features|\.claude)/}) || f.match(%r{^skills/.*-ja\.md$})
25
+ end
24
26
  end
25
27
  spec.bindir = "exe"
26
28
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
27
29
  spec.require_paths = ["lib"]
28
30
 
29
- spec.required_ruby_version = '>= 3.1.0'
31
+ spec.required_ruby_version = '>= 3.3.0'
30
32
 
31
- spec.add_dependency "rails", ">= 7.1.4"
33
+ spec.add_dependency "rails", ">= 7.2.0"
32
34
  spec.add_dependency "csv"
33
35
 
34
36
  spec.add_development_dependency "rake"
@@ -1,4 +1,4 @@
1
- source "http://rubygems.org"
1
+ source "https://rubygems.org"
2
2
 
3
3
  gem 'rails', '~> 7.2.0'
4
4
 
@@ -1,4 +1,4 @@
1
- source "http://rubygems.org"
1
+ source "https://rubygems.org"
2
2
 
3
3
  gem 'rails', '~> 8.0.0'
4
4
 
@@ -1,4 +1,4 @@
1
- source "http://rubygems.org"
1
+ source "https://rubygems.org"
2
2
 
3
3
  gem 'rails', '~> 8.1.0'
4
4
 
data/lib/csb/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Csb
2
- VERSION = '0.15.0'
2
+ VERSION = '0.17.0'
3
3
  end
@@ -0,0 +1,150 @@
1
+ ---
2
+ name: csb
3
+ description: "Generate streaming, Excel-friendly CSV downloads in Rails with the csb gem. Use when implementing a CSV export/download, writing a `.csv.csb` template, building CSV via Csb::Builder, or testing column definitions. Not for parsing CSV."
4
+ ---
5
+
6
+ # csb
7
+
8
+ A simple, streaming CSV template engine for Ruby on Rails (the name is short for **CSV builder**). Use it to generate Excel-friendly, memory-safe CSV downloads with column definitions that are easy to read and unit-test.
9
+
10
+ ## When to use csb
11
+
12
+ Reach for csb when building a CSV **download/export** in Rails. It replaces the naive hand-written `CSV.generate` in a view, which has recurring problems csb solves:
13
+
14
+ | Problem with hand-written CSV | csb solution |
15
+ | --- | --- |
16
+ | Garbled in Excel (UTF-8 without BOM) | Optional UTF-8 BOM output |
17
+ | Memory / timeout on large datasets | Row-by-row streaming download |
18
+ | Headers and values defined far apart | Each column's header + value on one line |
19
+ | Export logic buried in a view, hard to test | Extract column defs to a model and unit-test |
20
+
21
+ Out of scope: **parsing** CSV (use the `csv` stdlib directly).
22
+
23
+ ## Approach 1: Template handler (the common case)
24
+
25
+ Controller — just assign the records (an `ActiveRecord::Relation` is fine, no `.to_a` needed):
26
+
27
+ ```ruby
28
+ # app/controllers/reports_controller.rb
29
+ def index
30
+ @reports = Report.preload(:categories)
31
+ end
32
+ ```
33
+
34
+ View — `app/views/reports/index.csv.csb` (note the `.csv.csb` extension):
35
+
36
+ ```ruby
37
+ csv.items = @reports
38
+
39
+ # Each column: header + value defined together.
40
+ csv.cols.add('Update date') { |r| l(r.updated_at.to_date) } # block receives the record
41
+ csv.cols.add('Categories') { |r| r.categories.pluck(:name).join(' ') }
42
+ csv.cols.add('Content', :content) # Symbol -> calls the method on the record
43
+ csv.cols.add('Static', 'dummy') # String -> output verbatim
44
+ csv.cols.add('Empty') # no value -> empty column
45
+ csv.cols.add('Dup', :col1) # the same header may be added more than once;
46
+ csv.cols.add('Dup', :col2) # columns are output in definition order
47
+ ```
48
+
49
+ A link like `link_to 'Download CSV', reports_path(format: :csv)` triggers the streaming download automatically.
50
+
51
+ ### Large datasets
52
+
53
+ Pass an Enumerator so streaming starts immediately instead of loading every record first:
54
+
55
+ ```ruby
56
+ csv.items = @reports.find_each
57
+ # With a decorator (e.g. Draper), kept lazy:
58
+ csv.items = @reports.find_each.lazy.map(&:decorate)
59
+ ```
60
+
61
+ ### Per-view overrides
62
+
63
+ ```ruby
64
+ csv.filename = "reports_#{Time.current.to_i}.csv"
65
+ csv.streaming = false
66
+ csv.csv_options = { col_sep: "\t" }
67
+ ```
68
+
69
+ ## Approach 2: Direct generation (outside a request)
70
+
71
+ For background jobs or anywhere outside a controller, use `Csb::Builder`:
72
+
73
+ ```ruby
74
+ csv = Csb::Builder.new(items: items)
75
+ csv.cols.add('Update date') { |r| l(r.updated_at.to_date) }
76
+ csv.cols.add('Categories') { |r| r.categories.pluck(:name).join(' ') }
77
+ csv.cols.add('Content', :content)
78
+ csv.build # => returns the CSV string
79
+
80
+ # File.write('reports.csv', csv.build)
81
+ ```
82
+
83
+ ## Testing column definitions
84
+
85
+ Extract the column definitions into a model method so they can be unit-tested apart from the view:
86
+
87
+ ```ruby
88
+ # app/views/articles/index.csv.csb
89
+ csv.items = @articles
90
+ csv.cols = Article.csb_cols
91
+
92
+ # app/models/article.rb
93
+ def self.csb_cols
94
+ Csb::Cols.new do |cols|
95
+ cols.add('Update date') { |r| I18n.l(r.updated_at.to_date) }
96
+ cols.add('Categories') { |r| r.categories.pluck(:name).join(' ') }
97
+ cols.add('Title', :title)
98
+ end
99
+ end
100
+ ```
101
+
102
+ ```ruby
103
+ # spec/models/article_spec.rb
104
+ require 'csb/testing' # adds col_pairs and as_table
105
+
106
+ # One record, header/value pairs:
107
+ expect(Article.csb_cols.col_pairs(article)).to eq [
108
+ ['Update date', '2020-01-01'],
109
+ ['Categories', 'test rspec'],
110
+ ['Title', 'Testing'],
111
+ ]
112
+
113
+ # Whole table (header row + value rows):
114
+ expect(Article.csb_cols.as_table(articles)).to eq [
115
+ ['Update date', 'Categories', 'Title'],
116
+ ['2020-01-01', 'test rspec', 'Testing'],
117
+ ['2020-02-01', 'rails gem', 'Rails 6.2'],
118
+ ]
119
+ ```
120
+
121
+ ## Configuration
122
+
123
+ `config/initializers/csb.rb`:
124
+
125
+ ```ruby
126
+ Csb.configure do |config|
127
+ config.utf8_bom = true # default: false. Set true so Excel opens without mojibake.
128
+ config.streaming = false # default: true
129
+ config.csv_options = { col_sep: "\t" } # default: {}
130
+
131
+ # Called when an error is raised during streaming. WITHOUT this, mid-stream
132
+ # errors are silently swallowed and won't reach tools like Bugsnag.
133
+ config.after_streaming_error = ->(error) do # default: nil
134
+ Rails.logger.error(error)
135
+ Bugsnag.notify(error)
136
+ end
137
+
138
+ # Error classes to ignore (not re-raise) during streaming, e.g. when the
139
+ # client disconnects before the download finishes.
140
+ config.ignore_class_names = %w[Puma::ConnectionError] # default: %w[Puma::ConnectionError]
141
+ end
142
+ ```
143
+
144
+ ## Pitfalls
145
+
146
+ - The view must use the `.csv.csb` extension.
147
+ - Trigger the download with `format: :csv` (e.g. `link_to 'Download', reports_path(format: :csv)`).
148
+ - The `cols.add` value rules: block → receives the record; `Symbol` → calls that method; `String` → literal; omitted → empty cell.
149
+ - Streaming errors are swallowed unless you set `config.after_streaming_error` — set it if you rely on error reporting.
150
+ - For big tables, pass `find_each` (an Enumerator), not a fully-loaded array, to keep streaming memory-safe.
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: csb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - aki77
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-01-10 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 7.1.4
18
+ version: 7.2.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 7.1.4
25
+ version: 7.2.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: csv
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -98,7 +98,6 @@ files:
98
98
  - bin/console
99
99
  - bin/setup
100
100
  - csb.gemspec
101
- - gemfiles/rails71.gemfile
102
101
  - gemfiles/rails72.gemfile
103
102
  - gemfiles/rails80.gemfile
104
103
  - gemfiles/rails81.gemfile
@@ -112,6 +111,7 @@ files:
112
111
  - lib/csb/template.rb
113
112
  - lib/csb/testing.rb
114
113
  - lib/csb/version.rb
114
+ - skills/csb/SKILL.md
115
115
  homepage: https://github.com/aki77/csb
116
116
  licenses:
117
117
  - MIT
@@ -126,14 +126,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
126
126
  requirements:
127
127
  - - ">="
128
128
  - !ruby/object:Gem::Version
129
- version: 3.1.0
129
+ version: 3.3.0
130
130
  required_rubygems_version: !ruby/object:Gem::Requirement
131
131
  requirements:
132
132
  - - ">="
133
133
  - !ruby/object:Gem::Version
134
134
  version: '0'
135
135
  requirements: []
136
- rubygems_version: 3.6.2
136
+ rubygems_version: 4.0.10
137
137
  specification_version: 4
138
138
  summary: A simple and streaming support CSV template engine for Ruby on Rails.
139
139
  test_files: []
@@ -1,5 +0,0 @@
1
- source "http://rubygems.org"
2
-
3
- gem 'rails', '~> 7.1.4'
4
-
5
- gemspec path: '../'