module MakeExportable #:nodoc:
# Inventory of the exportable classes
mattr_accessor :exportable_classes
@@exportable_classes = {}
# Inventory of the exportable formats
mattr_accessor :exportable_formats
@@exportable_formats = {}
def self.included(target)
target.extend(ActiveRecordBaseMethods)
end
module ActiveRecordBaseMethods
# exportable? returns false for all ActiveRecord classes
# until make_exportable has been called on them.
def exportable?(format=nil)
return false
end
protected
# make_exportable is an ActiveRecord method that, when called, add
# methods to a particular class to make exporting data from that class easier.
#
# Example:
#
# class Customer < ActiveRecord::Base
# make_exportable
# end
# Customer.to_export(:csv)
#
# An optional hash of options can be passed as an argument to establish the default
# export parameters.
#
# These options include:
# * :only and :except - specify columns or methods to export (defaults to all columns)
# * :as - specify formats to allow for exporting (defaults to all formats)
# * :scopes - specify scopes to be called on the class before exporting
# * find options - for Rails 2.3 and earlier compatibility, standard find options
# are supported (:conditions, :order, :limit, :offset, etc.). These will be deprecated
# and removed in future versions.
#
# Examples:
#
# class Customer < ActiveRecord::Base
# make_exportable :only => [:id, :username, :full_name]
# end
#
# class Customer < ActiveRecord::Base
# make_exportable :except => [:id, :password], :scopes => [:new_signups, :with_referals],
# :as => [:csv, :tsv, :xls]
# end
#
# class Customer < ActiveRecord::Base
# make_exportable :conditions => {:active => true}, :order => 'last_name ASC, first_name ASC',
# :as => [:json, :html, :xml]
# end
#
def make_exportable(options={})
return unless self.table_exists?
# register the class as exportable
MakeExportable.exportable_classes[self.name] = self
# remove any invalid options
valid_options = [:as, :only, :except, :scopes, :conditions, :order, :include,
:group, :having, :limit, :offset, :joins]
options.slice!(*valid_options)
# Determine the exportable formats, default to all registered formats
options[:formats] = MakeExportable.exportable_formats.keys
if format_options = options.delete(:as)
options[:formats] = options[:formats] & Array.wrap(format_options).map(&:to_sym)
end
# Handle case when :as option was sent, but with no valid formats
if options[:formats].blank?
valid_formats = MakeExportable.exportable_formats.keys.map {|f| ":#{f}"}
raise MakeExportable::FormatNotFound.new("No valid export formats. Use: #{valid_formats.join(', ')}")
end
# Determine the exportable columns, default to all columns and then
# remove columns using the :only and :except options
options[:columns] = column_names.map(&:to_sym)
if only_options = options.delete(:only)
options[:columns] = Array.wrap(only_options).map(&:to_sym)
end
if except_options = options.delete(:except)
options[:columns] = options[:columns] - Array.wrap(except_options).map(&:to_sym)
end
options[:scopes] ||= []
# exportable options will be :formats, :columns, :scopes & find options
class_attribute :exportable_options
self.exportable_options = options
extend MakeExportable::ClassMethods
include MakeExportable::InstanceMethods
end
end
module ClassMethods
# exportable? returns true if the class has called "make_exportable".
# This is overriding the default :exportable? in ActiveRecord::Base which
# always returns false.
# If a format is passed as an argument, returns true only if the format is
# allowed for this class.
def exportable?(format=nil)
return exportable_options[:formats].include?(format.to_sym) if format
return true
end
# to_export exports records from a class. It can be called
# directly on an ActiveRecord class, but it can also be called on an ActiveRelation scope.
# It takes two arguments: a format (required) and a hash of options (optional).
#
# The options include:
# * :only and :except - specify columns or methods to export
# * :scopes - specify scopes to be called on the class before exporting
# * find options - for Rails 2.3 and earlier compatibility, standard find options
# are supported (:conditions, :order, :limit, :offset, etc.). These will be deprecated
# and removed in future versions.
# * :headers - supply an array of custom headers for the columns of exported attributes,
# the sizes of the header array and the exported columns must be equal.
#
# Examples:
#
# User.to_export(:xml, :only => [:first_name, :last_name, :username],
# :order => 'users.last_name ASC')
#
# User.visible.sorted_by_username.to_export('csv',
# :only => [:first_name, :last_name, :username])
#
def to_export(format, options={})
export_data = get_export_data(options)
# remove the auto-headers from the export_data (i.e. the first row)
auto_headers = export_data.shift
# Use auto-headers unless given alternates or false (use no headers)
options[:headers] = auto_headers unless !options[:headers].blank? || options[:headers] === false
export_string = create_report(format, export_data, :headers => options[:headers])
return export_string
end
# get_export_data finds records for export using a combination of the default
# export options and the argument options, and returns an array of arrays representing
# the rows and columns of the export data. The first item ("row") in the array will be
# an array of strings to be used as column headers.
# Valid options include :only, :except, :scopes and the standard find options.
# See to_export for more details on the options.
#
# Example:
#
# User.get_export_data(:only => [:first_name, :last_name, :username])
# # => [['first_name', 'last_name', 'username'], ['John', 'Doe', 'johndoe'], ['Joe', 'Smith', 'jsmith']] }
#
def get_export_data(options={})
column_options = options.slice(:only, :except)
records = find_export_data(options)
export_data = map_export_data(records, column_options)
return export_data
end
# create_report creates a report from a set of data. It takes three arguments:
# a format, the data set to use for the report, and an optional hash of options.
# The only meaningful option is :headers which sets the strings to be used as column
# headers for the data set. The value of :headers can be:
# * true - headers are the first row in the data set
# * false - headers are not in the data set and should not be added
# * array of strings to use for the column headers
#
# The length of the headers must match the length of each row in the data set.
def create_report(format, data_set, options={})
if options[:headers] === true
options[:headers] = data_set.shift
end
validate_export_format(format)
validate_export_data_lengths(data_set, options[:headers])
format_class = MakeExportable.exportable_formats[format.to_sym]
formater = format_class.new(data_set, options[:headers])
return formater.generate, formater.mime_type
end
private
# method_missing allows the class to accept dynamically named methods
# such as: SomeClass.to_xls_export(), SomeClass.create_csv_report()
def method_missing(method_id, *arguments)
possible_formats = MakeExportable.exportable_formats.keys.map(&:to_s).join('|')
if match = /^create_(#{possible_formats})_report$/.match(method_id.to_s)
format = match.captures.first
self.create_report(format, *arguments)
elsif match = /^to_(#{possible_formats})_export$/.match(method_id.to_s)
format = match.captures.first
self.to_export(format, *arguments)
else
super
end
end
# find_export_data finds all objects of a given
# class using a combination of the default export options and the options passed in.
# Valid options include :scopes and the standard find options. It returns a collection of
# objects matching the find criteria.
# See to_export for more details on the options.
def find_export_data(options={})
# merge with defaults then pull out the supported find and scope options
merged_options = options.reverse_merge(exportable_options)
find_options = merged_options.slice(:conditions, :order, :include, :group, :having, :limit, :offset, :joins)
scope_options = merged_options.slice(:scopes)
# apply scopes and then find options
collection = self
scope_options[:scopes].each do |scope|
collection = collection.send(scope)
end
# For Rails 2.3 compatibility
if ActiveRecord::VERSION::MAJOR < 3
collection = collection.find(:all, find_options)
else
# they should not be sending find options anymore, so we don't support them
collection = collection.all
end
return collection
end
# map_export_data takes a collection and outputs an array of arrays representing
# the rows and columns of the export data. The first item ("row") in the array will be
# an array of strings to be used as column headers.
# Valid options include :only and :except.
# See to_export for more details on the options.
#
# User.map_export_data(User.visible, :only => [:first_name, :last_name, :username])
# # => [['first_name', 'last_name', 'username'], ['John', 'Doe', 'johndoe'], ...]
#
def map_export_data(collection, options={})
# Use :only and :except options or else use class defaults for columns.
if !options[:only].blank?
options[:columns] = Array.wrap(options[:only]).map(&:to_sym)
elsif !options[:except].blank?
options[:columns] = column_names.map(&:to_sym) - Array.wrap(options[:except]).map(&:to_sym)
else
options[:columns] = exportable_options[:columns]
end
if options[:columns].empty?
raise MakeExportable::ExportFault.new("You are not exporting anything")
end
# TODO: Go ahead and humanize/titleize the column names here
headers = options[:columns].map(&:to_s)
rows = collection.map do |item|
options[:columns].map {|col| item.export_attribute(col) }
end
return rows.unshift(headers)
end
# validate_export_format ensures that the requested export format is valid.
def validate_export_format(format)
unless MakeExportable.exportable_formats.keys.include?(format.to_sym)
raise MakeExportable::FormatNotFound.new("#{format} is not a supported format.")
end
unless exportable_options[:formats].include?(format.to_sym)
raise MakeExportable::FormatNotFound.new("#{format} format is not allowed on this class.")
end
end
# validate_export_data_lengths ensures that the headers and all data rows are of the
# same size. (This is an important data integrity check if you are using NoSQL.)
def validate_export_data_lengths(data_set, data_headers=nil)
row_length = !data_headers.blank? ? data_headers.size : data_set[0].size
if data_set.any? {|row| row_length != row.size }
raise MakeExportable::ExportFault.new("Headers and all rows in the data set must be the same size.")
end
end
end
module InstanceMethods
# export_attribute returns the export value of an attribute or method.
# By default, this is simply the value of the attribute or method itself,
# but the value can be permanently overridden with another value by defining
# a method called "#{attribute}_export". The alternate method will *always*
# be called in place of the original one. At a minimum, this is useful
# when a date should be formatted when exporting or when booleans should
# always export as "Yes"/"No". But it can do more, performing any amount of
# processing or additional queries, as long as in the end it returns a value
# for the export to use.
# Sending an attribute name that does not exist will return an empty string.
def export_attribute(attribute)
begin
if self.respond_to?("#{attribute}_export")
return self.send("#{attribute}_export").to_s
else
return self.send(attribute).to_s
end
rescue
return ""
end
end
end
end