class CircularDependencyException < Exception; end # Super class for reportings to be rendered with Google visualizations # Encapsulates the aggregation of the reporting data based on a configuration. # # The configuration is implemented by inherting from +ActiveRecord+ and switching off # the database stuff. Thus the regular +ActiveRecord+ validators and parameter assignments # including type casting cab be used # # The ActiveRecord extension is copied from the ActiveForm plugin (http://github.com/remvee/active_form) class Reporting < ActiveRecord::Base attr_accessor :query, :group_by, :select, :order_by, :limit, :offset attr_reader :virtual_columns #, :required_columns attr_writer :id class_attribute :datasource_filters, :datasource_columns, :datasource_defaults, {:instance_reader => false, :instance_writer => false} def initialize(*args) @required_columns = [] @virtual_columns = {} super(*args) end # Returns an instance of our own connection adapter # def self.connection @adapter ||= ActiveRecord::ConnectionAdapters::ReportingAdapter.new end # Returns an ID which is used by frontend components to generate unique dom ids # Defaults to the underscoreized classname def id @id || self.class.name.underscore.split('/').last #gsub('/', '_') end # helper method used by required_columns # Returns all columns required by a certain column resolving the dependencies recursively def required_columns_for(column, start = nil) return [] unless self.datasource_columns.has_key?(column) raise CircularDependencyException.new("Column #{start} has a circular dependency") if column.to_sym == start columns = [ self.datasource_columns[column][:requires] ].flatten.compact.collect(&:to_s) columns.collect { |c| [c, required_columns_for(c, start || column.to_sym)] }.flatten end # Returns the columns that have to be selected def required_columns (select + group_by + @required_columns).inject([]) do |columns, column| columns << required_columns_for(column) columns << column end.flatten.map(&:to_s).uniq end # Adds required columns def add_required_columns(*columns) @required_columns = (@required_columns + columns.flatten.collect(&:to_s)).uniq end # 'Abstract' method that has to be overridden by subclasses # Returns an array of rows written to #rows and accessible in DataSource compatible format in #rows_as_datasource # def aggregate [] end # Returns the data rows # Calls the aggregate method first if rows do not exist # def data(options = {}) add_required_columns(options[:required_columns]) @rows ||= aggregate end # Lazy getter for the columns object def columns select.inject([]) do |columns, column| columns << { :type => all_columns[column][:type] }.merge({ :id => column.to_s, :label => column_label(column) }) if all_columns.key?(column) columns end end # Retrieves the I18n translation of the column label def column_label(column, default = nil) return '' if column.blank? defaults = ['reportings.{{model}}.{{column}}', 'models.attributes.{{model}}.{{column}}'].collect do |scope| scope.gsub!('{{model}}', self.class.name.underscore.gsub('/', '.')) scope.gsub('{{column}}', column.to_s) end.collect(&:to_sym) defaults << column.to_s.humanize I18n.t(defaults.shift, :default => defaults) end # Returns the select columns as array def select (@select ||= (defaults[:select] || [])).collect { |c| c == '*' ? all_columns.keys : c }.flatten end # Returns the grouping columns as array def group_by @group_by ||= (defaults[:group_by] || []) end # add a virtual column def add_virtual_column(name, type = :string) virtual_columns[name.to_s] = { :type => type } end # Returns a list of all columns (real and virtual) def all_columns datasource_columns.merge(virtual_columns) end # Returns an array of columns which are allowed for grouping # def groupable_columns datasource_columns.collect do |key, options| key.to_sym if options[:grouping] end.compact end # Returns the +defaults+ Hash # Convenience wrapper for instance access def defaults self.class.defaults #.merge(@defaults || {}) end # Attribute reader for datasource_columns def datasource_columns self.class.datasource_columns || { }.freeze end # Returns a serialized representation of the reporting def serialize to_param.to_json end def to_param # :nodoc: attributes.merge({ :select => select, :group_by => group_by, :order_by => order_by, :limit => limit, :offset => offset }) end def limit (@limit || 1000).to_i end # Returns the serialized Reporting in a Hash that can be used for links # and which is deserialized by from_params def to_params(key = self.class.name.underscore.gsub('/', '_')) HashWithIndifferentAccess.new( key => to_param ) end # Returns true if the given column is available for filtering # def filterable_by?(column_name) self.class.datasource_filters.key?(column_name.to_s) end class << self # Defines a displayable column of the datasource # Type defaults to string def column(name, options = {}) self.datasource_columns ||= Hash.new.freeze self.datasource_columns = column_or_filter(name, options, self.datasource_columns) end # Marks a column as filterable / adds it in the background # # def filter(name, options = {}) self.datasource_filters ||= Hash.new.freeze # update the column options self.datasource_filters = column_or_filter(name, options, self.datasource_filters) end # Returns the defaults class variable def defaults self.datasource_defaults ||= Hash.new.freeze end # Sets the default value for select def select_default(select) self.datasource_defaults = defaults.merge({:select => select}) end # Sets the default value for group_by def group_by_default(group_by) self.datasource_defaults = defaults.merge({:group_by => group_by}) end # Returns a reporting from a serialized representation def deserialize(value) self.new(JSON.parse(value)) end # Uses the +simple_parse+ method of the SqlParser to setup a reporting # from a query. The where clause is intepreted as reporting configuration (activerecord attributes) def from_params(params, key = self.name.underscore.gsub('/', '_')) return self.deserialize(params[key]) if params.has_key?(key) && params[key].is_a?(String) reporting = self.new(params[key]) reporting = from_query_params(params["query"]) if params.has_key?("query") return reporting unless params.has_key?(:tq) query = GoogleDataSource::DataSource::SqlParser.simple_parse(params[:tq]) attributes = Hash.new query.conditions.each do |k, v| if v.is_a?(Array) v.each do |condition| case condition.op when '<=' attributes["to_#{k}"] = condition.value when '>=' attributes["from_#{k}"] = condition.value when 'in' attributes["in_#{k}"] = condition.value else # raise exception for unsupported operator? end end else attributes[k] = v end end attributes[:group_by] = query.groupby attributes[:select] = query.select attributes[:order_by] = query.orderby attributes[:limit] = query.limit attributes[:offset] = query.offset attributes.merge!(params[key]) if params.has_key?(key) #reporting.update_attributes(attributes) reporting.attributes = attributes reporting.query = params[:tq] reporting end ############################ # ActiveRecord overrides ############################ # Needed to build column accessors # def columns_hash @columns_hash ||= begin ret = { } data = { } data.update(self.datasource_columns) if self.datasource_columns data.update(self.datasource_filters) if self.datasource_filters data.each do |k, v| ret[k.to_s] = ActiveRecord::ConnectionAdapters::Column.new(k.to_s, v[:default], v[:type].to_s, v.key?(:null) ? v[:null] : true) # define a humanize method which returns the human name if v.key?(:human_name) ret[k.to_s].define_singleton_method(:humanize) do v[:human_name] end end end ret end end # Returns a hash with column name as key and default value as value # def column_defaults(*args) columns_hash.keys.inject({}) do |mem, var| mem[var] = columns_hash[var].default mem end end # TODO: merge with columns hash def columns columns_hash.values end def primary_key 'id' end def abstract_class # :nodoc: true end protected # Used to decorate a filter or column # used by #column and #filter internally # def column_or_filter(name, options, registry) name = name.to_s # whatever this is. # options.each { |k,v| options[k] = v.to_s if Symbol === v } default_options = { :type => :string } new_entry = { name => (registry[name] || {}) } new_entry[name].reverse_merge!(default_options.merge(options)) new_entry.freeze # frozen, to prevent modifications on class_attribute registry.merge(new_entry).freeze end def from_query_params(query) page = query.delete('page') reporting = self.new(query) reporting.limit = query['limit'] if page reporting.offset = reporting.limit * (page.to_i - 1) else reporting.offset = query['offset'].to_i end reporting end end ############################ # ActiveRecord overrides ############################ def save # :nodoc: if result = valid? end result end def save! # :nodoc: save or raise ActiveRecord::RecordInvalid.new(self) end end