class ImportResult
RowError = Struct.new(:line, :messages)
attr_reader :imported, :row_errors
def initialize
@imported = 0
@row_errors = []
end
def record_success
@imported += 1
end
def record_failure(line, messages)
@row_errors << RowError.new(line, Array(messages))
end
def failed
@row_errors.size
end
def success?
@row_errors.empty?
end
def summary
"Imported #{imported} products, #{failed} failed."
end
end
require "csv"
require "bigdecimal"
class ProductCsvImporter
REQUIRED_HEADERS = %w[sku name price].freeze
def initialize(content, imported_by:)
@content = content
@imported_by = imported_by
end
def call
result = ImportResult.new
ActiveRecord::Base.transaction do
CSV.parse(@content, headers: true).each_with_index do |row, index|
line = index + 2 # header is line 1
product = Product.new(attributes_for(row))
if product.save
result.record_success
else
result.record_failure(line, product.errors.full_messages)
end
end
end
result
rescue CSV::MalformedCSVError => e
result.record_failure(0, "File could not be parsed: #{e.message}")
result
end
private
def attributes_for(row)
{
sku: row["sku"].to_s.strip,
name: row["name"].to_s.strip,
price: parse_price(row["price"]),
imported_by_id: @imported_by.id
}
end
def parse_price(raw)
BigDecimal(raw.to_s.strip)
rescue ArgumentError
nil # let model validation reject it
end
end
class ProductsController < ApplicationController
def new_import
end
def import
file = params.require(:file)
importer = ProductCsvImporter.new(file.read, imported_by: current_user)
@result = importer.call
if @result.success?
redirect_to products_path, notice: @result.summary
else
flash.now[:alert] = @result.summary
render :new_import, status: :unprocessable_entity
end
rescue ActionController::ParameterMissing
redirect_to new_import_products_path, alert: "Please choose a CSV file."
end
end
This snippet shows the common Rails pattern of wrapping a CSV import in a service object so the controller stays thin and the messy details — parsing, per-row validation, and error accumulation — live in one testable place.
In ImportResult, the outcome of the run is modeled as an explicit value object rather than a raw boolean. It tallies imported and failed counts and keeps a row_errors list of small structs, each carrying the offending line number and its messages. Returning a result object instead of raising means the caller can render a rich summary; success? merely asks whether any rows failed. This avoids the classic trap of an import that silently swallows bad rows or aborts the whole file on the first mistake.
The heart of the work is ProductCsvImporter. It reads with CSV.foreach and headers: true so the file is streamed row by row rather than loaded whole into memory, which matters for large uploads. Each row is converted to a plain attributes hash by attributes_for, then handed to Product.new for normal ActiveRecord validation. When save fails, the record's errors.full_messages are folded into the result together with csv.lineno, giving humans an actionable line reference. The enumerate index is + 2 because the header consumes line one and each_with_index starts at zero.
A key design choice is the transaction with requested_by: isolation: each row saves independently, so one bad row never rolls back the good ones, yet the whole run is still wrapped so an unexpected exception leaves the table clean. String#strip guards against stray whitespace from spreadsheet exports, and BigDecimal conversion keeps prices exact.
In ProductsController, the import action reads the uploaded file from params, streams its read into the importer, and stores the returned result. Because the service returns data instead of throwing, the controller can branch on result.success? to flash a clean message or re-render with the detailed row_errors. This separation makes the importer trivial to unit test without HTTP, reusable from a rake task or a background job, and keeps validation logic out of the view layer.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.