module Saulabs
module Reportable
# The Report class that does all the data retrieval and calculations.
#
class Report
# the model the report works on (This is the class you invoke {Saulabs::Reportable::ClassMethods#reportable} on)
#
attr_reader :klass
# the name of the report (as in {Saulabs::Reportable::ClassMethods#reportable})
#
attr_reader :name
# the name of the date column over that the records are aggregated
#
attr_reader :date_column
# the name of the column that holds the values to aggregate when using a calculation aggregation like +:sum+
#
attr_reader :value_column
# the aggregation to use (one of +:count+, +:sum+, +:minimum+, +:maximum+ or +:average+); when using anything other than +:count+, +:value_column+ must also be specified
#
attr_reader :aggregation
# options for the report
#
attr_reader :options
# Initializes a new {Saulabs::Reportable::Report}
#
# @param [Class] klass
# the model the report works on (This is the class you invoke {Saulabs::Reportable::ClassMethods#reportable} on)
# @param [String] name
# the name of the report (as in {Saulabs::Reportable::ClassMethods#reportable})
# @param [Hash] options
# options for the report creation
#
# @option options [Symbol] :date_column (created_at)
# the name of the date column over that the records are aggregated
# @option options [String, Symbol] :value_column (:id)
# the name of the column that holds the values to aggregate when using a calculation aggregation like +:sum+
# @option options [Symbol] :aggregation (:count)
# the aggregation to use (one of +:count+, +:sum+, +:minimum+, +:maximum+ or +:average+); when using anything other than +:count+, +:value_column+ must also be specified
# @option options [Symbol] :grouping (:day)
# the period records are grouped in (+:hour+, +:day+, +:week+, +:month+); Beware that reportable treats weeks as starting on monday!
# @option options [Fixnum] :limit (100)
# the number of reporting periods to get (see +:grouping+)
# @option options [Hash] :conditions ({})
# conditions like in +ActiveRecord::Base#find+; only records that match these conditions are reported;
# @option options [Boolean] :live_data (false)
# specifies whether data for the current reporting period is to be read; if +:live_data+ is +true+, you will experience a performance hit since the request cannot be satisfied from the cache alone
# @option options [DateTime, Boolean] :end_date (false)
# when specified, the report will only include data for the +:limit+ reporting periods until this date.
#
def initialize(klass, name, options = {})
ensure_valid_options(options)
@klass = klass
@name = name
@date_column = (options[:date_column] || 'created_at').to_s
@aggregation = options[:aggregation] || :count
@value_column = (options[:value_column] || (@aggregation == :count ? 'id' : name)).to_s
@options = {
:limit => options[:limit] || 100,
:conditions => options[:conditions] || [],
:grouping => Grouping.new(options[:grouping] || :day),
:live_data => options[:live_data] || false,
:end_date => options[:end_date] || false
}
@options.merge!(options)
@options.freeze
end
# Runs the report and returns an array of array of DateTimes and Floats
#
# @param [Hash] options
# options to run the report with
#
# @option options [Symbol] :grouping (:day)
# the period records are grouped in (+:hour+, +:day+, +:week+, +:month+); Beware that reportable treats weeks as starting on monday!
# @option options [Fixnum] :limit (100)
# the number of reporting periods to get (see +:grouping+)
# @option options [Hash] :conditions ({})
# conditions like in +ActiveRecord::Base#find+; only records that match these conditions are reported;
# @option options [Boolean] :live_data (false)
# specifies whether data for the current reporting period is to be read; if +:live_data+ is +true+, you will experience a performance hit since the request cannot be satisfied from the cache alone
# @option options [DateTime, Boolean] :end_date (false)
# when specified, the report will only include data for the +:limit+ reporting periods until this date.
#
# @return [Array>]
# the result of the report as pairs of {DateTime}s and {Float}s
#
def run(options = {})
options = options_for_run(options)
ReportCache.process(self, options) do |begin_at, end_at|
read_data(begin_at, end_at, options)
end
end
private
def options_for_run(options = {})
options = options.dup
ensure_valid_options(options, :run)
options.reverse_merge!(@options)
options[:grouping] = Grouping.new(options[:grouping]) unless options[:grouping].is_a?(Grouping)
return options
end
def read_data(begin_at, end_at, options)
conditions = setup_conditions(begin_at, end_at, options[:conditions])
@klass.send(@aggregation,
@value_column,
:conditions => conditions,
:group => options[:grouping].to_sql(@date_column),
:order => "#{options[:grouping].to_sql(@date_column)} ASC",
:limit => options[:limit]
)
end
def setup_conditions(begin_at, end_at, custom_conditions = [])
conditions = [@klass.send(:sanitize_sql_for_conditions, custom_conditions) || '']
conditions[0] += "#{(conditions[0].blank? ? '' : ' AND ')}#{ActiveRecord::Base.connection.quote_table_name(@klass.table_name)}.#{ActiveRecord::Base.connection.quote_column_name(@date_column.to_s)} "
conditions[0] += if begin_at && end_at
'BETWEEN ? AND ?'
elsif begin_at
'>= ?'
elsif end_at
'<= ?'
else
raise ArgumentError.new('You must pass either begin_at, end_at or both to setup_conditions.')
end
conditions << begin_at if begin_at
conditions << end_at if end_at
conditions
end
def ensure_valid_options(options, context = :initialize)
case context
when :initialize
options.each_key do |k|
raise ArgumentError.new("Invalid option #{k}!") unless [:limit, :aggregation, :grouping, :date_column, :value_column, :conditions, :live_data, :end_date].include?(k)
end
raise ArgumentError.new("Invalid aggregation #{options[:aggregation]}!") if options[:aggregation] && ![:count, :sum, :maximum, :minimum, :average].include?(options[:aggregation])
raise ArgumentError.new('The name of the column holding the value to sum has to be specified for aggregation :sum!') if [:sum, :maximum, :minimum, :average].include?(options[:aggregation]) && !options.key?(:value_column)
when :run
options.each_key do |k|
raise ArgumentError.new("Invalid option #{k}!") unless [:limit, :conditions, :grouping, :live_data, :end_date].include?(k)
end
end
raise ArgumentError.new('Options :live_data and :end_date may not both be specified!') if options[:live_data] && options[:end_date]
raise ArgumentError.new("Invalid grouping #{options[:grouping]}!") if options[:grouping] && ![:hour, :day, :week, :month].include?(options[:grouping])
raise ArgumentError.new("Invalid conditions: #{options[:conditions].inspect}!") if options[:conditions] && !options[:conditions].is_a?(Array) && !options[:conditions].is_a?(Hash)
raise ArgumentError.new("Invalid end date: #{options[:end_date].inspect}; must be a DateTime!") if options[:end_date] && !options[:end_date].is_a?(DateTime) && !options[:end_date].is_a?(Time)
raise ArgumentError.new('End date may not be in the future!') if options[:end_date] && options[:end_date] > DateTime.now
end
end
end
end