Skip to main content

Phlex::CSV

Class for generating CSV (Comma-Separated Values) files in Phlex. Provides robust CSV generation with configurable escaping, delimiters, and CSV injection protection.

Usage

Create a CSV component by subclassing Phlex::CSV and defining a row_template method:
class ProductsCSV < Phlex::CSV
  # Required: configure CSV injection protection
  def escape_csv_injection?
    true # or false depending on your use case
  end
  
  def row_template(product)
    column "Name", product.name
    column "Price", product.price
    column "Stock", product.stock
  end
end

products = [
  Product.new("Apple", 1.50, 100),
  Product.new("Banana", 0.75, 200)
]

ProductsCSV.new(products).call
# => "Name,Price,Stock\nApple,1.5,100\nBanana,0.75,200\n"

Constructor

initialize(collection)

Create a new CSV renderer with a collection of items.
ProductsCSV.new(@products)
collection
Enumerable
required
Collection of items to render as CSV rows

collection

Access the collection passed to the constructor.
attr_reader :collection

Instance Methods

#call(buffer = +"", context: nil, delimiter: self.delimiter)

Render the CSV and return the output string.
csv = ProductsCSV.new(products)
output = csv.call

# With custom delimiter
output = csv.call(delimiter: ";")
buffer
String
default:"+\"\""
Output buffer to append to
context
Object
Context object (reserved for future use)
delimiter
String
default:"self.delimiter"
Column delimiter (must be a single character)
Raises Phlex::ArgumentError if delimiter is not a single character.

#row_template(...)

Define the template for each row. Override this method in your CSV class.
class UsersCSV < Phlex::CSV
  def escape_csv_injection? = true
  
  def row_template(user)
    column "ID", user.id
    column "Name", user.name
    column "Email", user.email
    column "Created", user.created_at
  end
end
The arguments to row_template are determined by what you pass to each_item or around_row.

#around_row(...)

Wrap row rendering with custom logic. Call super to render the row.
class ReportCSV < Phlex::CSV
  def escape_csv_injection? = true
  
  def around_row(record)
    # Transform data before rendering
    super(record.name, record.calculated_value)
  end
  
  def row_template(name, value)
    column "Name", name
    column "Value", value
  end
end
...
Any
Arguments passed from each_item loop

#content_type

Returns the MIME type for CSV content.
ProductsCSV.new([]).content_type # => "text/csv"
Returns: "text/csv"

#filename

Override to provide a filename when serving the CSV file.
class SalesReport < Phlex::CSV
  def filename
    "sales-#{Date.today}.csv"
  end
end
Default: nil

#delimiter

Override to set a default delimiter. Default is comma (,).
class EuropeanCSV < Phlex::CSV
  def delimiter
    ";" # Common in European locales
  end
end
Default: ","

Protected Methods

#column(header = nil, value)

Define a column in the current row.
def row_template(product)
  column "Product Name", product.name
  column "Price (USD)", product.price
  column nil, product.id # Header from first call will be used
end
header
String
Column header (nil uses header from first row)
value
String | Symbol | Numeric
required
Column value (converted to string with to_s)
Column order and headers must be consistent across all rows, or a Phlex::RuntimeError will be raised.

#each_item(&block)

Override to customize iteration over the collection.
def each_item(&block)
  collection.select(&:active?).each(&block)
end
Default: Iterates over collection with each

Configuration Methods

#render_headers?

Control whether to render the header row. Override to return false to skip headers.
class NoHeaderCSV < Phlex::CSV
  def escape_csv_injection? = false
  
  private def render_headers?
    false
  end
end

# Output:
# Apple,1.5
# Banana,0.75
# (no header row)
Default: true

#trim_whitespace?

Control whether to strip leading and trailing whitespace from values.
class TrimmedCSV < Phlex::CSV
  def escape_csv_injection? = false
  
  private def trim_whitespace?
    true
  end
end

# Input: " Hello "
# Output with trim: "Hello"
# Output without trim: " Hello "
Default: false

#escape_csv_injection?

REQUIRED: Configure CSV injection protection. Must be explicitly set to true or false.
class SafeCSV < Phlex::CSV
  # Enable protection: prefixes dangerous characters with '
  def escape_csv_injection?
    true
  end
end

