require "groupdate/version" require "active_record" module Groupdate extend ActiveSupport::Concern # Pattern from kaminari # https://github.com/amatsuda/kaminari/blob/master/lib/kaminari/models/active_record_extension.rb included do # Future subclasses will pick up the model extension class << self def inherited_with_groupdate(kls) #:nodoc: inherited_without_groupdate kls kls.send(:include, ClassMethods) if kls.superclass == ActiveRecord::Base end alias_method_chain :inherited, :groupdate end # Existing subclasses pick up the model extension as well self.descendants.each do |kls| kls.send(:include, ClassMethods) if kls.superclass == ActiveRecord::Base end end module ClassMethods extend ActiveSupport::Concern included do # Field list from # http://www.postgresql.org/docs/9.1/static/functions-datetime.html time_fields = %w(second minute hour day week month year) number_fields = %w(day_of_week hour_of_day) (time_fields + number_fields).each do |field| self.scope :"group_by_#{field}", lambda {|*args| column = connection.quote_table_name(args[0]) time_zone = args[1] || Time.zone || "Etc/UTC" if time_zone.is_a?(ActiveSupport::TimeZone) or time_zone = ActiveSupport::TimeZone[time_zone] time_zone = time_zone.tzinfo.name else raise "Unrecognized time zone" end query = case connection.adapter_name when "MySQL", "Mysql2" case field when "day_of_week" # Sunday = 0, Monday = 1, etc # use CONCAT for consistent return type (String) ["DAYOFWEEK(CONVERT_TZ(#{column}, '+00:00', ?)) - 1", time_zone] when "hour_of_day" ["EXTRACT(HOUR from CONVERT_TZ(#{column}, '+00:00', ?))", time_zone] when "week" ["CONVERT_TZ(DATE_FORMAT(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL (DAYOFWEEK(CONVERT_TZ(#{column}, '+00:00', ?)) - 1) DAY), '+00:00', ?), '%Y-%m-%d 00:00:00'), ?, '+00:00')", time_zone, time_zone, time_zone] else format = case field when "second" "%Y-%m-%d %H:%i:%S" when "minute" "%Y-%m-%d %H:%i:00" when "hour" "%Y-%m-%d %H:00:00" when "day" "%Y-%m-%d 00:00:00" when "month" "%Y-%m-01 00:00:00" else # year "%Y-01-01 00:00:00" end ["CONVERT_TZ(DATE_FORMAT(CONVERT_TZ(#{column}, '+00:00', ?), '#{format}'), ?, '+00:00')", time_zone, time_zone] end when "PostgreSQL" case field when "day_of_week" ["EXTRACT(DOW from #{column}::timestamptz AT TIME ZONE ?)", time_zone] when "hour_of_day" ["EXTRACT(HOUR from #{column}::timestamptz AT TIME ZONE ?)", time_zone] when "week" # start on Sunday, not PostgreSQL default Monday ["(DATE_TRUNC('#{field}', (#{column}::timestamptz + INTERVAL '1 day') AT TIME ZONE ?) - INTERVAL '1 day') AT TIME ZONE ?", time_zone, time_zone] else ["DATE_TRUNC('#{field}', #{column}::timestamptz AT TIME ZONE ?) AT TIME ZONE ?", time_zone, time_zone] end else raise "Connection adapter not supported: #{connection.adapter_name}" end if args[2] # zeros if time_fields.include?(field) range = args[2] unless range.is_a?(Range) raise "Expecting a range" end # determine start time time = range.first.in_time_zone(time_zone) starts_at = case field when "second" time.change(:usec => 0) when "minute" time.change(:sec => 0) when "hour" time.change(:min => 0) when "day" time.beginning_of_day when "week" time.beginning_of_week(:sunday) when "month" time.beginning_of_month else # year time.beginning_of_year end end derived_table = case connection.adapter_name when "PostgreSQL" case field when "day_of_week", "hour_of_day" max = field == "day_of_week" ? 6 : 23 "SELECT generate_series(0, #{max}, 1) AS #{field}" else ends_at = range.exclude_end? ? range.last - 1 : range.last sanitize_sql_array(["SELECT (generate_series(CAST(? AS timestamptz) AT TIME ZONE ?, ?, ?) AT TIME ZONE ?) AS #{field}", starts_at, time_zone, ends_at, "1 #{field}", time_zone]) end else # MySQL case field when "day_of_week", "hour_of_day" max = field == "day_of_week" ? 6 : 23 (0..max).map{|i| "SELECT #{i} AS #{field}" }.join(" UNION ") else series = [starts_at] step = 1.send(field) while range.cover?(series.last + step) series << series.last + step end sanitize_sql_array([series.map{|i| "SELECT CAST(? AS DATETIME) AS #{field}" }.join(" UNION ")] + series) end end joins("RIGHT OUTER JOIN (#{derived_table}) groupdate_series ON groupdate_series.#{field} = (#{sanitize_sql_array(query)})").group(Groupdate::OrderHack.new("groupdate_series.#{field}", field)) else group(Groupdate::OrderHack.new(sanitize_sql_array(query), field)) end } end end end class OrderHack < String attr_reader :field def initialize(str, field) super(str) @field = field end end end ActiveRecord::Base.send :include, Groupdate # hack for **unfixed** rails issue # https://github.com/rails/rails/issues/7121 module ActiveRecord module Calculations def column_alias_for_with_hack(*keys) if keys.first.is_a?(Groupdate::OrderHack) keys.first.field else column_alias_for_without_hack(*keys) end end alias_method_chain :column_alias_for, :hack end end