# Population first, then filters. Previous versions are joined before period filtering. class CountsPopulation attr_reader :definition, :params, :at, :history, :scope, :timezone def initialize(params) @params = params @definition = CountsResourceDefinition.new(params.fetch('resource', 'posts')) @history = params.fetch('definition', 'latest') == 'history' raise ArgumentError unless ['latest', 'history'].include?(params.fetch('definition', 'latest')) @timezone = TZInfo::Timezone.get(params.fetch('timezone', 'UTC')) @at = timestamp(params['execution_at']) || Time.current.utc raise ArgumentError if at > Time.current + 1.second @scope = definition.model.all make_population apply_filters rescue TZInfo::InvalidTimezoneIdentifier raise ArgumentError end def timestamp(value, end_date: false) return nil if value.nil? || value == '' raise ArgumentError unless value.is_a?(String) if value.match?(/\A\d{4}-\d{2}-\d{2}\z/) date = Date.iso8601(value) date += 1 if end_date timezone.local_to_utc(Time.utc(date.year, date.month, date.day)) else raise ArgumentError unless value.match?(/(?:Z|[+-]\d{2}:\d{2})\z/) time = Time.iso8601(value).utc raise ArgumentError unless time.year.between?(1900, 2100) time end rescue TZInfo::AmbiguousTime, TZInfo::PeriodNotFound, Date::Error raise ArgumentError end def latest_sql(row = 'v', cutoff = nil) cutoff ||= history ? at : timestamp(params['at']) || at keys = definition.keys.map { "newer.#{ _1 } = #{ row }.#{ _1 }" }.join(' AND ') bound = definition.model.connection.quote(cutoff) <<~SQL.squish NOT EXISTS (SELECT 1 FROM #{ definition.table } newer WHERE #{ keys } AND newer.version_no > #{ row }.version_no AND newer.#{ definition.time_column } <= #{ bound }) SQL end def changed_sql(key) column = definition.column(key) "(previous.id IS NOT NULL AND NOT (v.#{ column } <=> previous.#{ column }))" end private def make_population table = definition.table @scope = @scope.from("#{ table } v").select('v.*') keys = definition.keys.map { "previous.#{ _1 } = v.#{ _1 }" }.join(' AND ') @scope = @scope.joins("LEFT JOIN #{ table } previous ON #{ keys } " + 'AND previous.version_no = v.version_no - 1') @scope = @scope.where("v.#{ definition.time_column } <= ?", at) unless history point = timestamp(params['at']) || at raise ArgumentError if point > at @scope = @scope.where("v.#{ definition.time_column } <= ?", point) .where(Arel.sql(latest_sql('v', point))).where.not('v.event_type' => 'discard') return end first = timestamp(params['from']) last = timestamp(params['through'], end_date: true) raise ArgumentError if first && last && first > last @scope = @scope.where('v.created_at >= ?', first) if first if last operator = params['through'].length == 10 ? '<' : '<=' @scope = @scope.where("v.created_at #{ operator } ?", last) end end def apply_filters ['resource_id', 'user'].each do |key| value = params[key == 'user' ? 'user_id' : key] next if value.nil? || value == '' raise ArgumentError unless value.match?(/\A[0-9]+\z/) @scope = @scope.where("#{ definition.expression(key) } = ?", value.to_i) end apply_tags if params['tags'].present? || params['tag'].present? conditions = JSON.parse(params.fetch('conditions', '[]')) raise ArgumentError unless conditions.is_a?(Array) && conditions.length <= 100 conditions.each { apply_condition(_1) } drills = JSON.parse(params.fetch('drill', '[]')) raise ArgumentError unless drills.is_a?(Array) && drills.length <= 100 drills.each do |entry| key = entry.fetch('field') if key == 'tags' @scope = @scope.where(snapshot_membership(entry.fetch('value'))) elsif key == 'changed_field' raise ArgumentError unless history && definition.changes.include?(entry['value']) @scope = @scope.where(Arel.sql(changed_sql(entry['value']))) elsif definition.fields[key] == :datetime && entry['grain'] @scope = @scope.where(definition.expression(key) => nil) if entry['value'].nil? if entry['value'] expression = CountsAxes.time_expression(definition.expression(key), entry['grain'], timezone) @scope = @scope.where("#{ expression } = ?", entry['value']) end else raise ArgumentError unless definition.axes(history).include?(key) @scope = @scope.where(definition.expression(key) => entry['value']) end end end def apply_condition(entry) raise ArgumentError unless entry.is_a?(Hash) raise ArgumentError if (entry.keys - ['field', 'operator', 'value', 'end', 'blank']).any? key = entry.fetch('field') type = definition.fields.fetch(key) { raise ArgumentError } operator = entry.fetch('operator') raise ArgumentError unless CountsResourceDefinition::OPERATORS.fetch(type, []).include?(operator) raise ArgumentError unless [true, false, nil].include?(entry['blank']) expression = definition.expression(key) blank = type == :string || type == :enum ? "(#{ expression } IS NULL OR #{ expression } = '')" : "#{ expression } IS NULL" if entry['blank'] @scope = @scope.where(Arel.sql(blank)) return end values = [entry['value'], entry['end']] raise ArgumentError unless values.all? { _1.nil? || _1.is_a?(String) } return if values.all? { _1.nil? || _1 == '' } parsed = values.map do |value| next nil if value.nil? || value == '' case type when :integer raise ArgumentError unless value.match?(/\A-?\d+\z/) value.to_i when :datetime then timestamp(value) else value end end if operator == 'range' @scope = @scope.where("#{ expression } >= ?", parsed[0]) if parsed[0] @scope = @scope.where("#{ expression } <= ?", parsed[1]) if parsed[1] return end return unless parsed[0] sql_operator = { 'eq' => '=', 'ne' => '<>', 'gt' => '>', 'lt' => '<', 'ge' => '>=', 'le' => '<=', 'like' => 'LIKE' }.fetch(operator) sql = "#{ expression } #{ sql_operator } ?" sql = "(#{ sql } OR #{ blank })" if operator == 'ne' @scope = @scope.where(sql, parsed[0]) end def snapshot_membership(value) kind, identifier = value.to_s.split(':', 2) raise ArgumentError unless ['internal', 'external'].include?(kind) raise ArgumentError unless identifier&.match?(/\A[1-9]\d*\z/) key = kind == 'internal' ? 'tag_id' : 'external_tag_id' definition.model.sanitize_sql_array([ "JSON_CONTAINS(v.tags_json, JSON_OBJECT('#{ key }', CAST(? AS UNSIGNED)))", identifier.to_i]) end def apply_tags raise ArgumentError unless definition.resource == 'posts' if params['tag'].present? @scope = @scope.where(snapshot_membership( "#{ params.fetch('kind', 'internal') }:#{ params['tag'] }")) end return unless params['tags'].present? locale = Locale.find_by!(code: params.fetch('locale', 'ja')) refs = PostTagSearch.parse_references(params['tag_refs'], params['tags'].split) raw = params['tags'] literals = PostTagSearch.literals(locale, raw, references: refs) predicates = literals.map do |literal| internal, external = PostTagSearch.references(locale, literal) parts = Array(internal).map { snapshot_membership("internal:#{ _1 }") } + Array(external).map { snapshot_membership("external:#{ _1 }") } expression = parts.empty? ? 'FALSE' : "(#{ parts.join(' OR ') })" literal[:negative] ? "NOT #{ expression }" : expression end return if predicates.empty? any = params['match'] == 'any' || raw.split.any? { _1.downcase == 'type:or' } @scope = @scope.where(Arel.sql("(#{ predicates.join(any ? ' OR ' : ' AND ') })")) end end