class CostQuery::SqlStatement class Union attr_accessor :first, :second, :as def initialize(first, second, as = nil) @first, @second, @as = first, second, as end def to_s "((#{first}) UNION (#{second}))#{" AS #{as}" if as}" end def each_subselect yield first yield second end def gsub(*args, &block) to_s.gsub(*args, &block) end end include CostQuery::QueryUtils COMMON_FIELDS = %w[ user_id project_id issue_id rate_id comments spent_on created_on updated_on tyear tmonth tweek costs overridden_costs ] ## # Generates new SqlStatement. # # @param [String, #to_s] table Table name (or subselect) for from part. def initialize(table) from table end ## # Generates SqlStatement that maps time_entries and cost_entries to a common structure. # # Mapping for direct fields: # # Result | Time Entires | Cost entries # --------------------------|--------------------------|-------------------------- # id | id | id # user_id | user_id | user_id # project_id | project_id | project_id # issue_id | issue_id | issue_id # rate_id | rate_id | rate_id # comments | comments | comments # spent_on | spent_on | spent_on # created_on | created_on | created_on # updated_on | updated_on | updated_on # tyear | tyear | tyear # tmonth | tmonth | tmonth # tweek | tweek | tweek # costs | costs | costs # overridden_costs | overridden_costs | overridden_costs # units | hours | units # activity_id | activity_id | -1 # cost_type_id | -1 | cost_type_id # type | "TimeEntry" | "CostEntry" # count | 1 | 1 # # Also: This _should_ handle joining activities and cost_types, as the logic differs for time_entries # and cost_entries. # # @param [#table_name] model The model to map # @return [CostQuery::SqlStatement] Generated statement def self.unified_entry(model) table = table_name_for model new(table).tap do |query| query.select COMMON_FIELDS query.select({ :type => "'#{model.model_name}'", :count => 1, :id => [model, :id], :real_costs => switch("#{table}.overridden_costs IS NULL" => [model, :costs], :else => [model, :overridden_costs]), :week => iso_year_week(:spent_on, model) }) send("unify_#{table}", query) end end ## # Applies logic for mapping time entries to general entries structure. # # @param [CostQuery::SqlStatement] query The statement to adjust def self.unify_time_entries(query) query.select :activity_id, :units => :hours, :cost_type_id => -1 query.select :cost_type => quoted_label(:caption_labor) end ## # Applies logic for mapping cost entries to general entries structure. # # @param [CostQuery::SqlStatement] query The statement to adjust def self.unify_cost_entries(query) query.select :units, :cost_type_id, :activity_id => -1 query.select :cost_type => "cost_types.name" query.join CostType end ## # Generates a statement based on all entries (i.e. time entries and cost entries) mapped to the general entries structure, # and therefore usable by filters and such. # # @return [CostQuery::SqlStatement] Generated statement def self.for_entries new unified_entry(TimeEntry).union(unified_entry(CostEntry), "entries") end ## # Creates a uninon of the caller and the callee. # # @param [CostQuery::SqlStatement] other Second part of the union # @return [String] The sql query. def union(other, as = nil) Union.new(self, other, as) end ## # Adds sum(..) part to select. # # @param [#to_s] field Name of the field to aggregate on # @param [#to_s] name Name of the result (defaults to sum) def sum(field, name = :sum, type = :sum) @sql = nil return sum({ name => field }, nil, type) unless field.respond_to? :to_hash field.each { |k,v| field[k] = "#{type}(#{v})" } select field end ## # Adds count(..) part to select. # # @param [#to_s] field Name of the field to aggregate on (defaults to *) # @param [#to_s] name Name of the result (defaults to sum) def count(field = "*", name = :count) sum field, name, :count end ## # Generates the SQL query. # Code looks ugly in exchange for pretty output (so one does unterstand those). # # @return [String] The query def to_s # FIXME I'm ugly @sql ||= begin sql = "\nSELECT\n#{select.map { |e| "\t#{e}" }.join ",\n"}" \ "\nFROM\n\t#{from.gsub("\n", "\n\t")}" \ "\n#{joins.map { |e| "\t#{e}" }.join "\n"}" \ "\nWHERE #{where.join " AND "}\n" sql << "GROUP BY #{group_by.join ', '}\nORDER BY #{group_by.join ', '}\n" if group_by? sql # << " LIMIT 100" end end ## # @overload from # Reads the from part. # @return [#to_s] From part # @overload from(table) # Sets the from part. # @param [#to_s] table # @param [#to_s] From part def from(table = nil) return @from unless table @sql = nil @from = table end ## # Where conditions. Will be joined together by AND. # # @overload where # Reads the where part # @return [Array<#to_s>] Where clauses # @overload where(fields) # Adds condition to where clause # @param [Array, Hash, String] fields Parameters passed to sanitize_sql_for_conditions. # @see CostQuery::QueryUtils#sanitize_sql_for_conditions def where(fields = nil) @where ||= ["1=1"] unless fields.nil? @where << sanitize_sql_for_conditions(fields) @sql = nil end @where end ## # @return [Array] List of table joins def joins (@joins ||= []).tap { |j| j.uniq! } end ## # Adds an "left outer join" (guessing field names) to #joins. # # @overload join(name) # @param [Symbol, String] name Singular table name to join with, will join plural from on table.id = table_id # @overload join(model) # @param [#table_name, #model_name] model ActiveRecord model to join with # @overload join(hash) # @param [Hash<#to_s => #to_s>] hash Key is singular table name to join with, value is field to join on # @overload join(*list) # @param [Array] list Will generate join entries (according to guessings described above) # @see #joins def join(*list) @sql = nil join_syntax = "LEFT OUTER JOIN %1$s ON %1$s.id = %2$s_id" list.each do |e| case e when Class then joins << (join_syntax % [table_name_for(e), e.model_name.underscore]) when Symbol, String then joins << (join_syntax % [table_name_for(e), e]) when Hash then e.each { |k,v| joins << (join_syntax % [table_name_for(k), field_name_for(v)]) } when Array then join(*e) else raise ArgumentError, "cannot join #{e.inspect}" end end end def default_select(*fields) return(@default_select || []) if fields.empty? @default_select ||= [] @select, select_was = @default_select, @select select(*fields) @select = select_was end ## # @overload select # @return [Array] All fields/statements for select part # # @overload select(*fields) # Adds fields to select query. # @example # SqlStatement.new.select(some_sql_statement) # [some_sql_statement.to_s] # SqlStatement.new.select("sum(foo)") # ["sum(foo)"] # SqlStatement.new.select(:a).select(:b) # ["a", "b"] # SqlStatement.new.select(:bar => :foo) # ["foo as bar"] # SqlStatement.new.select(:bar => nil) # ["NULL as bar"] # @param [Array, Hash, String, Symbol, SqlStatement] fields Fields to add to select part # @return [Array] All fields/statements for select part def select(*fields) return (@select || ["*"]) + default_select if fields.empty? returning(@select ||= []) do @sql = nil fields.each do |f| case f when Array if f.size == 2 and f.first.respond_to? :table_name then select field_name_for(f) else select(*f) end when Hash then select f.map { |k,v| "#{field_name_for v} as #{field_name_for k}" } when String, Symbol then @select << field_name_for(f) when CostQuery::SqlStatement then @select << f.to_s else raise ArgumentError, "cannot handle #{f.inspect}" end end # when doing a union in sql, both subselects must have the same order. # by sorting here we never ever have to worry about this again, sucker! @select = @select.uniq.sort_by { |x| x.split(" as ").last } end end ## # @overload group_by # @return [Array] All fields/statements for group by part # # @overload group(*fields) # Adds fields to group by query # @param [Array, String, Symbol] fields Fields to add def group_by(*fields) @sql = nil unless fields.empty? returning(@group_by ||= []) do fields.each do |e| if e.is_a? Array and (e.size != 2 or !e.first.respond_to? :table_name) group_by(*e) else @group_by << field_name_for(e) end end end end ## # @return [TrueClass, FalseClass] Whether or not to add a group by part. def group_by? !group_by.empty? end def inspect "#" end end