Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
86c96d3
Switch to require_relative
alexdunae Aug 6, 2022
c0fb717
Use IOString instead of a tempfile when generating XLSX
alexdunae Aug 6, 2022
001a54f
Add as_json from ActiveSupport
alexdunae Aug 6, 2022
c87fc87
Ignore *.xlsx
alexdunae Aug 6, 2022
b790bc5
Spike of data validation logic
alexdunae Aug 6, 2022
241aa44
Improve after integrating in the app
alexdunae Aug 6, 2022
8524059
Add docs
alexdunae Aug 9, 2022
1850288
Re-organize for clarity
alexdunae Aug 9, 2022
f75aa12
Add activesupport import
alexdunae Aug 9, 2022
0a07e35
Always use absolute references for data source lists
alexdunae Aug 9, 2022
00d7bc8
Update test suite
alexdunae Aug 9, 2022
879618d
Refactor to handle reusable validation data sources and named ranges
alexdunae Aug 29, 2022
5c3663f
Add logic for dependent pick lists
alexdunae Aug 30, 2022
bd2545a
Add HeaderCell to simplify humanizing names
alexdunae Aug 30, 2022
2832fb1
Docs
alexdunae Aug 30, 2022
383d4f6
Add freeze_panes option
alexdunae Aug 30, 2022
2cd0fe2
Add GeneratesSpreadsheet model concern
alexdunae Aug 30, 2022
2c5a327
Prevent having to write per-row dependent validations 🥳
alexdunae Aug 30, 2022
661d5a6
Tighten
alexdunae Aug 30, 2022
fc5d433
Add macros to source control
alexdunae Aug 30, 2022
1f31546
Use kwargs
alexdunae Sep 7, 2022
eb5285c
Docs
alexdunae Sep 7, 2022
a32e6c4
Docs
alexdunae Sep 7, 2022
fcff9f9
Move test script
alexdunae Sep 28, 2022
f1fc22a
Add docs to the macro
alexdunae Sep 28, 2022
e5e9a39
Remove dead code
alexdunae Sep 28, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ test/dummy/log/*.log
test/dummy/tmp/
test/dummy/.sass-cache
*.gem
*.xlsx
20 changes: 16 additions & 4 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
PATH
remote: .
specs:
spreadsheet_exporter (0.1.1)
spreadsheet_exporter (0.1.2)
activesupport (>= 6)
write_xlsx

GEM
remote: https://rubygems.org/
specs:
rubyzip (2.3.0)
write_xlsx (0.83.0)
activesupport (7.0.3.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
concurrent-ruby (1.1.10)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
minitest (5.16.2)
rubyzip (2.3.2)
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
write_xlsx (1.09.3)
rubyzip (>= 1.0.0)
zip-zip
zip-zip (0.3)
Expand All @@ -21,4 +33,4 @@ DEPENDENCIES
spreadsheet_exporter!

BUNDLED WITH
1.10.4
2.3.19
43 changes: 41 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ gem 'spreadsheet_exporter'

## Usage

Objects that are exported must respond to ```as_csv``` or ```as_json``` and return a hash
representing column names and values.
Objects that are exported must respond to `as_spreadsheet`, `as_csv` or `as_json` and return a hash
representing column names and values. In Rails you can `include SpreadsheetExporter::GeneratesSpreadsheet` into your model.


### CSV or XLSX
Output can be .csv or .xlsx. Choose by using SpreadsheetExporter::CSV or SpreadsheetExporter::XLSX modules.
Expand All @@ -28,3 +29,41 @@ that is actually comma-delimited, pass ```:col_sep => ','``` as an option when e
```ruby
SpreadsheetExporter::CSV.from_spreadsheet([["First Name", "Last Name"], ["Bob", "Hoskins"], ["Roger", "Rabbit"]])
```

### XLSX with Pick Lists

```ruby
# data sources are written to a `data` worksheet and may be referenced by
# multiple rows
data_sources = {
"food_types" => %w[Polenta Paella Papaya],
"countries" => %w[Canada Türkiye],
"cities" => {"Canada"=>["Sxwōxwiyám", "Toronto"], "Türkiye"=>["Eskişehir", "İzmir", "İstanbul"]}
}

validations = {
"favourite_food" => SpreadsheetExporter::ColumnValidation.new(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is my main question about code style. It's nice to have these structs for safety, etc... but they're kind of ugly. Would this be better as

validations = {
  "favourite_food" => { data_source: "food_types" }
}

?

data_source: "food_types"
),
"yuckiest_food" => SpreadsheetExporter::ColumnValidation.new(
data_source: "food_types"
),
"country" => SpreadsheetExporter::ColumnValidation.new(
data_source: "countries"
),
"city" => SpreadsheetExporter::ColumnValidation.new(
dependent_on: "country",
data_source: "cities"
),
}

SpreadsheetExporter::XLSX.from_objects(array_of_objects,
data_sources: data_sources,
validations: validations,
freeze_panes: [1, 0] # number of rows and columns to freeze (only applies to XLSX)
)
```

### Testing

There isn't currently a test suite. You can generate a sample test XLSX file by running `test/test.rb`.
12 changes: 10 additions & 2 deletions lib/spreadsheet_exporter.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
require 'spreadsheet_exporter/csv'
require 'spreadsheet_exporter/xlsx'
require_relative './spreadsheet_exporter/generates_spreadsheet'
require_relative './spreadsheet_exporter/column_validation'
require_relative './spreadsheet_exporter/csv'
require_relative './spreadsheet_exporter/xlsx'
require 'active_support'
require 'active_support/core_ext/object/json'
require 'active_support/core_ext/hash/reverse_merge'

module SpreadsheetExporter
VALIDATION_ERROR_TYPES = %w[stop warning information].freeze

begin
Mime::Type.register "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", :xlsx
rescue NameError
Expand Down
9 changes: 9 additions & 0 deletions lib/spreadsheet_exporter/column_validation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module SpreadsheetExporter
ColumnValidation = Struct.new(:ignore_blank, :data_source, :dependent_on, :error_type, keyword_init: true) do
def initialize(*)
super
self.ignore_blank = true if ignore_blank.nil?
self.error_type ||= VALIDATION_ERROR_TYPES[0]
end
end
end
10 changes: 5 additions & 5 deletions lib/spreadsheet_exporter/csv.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,19 @@ module SpreadsheetExporter
module CSV
BOM = "\377\376".force_encoding("utf-16le") # Byte Order Mark so Excel displays characters correctly

def self.from_objects(objects, options = {})
spreadsheet = Spreadsheet.from_objects(objects, options).compact
def self.from_objects(objects, humanize_headers_class: nil, **options)
spreadsheet = Spreadsheet.from_objects(objects, humanize_headers_class: humanize_headers_class, **options).compact
from_spreadsheet(spreadsheet)
end

def self.from_spreadsheet(spreadsheet, options = {})
output = ::CSV.generate(**options.reverse_merge(:encoding => 'UTF-8', :col_sep => "\t")) do |csv|
def self.from_spreadsheet(spreadsheet, encoding: 'UTF-8', col_sep: "\t", **options)
output = ::CSV.generate(encoding: encoding, col_sep: col_sep, **options) do |csv|
spreadsheet.each do |row|
csv << row
end
end

return BOM + output.encode!('utf-16le')
BOM + output.encode!('utf-16le')
end
end
end
11 changes: 11 additions & 0 deletions lib/spreadsheet_exporter/generates_spreadsheet.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
require "active_support/concern"

module SpreadsheetExporter
module GeneratesSpreadsheet
extend ActiveSupport::Concern

def as_spreadsheet(options = {})
serializable_hash(options)
end
end
end
43 changes: 31 additions & 12 deletions lib/spreadsheet_exporter/spreadsheet.rb
Original file line number Diff line number Diff line change
@@ -1,24 +1,43 @@
# TODO: Find out why we can't detect arrays properly and must resort to crappy class.name comparison
module SpreadsheetExporter
HeaderCell = Struct.new(:attribute_name, :human_attribute_name) do
def to_s
human_attribute_name.presence || attribute_name
end
end

module Spreadsheet
def self.from_objects(objects, options = {})
def self.from_objects(objects, humanize_headers_class: nil, **options)
headers = []
rows = []

# Get all the data and accumulate headers from each row (since rows may not have all the same attributes)
Array(objects).each do |object|
data = object.respond_to?(:as_csv) ? get_values(object.as_csv(options)) : get_values(object.as_json(options))
headers = headers | data.keys
data = if object.respond_to?(:as_spreadsheet)
get_values(object.as_spreadsheet(options))
elsif object.respond_to?(:as_csv)
get_values(object.as_csv(options))
else
get_values(object.as_json(options))
end

headers |= data.keys.map { |v| HeaderCell.new(v) }
rows << data
end

# Create the csv, ensuring to place each row's attributes under the appropriate header (since rows may not have all the same attributes)
[].tap do |spreadsheet|
spreadsheet << (options[:humanize_headers_class] ? han(options[:humanize_headers_class], headers) : headers)
if humanize_headers_class
headers = han(headers, humanize_headers_class: humanize_headers_class, **options)
end

spreadsheet << headers

rows.each do |row|
sorted_row = []
row.each do |header, value|
sorted_row[headers.index(header)] = value
col_index = headers.find_index { |h| h.attribute_name == header }
sorted_row[col_index] = value
end

spreadsheet << sorted_row
Expand All @@ -28,14 +47,14 @@ def self.from_objects(objects, options = {})

# Return an array of human_attribute_name's
# Used by the CSV Import/Export process to match CSV headers to model attribute names
def self.han(klass, *attributes)
options = attributes.extract_options!
def self.han(headers, humanize_headers_class:, downcase: false, **)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type for this use to be scalar or array but now is always an array. This change didn't break any other code that I could see

headers.flatten!

attributes.flatten!
attributes.collect! {|attribute| klass.human_attribute_name(attribute) }
attributes.collect!(&:downcase) if options[:downcase]

return attributes.many? ? attributes : attributes.first
headers.collect! do |header|
header.human_attribute_name = humanize_headers_class.human_attribute_name(header.attribute_name)
header.human_attribute_name.downcase! if downcase
header
end
end

def self.get_values(node, current_header = nil)
Expand Down
Loading