# Read-only history queries. All identifiers and SQL expressions are server-owned. class CountsQuery EVENTS = ['create', 'update', 'discard', 'restore'].freeze AXES = ['event_type', 'month', 'user'].freeze COMMON_COLUMNS = ['created_at', 'resource_id', 'version_no', 'event_type', 'user'].freeze COLUMNS = { 'posts' => COMMON_COLUMNS + ['title', 'url', 'tags', 'added_tags', 'removed_tags'], 'tags' => COMMON_COLUMNS + ['name', 'category', 'aliases', 'parents', 'deprecated'] }.freeze KEYS = [ 'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', 'user_id', 'event_type', 'resource_id', 'tag', 'columns'].freeze attr_reader :page, :limit, :mode, :axis, :columns def initialize(scope, resource, params) @scope = scope.except(:select, :order) @resource = resource @table = scope.klass.table_name @params = params raise ArgumentError unless (params.keys - KEYS).empty? raise ArgumentError unless params.values.all? { _1.is_a?(String) } @mode = params.fetch('mode', 'summary') @axis = params.fetch('axis', 'event_type') raise ArgumentError unless ['summary', 'details'].include?(mode) && AXES.include?(axis) @page = integer('page', 1) @limit = integer('limit', 20) raise ArgumentError if limit > 100 || page > 1_000_000 @columns = params.fetch('columns', COLUMNS.fetch(resource).join(',')).split(',') if columns.empty? || columns.uniq != columns || (columns - COLUMNS.fetch(resource)).any? raise ArgumentError end apply_filters end def count = @scope.count def details(scope) scope.where(id: @scope.select("#{ @table }.id")) .order(Arel.sql("#{ @table }.created_at DESC, #{ @table }.id DESC")) .limit(limit).offset((page - 1) * limit) end def summary expression = case axis when 'event_type' then "#{ @table }.event_type" when 'user' then "#{ @table }.created_by_user_id" when 'month' then "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" end grouped = @scope.group(Arel.sql(expression)) # A derived table counts groups without materialising every group in Ruby. sql = grouped.select(Arel.sql("#{ expression } AS group_key")).to_sql groups = @scope.klass.connection.select_value("SELECT COUNT(*) FROM (#{ sql }) counts_groups") rows = grouped.order(Arel.sql("#{ expression } ASC")) .limit(limit).offset((page - 1) * limit).count names = axis == 'user' ? User.where(id: rows.keys.compact).pluck(:id, :name).to_h : {} { group_count: groups.to_i, groups: rows.map { |key, value| { key:, name: names[key], count: value } } } end private def integer(key, default = nil) value = @params[key].presence return default if value.nil? && !default.nil? raise ArgumentError unless value&.match?(/\A[1-9][0-9]{0,9}\z/) value.to_i end def timestamp(key) value = @params[key].presence return nil unless value raise ArgumentError unless value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?Z\z/) Date.iso8601(value.first(10)) Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value) end def apply_filters first = timestamp('from') last = timestamp('before') raise ArgumentError if first && last && first >= last @scope = @scope.where("#{ @table }.created_at >= ?", first) if first @scope = @scope.where("#{ @table }.created_at < ?", last) if last if @params['resource_id'].present? id_column = @resource == 'posts' ? 'post_id' : 'tag_id' @scope = @scope.where(@table => { id_column => integer('resource_id') }) end if @params['user_id'].present? user = @params['user_id'] == 'none' ? nil : integer('user_id') @scope = @scope.where(@table => { created_by_user_id: user }) end if @params['event_type'].present? raise ArgumentError unless EVENTS.include?(@params['event_type']) @scope = @scope.where(@table => { event_type: @params['event_type'] }) end return unless @params['tag'].present? raise ArgumentError unless @resource == 'posts' id = integer('tag') @scope = @scope.where( "JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('tag_id', CAST(? AS UNSIGNED))) OR " + "JSON_CONTAINS(prev.tags_json, JSON_OBJECT('tag_id', CAST(? AS UNSIGNED)))", id, id) end end