class UnsafeCSV < Phlex::CSV
  # Disable protection: use for data exchange between secure systems
  def escape_csv_injection?
    false
  end
end
CSV injection is a security vulnerability where malicious spreadsheet formulae execute code or exfiltrate data when opened in Excel or Google Sheets.Learn more: OWASP CSV Injection
When enabled, values starting with =, +, -, @, \t, or \r are prefixed with a single quote ' to prevent formula execution.

CSV Injection Protection

The library requires explicit configuration to handle CSV injection vulnerabilities:

Enable Protection (Data Integrity Trade-off)

class PublicExportCSV < Phlex::CSV
  def escape_csv_injection? = true
  
  def row_template(item)
    column "Name", item.name
    column "Formula", item.formula # If starts with =, becomes '=...
  end
end
Use when:
  • CSV will be opened in spreadsheet applications
  • User-generated content is included
  • Security is prioritized over exact data representation
Effect:
  • Prefixes dangerous characters with '
  • Prevents formula execution
  • May alter data appearance

Disable Protection (Security Trade-off)

class SystemExportCSV < Phlex::CSV
  def escape_csv_injection? = false
  
  def row_template(item)
    column "Name", item.name
    column "Value", item.value # Output exactly as-is
  end
end
Use when:
  • CSV is for data exchange between secure systems
  • CSV will never be opened in spreadsheet software
  • Exact byte-for-byte data integrity is required
Effect:
  • No modification of values
  • Perfect data integrity
  • Vulnerable to CSV injection if opened in spreadsheets

Examples

Basic CSV Export

class UsersCSV < Phlex::CSV
  def escape_csv_injection? = true
  
  def row_template(user)
    column "ID", user.id
    column "Name", user.name
    column "Email", user.email
    column "Role", user.role
  end
end

users = User.all
UsersCSV.new(users).call

Custom Delimiter (European Format)

class EuropeanReport < Phlex::CSV
  def escape_csv_injection? = true
  
  def delimiter
    ";" # Semicolon for European Excel
  end
  
  def row_template(item)
    column "Nom", item.name
    column "Prix", item.price
  end
end

Trimming Whitespace

class CleanCSV < Phlex::CSV
  def escape_csv_injection? = false
  
  private def trim_whitespace?
    true
  end
  
  def row_template(item)
    column "Name", item.name # " John " becomes "John"
  end
end

Without Headers

class DataOnlyCSV < Phlex::CSV
  def escape_csv_injection? = false
  
  private def render_headers?
    false
  end
  
  def row_template(item)
    column "Col1", item.a # Header not rendered
    column "Col2", item.b
  end
end

Custom Iteration

class FilteredCSV < Phlex::CSV
  def escape_csv_injection? = true
  
  private def each_item(&block)
    collection.select(&:published?).sort_by(&:created_at).each(&block)
  end
  
  def row_template(article)
    column "Title", article.title
    column "Date", article.published_at
  end
end

Transform Data with around_row

class SalesCSV < Phlex::CSV
  def escape_csv_injection? = true
  
  def around_row(sale)
    # Calculate totals before rendering
    total = sale.quantity * sale.unit_price
    tax = total * 0.2
    
    super(sale, total, tax)
  end
  
  def row_template(sale, total, tax)
    column "Product", sale.product_name
    column "Quantity", sale.quantity
    column "Unit Price", sale.unit_price
    column "Total", total
    column "Tax", tax
  end
end

Dynamic Filename

class MonthlyReport < Phlex::CSV
  def initialize(collection, month:)
    super(collection)
    @month = month
  end
  
  def filename
    "report-#{@month.strftime('%Y-%m')}.csv"
  end
  
  def escape_csv_injection? = true
  
  def row_template(record)
    column "Date", record.date
    column "Amount", record.amount
  end
end

Multiple Rows Per Item

class DetailedOrdersCSV < Phlex::CSV
  def escape_csv_injection? = true
  
  def around_row(order)
    # Output multiple rows for each order
    order.line_items.each do |item|
      super(order.id, order.customer_name, item.product, item.quantity)
    end
  end
  
  def row_template(order_id, customer, product, quantity)
    column "Order ID", order_id
    column "Customer", customer
    column "Product", product
    column "Quantity", quantity
  end
end

See Also

Build docs developers (and LLMs) love