kanbanworkflowstimelinescrumrubyroadmapproject-planningproject-managementopenprojectangularissue-trackerifcgantt-chartganttbug-trackerboardsbcf
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
297 lines
9.8 KiB
297 lines
9.8 KiB
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<String>] 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<String,Symbol,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<String>] 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<String>] 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<String>] 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
|
|
"#<SqlStatement: #{to_s.inspect}>"
|
|
end
|
|
|
|
end
|
|
|