Skip to content

Commit

Permalink
Phlex::CSV (#655)
Browse files Browse the repository at this point in the history
In addition to HTML and SVG, Phlex will soon support CSVs.

This is all based on @willcosgrove’s explorations here
https://gist.github.com/willcosgrove/004389a5a053bccb35016223317cb9e9

---------

Co-authored-by: Will Cosgrove <[email protected]>
  • Loading branch information
joeldrapper and willcosgrove authored Feb 18, 2024
1 parent 5db2737 commit 8bd2c7d
Show file tree
Hide file tree
Showing 6 changed files with 191 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ Style/MixinUsage:
Style/RedundantDoubleSplatHashBraces:
Enabled: false

Style/OptionalArguments:
Enabled: false

Naming/AsciiIdentifiers:
Enabled: false

Expand Down
2 changes: 2 additions & 0 deletions lib/phlex.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ module Phlex
autoload :Unbuffered, "phlex/unbuffered"
autoload :ConcurrentMap, "phlex/concurrent_map"
autoload :BlackHole, "phlex/black_hole"
autoload :CSV, "phlex/csv"
autoload :Callable, "phlex/callable"

# Included in all Phlex exceptions allowing you to match any Phlex error.
# @example Rescue any Phlex error:
Expand Down
93 changes: 93 additions & 0 deletions lib/phlex/csv.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# frozen_string_literal: true

class Phlex::CSV
include Phlex::Callable

def initialize(collection)
@collection = collection
@_headers = []
@_current_row = []
@_current_column_index = 0
@_view_context = nil
@_first = true
end

attr_reader :collection

def call(buffer = +"", view_context: nil)
@_view_context = view_context

each_item do |record|
collection_yielder(record) do |*args, **kwargs|
view_template(*args, **kwargs)

if @_first && render_headers?
buffer << @_headers.map! { |value| escape(value) }.join(",") << "\n"
end

buffer << @_current_row.map! { |value| escape(value) }.join(",") << "\n"
@_current_column_index = 0
@_current_row.clear
end

@_first = false
end

buffer
end

def file_name
nil
end

def content_type
"text/csv"
end

private

def column(header = nil, value)
if @_first
@_headers << header
elsif header != @_headers[@_current_column_index]
raise "Inconsistent header."
end

@_current_row << value
@_current_column_index += 1
end

def each_item(&block)
collection.each(&block)
end

def collection_yielder(record)
yield(record)
end

def template(...)
nil
end

def render_headers?
true
end

def helpers
@_view_context
end

def render(renderable)
renderable.call(view_context: @_view_context)
end

def escape(value)
value = value.to_s

if value.include?('"') || value.include?(",") || value.include?("\n")
%("#{value.gsub('"', '""')}")
else
value
end
end
end
8 changes: 8 additions & 0 deletions lib/phlex/html.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ def unbuffered
self.class.__unbuffered_class__.new(self)
end

def file_name
nil
end

def content_type
"text/html"
end

# This should be extended after all method definitions
extend ElementClobberingGuard
end
Expand Down
8 changes: 8 additions & 0 deletions lib/phlex/svg.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,13 @@ class SVG < SGML

# This should be extended after all method definitions
extend ElementClobberingGuard

def content_type
"image/svg+xml"
end

def file_name
nil
end
end
end
77 changes: 77 additions & 0 deletions test/phlex/csv.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# frozen_string_literal: true

Product = Struct.new(:name, :price)

class Example < Phlex::CSV
def view_template(product)
column("name", product.name)
column("price", product.price)
end
end

class ExampleWithoutHeaders < Example
def render_headers?
false
end
end

describe Phlex::CSV do
it "renders a CSV" do
products = [
Product.new("Apple", 1.00),
Product.new("Banana", 2.00)
]

csv = Example.new(products).call

expect(csv).to be == <<~CSV
name,price
Apple,1.0
Banana,2.0
CSV
end

it "escapes commas" do
product = Product.new("Apple, Inc.", 1.00)
csv = Example.new([product]).call

expect(csv).to be == <<~CSV
name,price
"Apple, Inc.",1.0
CSV
end

it "escapes newlines" do
product = Product.new("Apple\nInc.", 1.00)
csv = Example.new([product]).call

expect(csv).to be == <<~CSV
name,price
"Apple\nInc.",1.0
CSV
end

it "escapes quotes" do
product = Product.new("Apple\"Inc.", 1.00)
csv = Example.new([product]).call

expect(csv).to be == <<~CSV
name,price
"Apple""Inc.",1.0
CSV
end

it "renders without headers" do
products = [
Product.new("Apple", 1.00),
Product.new("Banana", 2.00)
]

csv = ExampleWithoutHeaders.new(products).call

expect(csv).to be == <<~CSV
Apple,1.0
Banana,2.0
CSV
end
end

0 comments on commit 8bd2c7d

Please sign in to comment.