class CountsAxes GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze def self.time_expression(expression, grain, timezone) raise ArgumentError unless GRAINS.include?(grain) connection = ApplicationRecord.connection # Use the installed TZInfo database, including DST, rather than MySQL timezone tables. first = Time.utc(1900) last = Time.utc(2101) offset = timezone.period_for_utc(first).utc_total_offset clauses = timezone.transitions_up_to(last, first).filter_map do |transition| next if transition.offset.utc_total_offset == offset previous = offset offset = transition.offset.utc_total_offset "WHEN #{ expression } < #{ connection.quote(transition.at.to_time) } THEN #{ previous }" end seconds = clauses.empty? ? offset.to_s : "CASE #{ clauses.join(' ') } ELSE #{ offset } END" local = "DATE_ADD(#{ expression }, INTERVAL (#{ seconds }) SECOND)" case grain when 'year' then "DATE_FORMAT(#{ local }, '%Y')" when 'quarter' then "CONCAT(YEAR(#{ local }), '-Q', QUARTER(#{ local }))" when 'month' then "DATE_FORMAT(#{ local }, '%Y-%m')" when 'week' then "DATE_FORMAT(#{ local }, '%x-W%v')" when 'day' then "DATE_FORMAT(#{ local }, '%Y-%m-%d')" else "DATE_FORMAT(#{ local }, '%Y-%m-%d %H:00')" end end attr_reader :rows, :columns, :all def initialize(population) @population = population @definition = population.definition @rows = parse(population.params.fetch('rows', '[]')) @columns = parse(population.params.fetch('cols', '[]')) @all = rows + columns raise ArgumentError if all.length > 8 || all.map { _1['field'] }.uniq.length != all.length end def expressions all.map do |axis| field = axis.fetch('field') case field when 'tags' "CASE WHEN tag_entry.tag_id IS NOT NULL " + "THEN CONCAT('internal:', tag_entry.tag_id) " + "ELSE CONCAT('external:', tag_entry.external_tag_id) END" when 'changed_field' then 'changed.field_key' else expression = @definition.expression(field) @definition.fields[field] == :datetime ? self.class.time_expression( expression, axis.fetch('grain', 'month'), @population.timezone) : expression end end end def joins result = '' if all.any? { _1['field'] == 'tags' } result += <<~SQL.squish JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS ( tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY, external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry SQL end if all.any? { _1['field'] == 'changed_field' } fields = @definition.changes.map do |field| "SELECT #{ ApplicationRecord.connection.quote(field) } AS field_key" end.join(' UNION ALL ') condition = @definition.changes.map do |field| "(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })" end.join(' OR ') result += " JOIN (#{ fields }) changed ON (#{ condition })" end result end private def parse(value) axes = JSON.parse(value) raise ArgumentError unless axes.is_a?(Array) axes.each do |axis| raise ArgumentError unless axis.is_a?(Hash) && (axis.keys - ['field', 'grain']).empty? raise ArgumentError unless @definition.axes(@population.history).include?(axis['field']) if @definition.fields[axis['field']] == :datetime raise ArgumentError unless GRAINS.include?(axis.fetch('grain', 'month')) elsif axis.key?('grain') raise ArgumentError end end axes end end