module Saulabs module Reportable # The +ReportCache+ class is a regular +ActiveRecord+ model and represents cached results for single {Saulabs::Reportable::ReportingPeriod}s. # +ReportCache+ instances are identified by the combination of +model_name+, +report_name+, +grouping+, +aggregation+ and +reporting_period+. # class ReportCache < ActiveRecord::Base set_table_name :reportable_cache self.skip_time_zone_conversion_for_attributes = [:reporting_period] # Clears the cache for the specified +klass+ and +report+ # # @param [Class] klass # the model the report to clear the cache for works on # @param [Symbol] report # the name of the report to clear the cache for # # @example Clearing the cache for a report # # class User < ActiveRecord::Base # reportable :registrations # end # # Saulabs::Reportable::ReportCache.clear_for(User, :registrations) # def self.clear_for(klass, report) self.delete_all(:conditions => { :model_name => klass.name, :report_name => report.to_s }) end # Processes the report using the respective cache. # # @param [Saulabe::Reportable::Report] report # the report to process # @param [Hash] options # options for the report # # @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 self.process(report, options, &block) raise ArgumentError.new('A block must be given') unless block_given? self.transaction do cached_data = read_cached_data(report, options) new_data = read_new_data(cached_data, options, &block) prepare_result(new_data, cached_data, report, options) end end private def self.prepare_result(new_data, cached_data, report, options) new_data = new_data.map { |data| [ReportingPeriod.from_db_string(options[:grouping], data[0]), data[1]] } cached_data.map! { |cached| [ReportingPeriod.new(options[:grouping], cached.reporting_period), cached.value] } current_reporting_period = ReportingPeriod.new(options[:grouping]) reporting_period = get_first_reporting_period(options) result = [] while reporting_period < (options[:end_date] ? ReportingPeriod.new(options[:grouping], options[:end_date]).next : current_reporting_period) if cached = cached_data.find { |cached| reporting_period == cached[0] } result << [cached[0].date_time, cached[1]] else new_cached = build_cached_data(report, options[:grouping], options[:conditions], reporting_period, find_value(new_data, reporting_period)) new_cached.save! result << [reporting_period.date_time, new_cached.value] end reporting_period = reporting_period.next end if options[:live_data] result << [current_reporting_period.date_time, find_value(new_data, current_reporting_period)] end result end def self.find_value(data, reporting_period) data = data.detect { |d| d[0] == reporting_period } data ? data[1] : 0.0 end def self.build_cached_data(report, grouping, conditions, reporting_period, value) self.new( :model_name => report.klass.to_s, :report_name => report.name.to_s, :grouping => grouping.identifier.to_s, :aggregation => report.aggregation.to_s, :conditions => conditions.to_s, :reporting_period => reporting_period.date_time, :value => value ) end def self.read_cached_data(report, options) conditions = [ %w(model_name report_name grouping aggregation conditions).map do |column_name| "#{self.connection.quote_column_name(column_name)} = ?" end.join(' AND '), report.klass.to_s, report.name.to_s, options[:grouping].identifier.to_s, report.aggregation.to_s, options[:conditions].to_s ] first_reporting_period = get_first_reporting_period(options) last_reporting_period = get_last_reporting_period(options) if last_reporting_period conditions.first << ' AND reporting_period BETWEEN ? AND ?' conditions << first_reporting_period.date_time conditions << last_reporting_period.date_time else conditions.first << ' AND reporting_period >= ?' conditions << first_reporting_period.date_time end self.all( :conditions => conditions, :limit => options[:limit], :order => 'reporting_period ASC' ) end def self.read_new_data(cached_data, options, &block) if !options[:live_data] && cached_data.length == options[:limit] [] else first_reporting_period_to_read = if cached_data.length < options[:limit] get_first_reporting_period(options) else ReportingPeriod.new(options[:grouping], cached_data.last.reporting_period).next end last_reporting_period_to_read = options[:end_date] ? ReportingPeriod.new(options[:grouping], options[:end_date]).last_date_time : nil yield(first_reporting_period_to_read.date_time, last_reporting_period_to_read) end end def self.get_first_reporting_period(options) if options[:end_date] ReportingPeriod.first(options[:grouping], options[:limit] - 1, options[:end_date]) else ReportingPeriod.first(options[:grouping], options[:limit]) end end def self.get_last_reporting_period(options) return ReportingPeriod.new(options[:grouping], options[:end_date]) if options[:end_date] end end end end