# Read-only history queries. SQL identifiers and expressions are server-owned. class CountsQuery AXES = ['month', 'user'].freeze COMMON_COLUMNS = ['resource_id', 'version_no', 'is_latest', 'event_type', 'user', 'created_at'].freeze COLUMNS = { 'posts' => COMMON_COLUMNS + [ 'thumbnail', 'thumbnail_base', 'title', 'url', 'tags', 'parents', 'video_ms', 'original_created_at', 'original_created_from', 'original_created_before'], 'tags' => COMMON_COLUMNS + [ 'name', 'names', 'category', 'aliases', 'parents', 'deprecated'] }.freeze KEYS = [ 'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', 'latest', 'user_id', 'resource_id', 'tag', 'columns', 'sort', 'at'].freeze attr_reader :page, :limit, :mode, :axis def initialize(scope, resource, params, latest_expression: nil) @table = scope.klass.table_name @resource = resource @id_column = resource == 'posts' ? 'post_id' : 'tag_id' @params = params @latest_expression = latest_expression @scope = scope.except(:select, :order).joins( "LEFT JOIN users counts_actor ON counts_actor.id = #{ @table }.created_by_user_id") 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', 'month') 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 @sort = parse_sort apply_filters end def relation = @scope def count = @scope.count def latest_expression return @latest_expression if @latest_expression at = timestamp('at') cutoff = at ? "AND newer.created_at <= #{ @scope.klass.connection.quote(at) }" : '' <<~SQL.squish NOT EXISTS (SELECT 1 FROM #{ @table } newer WHERE newer.#{ @id_column } = #{ @table }.#{ @id_column } AND newer.version_no > #{ @table }.version_no #{ cutoff }) SQL end def details @scope.select("#{ @table }.*", Arel.sql("(#{ latest_expression }) AS counts_latest")) .order(Arel.sql(order_sql(detail_expressions, ["#{ @table }.id DESC"]))) .limit(limit).offset((page - 1) * limit) end def summary(include_count: true) expression = axis == 'month' ? "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" : "#{ @table }.created_by_user_id" group_order = axis == 'user' ? 'MAX(counts_actor.name)' : expression sql = @scope.group(Arel.sql(expression)).select(Arel.sql( "#{ expression } AS group_key, COUNT(*) AS record_count, " + "#{ group_order } AS group_name")).to_sql connection = @scope.klass.connection source = "FROM (#{ sql }) counts_groups" groups = include_count ? connection.select_value("SELECT COUNT(*) #{ source }") : nil expressions = { 'group' => ['group_name'], 'count' => ['record_count'] } order = order_sql(expressions, ['group_key IS NULL ASC', 'group_key ASC']) rows = connection.select_all( "SELECT group_key, group_name, record_count #{ source } " + "ORDER BY #{ order } LIMIT #{ limit } OFFSET #{ (page - 1) * limit }") { group_count: groups&.to_i, groups: rows.map do |row| { key: row['group_key'], name: axis == 'user' ? row['group_name'] : nil, count: row['record_count'].to_i } end } end private def parse_sort value = @params.fetch('sort', mode == 'details' ? 'created_at:desc' : 'group:asc') return [] if value.empty? values = value.split(',', -1).map { _1.split(':', -1) } allowed = mode == 'details' ? COLUMNS.fetch(@resource) - ['thumbnail'] : ['group', 'count'] invalid = values.any? do |key, direction, *extra| extra.any? || !allowed.include?(key) || !['asc', 'desc'].include?(direction) end if (values.empty? || values.length > allowed.length || invalid || values.map(&:first).uniq.length != values.length) raise ArgumentError end values end # NULL always sorts last, including descending keys. The final key is unique. def order_sql(expressions, final) (@sort.flat_map do |key, direction| expressions.fetch(key).flat_map do |expression| ["(#{ expression }) IS NULL ASC", "#{ expression } #{ direction.upcase }"] end end + final).join(', ') end def detail_expressions common = { 'resource_id' => ["#{ @table }.#{ @id_column }"], 'version_no' => ["#{ @table }.version_no"], 'is_latest' => ["(#{ latest_expression })"], 'user' => ['counts_actor.name', "#{ @table }.created_by_user_id"], 'created_at' => ["#{ @table }.created_at"] } if @resource == 'posts' fields = [ 'title', 'url', 'thumbnail_base', 'video_ms', 'original_created_from', 'original_created_before'] common.merge(fields.to_h { [_1, ["#{ @table }.#{ _1 }"]] }).merge( 'original_created_at' => [ 'post_versions.original_created_from', 'post_versions.original_created_before'], 'tags' => json_order('post_versions.tags_json'), 'parents' => list_order('post_versions.parent_post_ids')) else name = "JSON_UNQUOTE(JSON_EXTRACT(tag_versions.names, #{ locale_name_path }))" common.merge( 'name' => ["COALESCE(#{ name }, tag_versions.name)"], 'names' => json_order('tag_versions.names'), 'category' => ['tag_versions.category'], 'aliases' => json_order( "JSON_EXTRACT(tag_versions.names, '$.*.aliases[*]')"), 'parents' => list_order('tag_versions.parent_tag_ids'), 'deprecated' => ['tag_versions.deprecated_at']) end end def locale_name_path language = Locale.find_by(code: @params.fetch('locale', 'ja'))&.language_code || 'ja' path = "$.#{ JSON.generate(language) }.name" @scope.klass.connection.quote(path) end # Multi-valued parent cells sort by cardinality, then binary snapshot text. def json_order(column) [ "COALESCE(JSON_LENGTH(#{ column }), 0)", "COALESCE(CAST(#{ column } AS CHAR), '[]') COLLATE utf8mb4_bin"] end def list_order(column) [ "CASE WHEN #{ column } = '' THEN 0 ELSE " + "1 + LENGTH(#{ column }) - LENGTH(REPLACE(#{ column }, ' ', '')) END", "#{ column } COLLATE utf8mb4_bin"] end 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 pattern = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?Z\z/ raise ArgumentError unless value.match?(pattern) Date.iso8601(value.first(10)) Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value) end def apply_filters at = timestamp('at') first = timestamp('from') last = timestamp('before') raise ArgumentError if first && last && first >= last raise ArgumentError unless ['0', '1'].include?(@params.fetch('latest', '1')) @scope = @scope.where("#{ @table }.created_at <= ?", at) if at @scope = @scope.where(Arel.sql(latest_expression)) unless @params['latest'] == '0' @scope = @scope.where("#{ @table }.created_at >= ?", first) if first @scope = @scope.where("#{ @table }.created_at < ?", last) if last if @params['resource_id'].present? @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 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