Gem Version Build

Csb

A simple, streaming CSV template engine for Ruby on Rails. (The name is short for CSV builder.)

Why csb?

Writing CSV downloads in Rails by hand looks easy, but the naive approach has recurring problems:

# app/views/posts/index.csv.erb (the typical hand-written version)
CSV.generate do |csv|
  csv << %w[Date Category Title Content]
  @posts.each do |post|
    csv << [l(post.created_at.to_date), post.category.name, post.title, post.content]
  end
end
  • Garbled in Excel — UTF-8 without a BOM shows up as mojibake.
  • Memory / timeout errors — loading and building the whole CSV in memory breaks on large datasets.
  • Hard to maintain — headers and values are defined far apart, so adding columns hurts readability.
  • Hard to test — the export logic is buried in a view, leaving you stuck with slow system tests.

csb solves each of these:

  • Excel-friendly — output UTF-8 with a BOM so Excel opens it without garbling.
  • Streaming download — stream row by row to handle hundreds of thousands of records without memory or timeout errors.
  • Readable — define each column's header and value together on one line.
  • Testable — extract column definitions into a model and unit-test them directly.

Usage

Template handler

In app/controllers/reports_controller.rb:

def index
  @reports = Report.preload(:categories)
end

In app/views/reports/index.csv.csb:

csv.items = @reports

# For large datasets, pass an Enumerator so streaming starts immediately
# instead of waiting for every record to load:
# csv.items = @reports.find_each

# Combine with a decorator (e.g. Draper) while keeping it lazy:
# csv.items = @reports.find_each.lazy.map(&:decorate)

# Optional per-view overrides:
# csv.filename = "reports_#{Time.current.to_i}.csv"
# csv.streaming = false
# csv.csv_options = { col_sep: "\t" }

csv.cols.add('Update date') { |r| l(r.updated_at.to_date) } # block receives the record
csv.cols.add('Categories') { |r| r.categories.pluck(:name).join(' ') }
csv.cols.add('Content', :content) # a Symbol calls the method on the record
csv.cols.add('Static', 'dummy')   # a String is output verbatim
csv.cols.add('Empty')             # no value -> empty column
csv.cols.add('Dup', :col1)        # the same header may be added more than once
csv.cols.add('Dup', :col2)        # (columns are output in definition order)

Output:

Update date,Categories,Content,Static,Empty,Dup,Dup
2019/06/01,category1 category2,content1,dummy,,a,b
2019/06/02,category3,content2,dummy,,c,d

A link such as link_to 'Download CSV', reports_path(format: :csv) triggers the streaming download automatically.

Directly

When you want to generate the CSV outside of a request (e.g. in a background job), use Csb::Builder:

csv = Csb::Builder.new(items: items)
csv.cols.add('Update date') { |r| l(r.updated_at.to_date) }
csv.cols.add('Categories') { |r| r.categories.pluck(:name).join(' ') }
csv.cols.add('Content', :content)
csv.build # => returns the CSV string

# File.write('reports.csv', csv.build)

Testing

Move the column definitions out of the view so you can unit-test them:

# app/views/articles/index.csv.csb
csv.items = @articles
csv.cols = Article.csb_cols

# app/models/article.rb
def self.csb_cols
  Csb::Cols.new do |cols|
    cols.add('Update date') { |r| I18n.l(r.updated_at.to_date) }
    cols.add('Categories') { |r| r.categories.pluck(:name).join(' ') }
    cols.add('Title', :title)
  end
end
# spec/models/article_spec.rb
require 'csb/testing' # adds col_pairs and as_table

# Assert a single record, row by row:
expect(Article.csb_cols.col_pairs(article)).to eq [
  ['Update date', '2020-01-01'],
  ['Categories', 'test rspec'],
  ['Title', 'Testing'],
]

# Assert the whole table (header row + value rows):
expect(Article.csb_cols.as_table(articles)).to eq [
  ['Update date', 'Categories', 'Title'],
  ['2020-01-01', 'test rspec', 'Testing'],
  ['2020-02-01', 'rails gem', 'Rails 6.2'],
]

Installation

Add this line to your application's Gemfile:

gem 'csb'

And then execute:

$ bundle

Or install it yourself as:

$ gem install csb

Configuration

In config/initializers/csb.rb, you can configure the following values.

Csb.configure do |config|
  config.utf8_bom = true # default: false
  config.streaming = false # default: true
  config.csv_options = { col_sep: "\t" } # default: {}

  # Called when an error is raised during streaming. Without this, errors that
  # happen mid-stream are not reported even if you use a tool like Bugsnag.
  config.after_streaming_error = ->(error) do # default: nil
    Rails.logger.error(error)
    Bugsnag.notify(error)
  end

  # Error classes to ignore (not re-raise) during streaming, e.g. when the
  # client disconnects before the download finishes.
  config.ignore_class_names = %w[Puma::ConnectionError] # default: %w[Puma::ConnectionError]
end

Contributing

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 code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the Csb project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.