opf/openproject

View on GitHub
modules/reporting/lib/report/chainable.rb

Summary

Maintainability
A
0 mins
Test Coverage
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

# Provides convenience layer and logic shared between GroupBy::Base and Filter::Base.
# Implements a double linked list (FIXME: is that the correct term?).
module Report
  class Chainable
    include Enumerable
    include Report::QueryUtils
    extend Report::InheritedAttribute
    extend Forwardable

    # this attr. should point to a symbol useable for translations
    inherited_attribute :applies_for, default: :label_cost_entry_attributes
    def_delegators :"self.class", :table_joins, :table_name, :field, :display?, :underscore_name

    def self.accepts_property(*list)
      engine.accepted_properties.push(*list.map(&:to_s))
    end

    def self.chain_list(*list)
      options = list.extract_options!
      options[:list] = true
      list << options
      inherited_attribute(*list)
    end

    def self.base?
      superclass == Report::Chainable or self == Report::Chainable or
        superclass == Chainable or self == Chainable or
        self == Report::Filter::Base or self == Report::GroupBy::Base
    end

    def self.base
      return self if base?

      superclass.base
    end

    def self.from_base(&)
      base.instance_eval(&)
    end

    def self.available
      from_base { @available ||= [] }
    end

    def self.register(label)
      available << klass
      set_inherited_attribute "label", label
    end

    def self.table_joins
      (@table_joins ||= []).clone
    end

    def self.table_from(value)
      return value.table_name if value.respond_to? :table_name
      return value unless value.respond_to? :to_ary or value.respond_to? :to_hash

      table_from value.to_a.first
    end

    def self.join_table(*args)
      @last_table = table_from(args.last)
      (@table_joins ||= []) << args
    end

    def self.underscore_name
      name.demodulize.underscore
    end

    def self.put_sql_table_names(table_prefix_placement = {})
      @table_prefix_placement ||= {}
      @table_prefix_placement.merge! table_prefix_placement
      @table_prefix_placement
    end

    ##
    # The given block is called when a new chain is created for a report.
    # The query will be given to the block as a parameter.
    # Example:
    # initialize_query_with { |query| query.filter Report::Filter::City, :operators => '=', :values => 'Berlin, da great City' }
    def self.initialize_query_with(&block)
      engine.chain_initializer.push block
    end

    def self.cache_key
      @cache_key ||= underscore_name
    end

    inherited_attribute :properties, list: true

    def self.label
      "Translation needed"
    end

    class << self
      alias inherited_attributes inherited_attribute
      alias accepts_properties accepts_property
    end

    attr_accessor :parent, :child, :type

    accepts_property :type

    def each(&)
      yield self
      child.try(:each, &)
    end

    def row?
      type == :row
    end

    def column?
      type == :column
    end

    def group_by?
      !filter?
    end

    def to_a
      [to_hash].tap { |a| a.unshift(*child.to_a) unless bottom? }
    end

    def top
      return self if top?

      parent.top
    end

    def top?
      parent.nil?
    end

    def bottom?
      child.nil?
    end

    def bottom
      return self if bottom?

      child.bottom
    end

    def initialize(child = nil, options = {})
      @options = options
      options.each do |key, value|
        unless self.class.extra_options.include? key
          raise ArgumentError, "may not set #{key}" unless engine.accepted_properties.include? key.to_s

          send :"#{key}=", value
        end
      end
      if child
        self.child = child
        child.parent = self
      end
      move_down until correct_position?
      clear
    end

    def compute_to_a
      [[self.class.field, @options], *child.try(:to_a)].compact
    end

    def to_s
      URI.escape to_a.map(&:join).join(",")
    end

    def serialize
      [self.class.to_s.demodulize, @options]
    end

    def move_down
      reorder parent, child, self, child.child
    end

    ##
    # Reorder given elements of a doubly linked list to follow the lists order.
    # Don't use this for evil. Assumes there are no elements inbetween, does
    # not touch the first element's parent and the last element's child.
    # Does not touch elements not part of the list.
    #
    # @param [Array] *list Part of the linked list
    def reorder(*list)
      list.each_with_index do |entry, index|
        next_entry = list[index + 1]
        entry.try(:child=, next_entry) if index < list.size - 1
        next_entry.try(:parent=, entry)
      end
    end

    def chain_collect(name, *, &)
      top.subchain_collect(name, *, &)
    end

    # See #chain_collect
    def subchain_collect(name, *, &)
      subchain = child.subchain_collect(name, *, &) unless bottom?
      [* send(name, *, &)].push(*subchain).compact.uniq
    end

    # overwrite in subclass to maintain constisten state
    # ie automatically turning
    #   FilterFoo.new(GroupByFoo.new(FilterBar.new))
    # into
    #   GroupByFoo.new(FilterFoo.new(FilterBar.new))
    # Returning false will make the
    def correct_position?
      true
    end

    def clear
      @cached = nil
      child.try :clear
    end

    def result
      cached(:compute_result)
    end

    def compute_result
      engine::Result.new engine.reporting_connection.select_all(sql_statement.to_s), {}, type
    end

    def cached(*args)
      @cached ||= {}
      @cached[args] ||= send(*args)
    end

    def sql_statement
      raise "should not get here (#{inspect})" if bottom?

      child.cached(:sql_statement).tap do |q|
        chain_collect(:table_joins).each { |args| q.join(*args) } if responsible_for_sql?
      end
    end

    inherited_attribute :db_field
    def self.field
      db_field || (name[/[^:]+$/] || name).to_s.underscore
    end

    def display?
      self.class.display?
    end

    inherited_attribute :display, default: true
    def self.display!
      display true
    end

    def self.display?
      !!display
    end

    def self.dont_display!
      display false
      not_selectable!
    end

    inherited_attribute :selectable, default: true
    def self.selectable!
      selectable true
    end

    def self.selectable?
      !!selectable
    end

    def self.not_selectable!
      selectable false
    end

    # Extra options this chainable accepts that are not defined in accepted_properties
    def self.extra_options(*symbols)
      @extra_option ||= []
      @extra_option += symbols
    end

    # This chainable type can only ever occur once in a chain
    def self.singleton
      class << self
        def new(chain = nil, options = {})
          return chain if chain and chain.map(&:class).include? self

          super
        end
      end
    end

    def self.last_table
      @last_table ||= engine::Filter::NoFilter.table_name
    end

    def self.table_name(value = nil)
      @table_name = table_name_for(value) if value
      @table_name || last_table
    end

    def with_table(fields)
      fields.map do |f|
        place_field_name = self.class.put_sql_table_names[f] || self.class.put_sql_table_names[f].nil?
        place_field_name ? field_name_for([self, f]) : f
      end
    end

    def mapping
      self.class.method(:mapping).to_proc
    end

    def self.mapping(value)
      value.to_s
    end

    def self.mapping_for(field)
      @field_map ||= (engine::Filter.all + engine.GroupBy.all).inject(Hash.new { |h, k| h[k] = [] }) do |hash, cbl|
        hash[cbl.field] << cbl.mapping
      end
      @field_map[field]
    end
  end
end