199 行
7.8 KiB
Ruby
199 行
7.8 KiB
Ruby
# 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', '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'].freeze
|
|
|
|
attr_reader :page, :limit, :mode, :axis
|
|
|
|
def initialize(scope, resource, params)
|
|
@table = scope.klass.table_name
|
|
@resource = resource
|
|
@id_column = resource == 'posts' ? 'post_id' : 'tag_id'
|
|
@params = params
|
|
@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 count = @scope.count
|
|
|
|
def latest_expression
|
|
<<~SQL.squish
|
|
NOT EXISTS (SELECT 1 FROM #{ @table } newer
|
|
WHERE newer.#{ @id_column } = #{ @table }.#{ @id_column }
|
|
AND newer.version_no > #{ @table }.version_no)
|
|
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
|
|
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 = connection.select_value("SELECT COUNT(*) #{ source }")
|
|
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
|
|
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
|
|
raise ArgumentError unless ['0', '1'].include?(@params.fetch('latest', '1'))
|
|
|
|
@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
|