310 行
14 KiB
Ruby
310 行
14 KiB
Ruby
# Server-owned version identities, attributes and field types. No client SQL is accepted.
|
|
class CountsResourceDefinition
|
|
# Compatibility belongs at the request boundary, not in field definitions.
|
|
def self.normalise_params(params)
|
|
aliases = nil
|
|
predicates = nil
|
|
scope = params['name_scope']
|
|
raise ArgumentError if scope && !['locale', 'language'].include?(scope)
|
|
params.each_with_object({}) do |(key, value), result|
|
|
result[key] = value
|
|
next unless value.is_a?(String)
|
|
|
|
if ['columns', 'order', 'sort', 'rows', 'cols', 'axis'].include?(key)
|
|
result[key] = value.split(/ /, -1).map { |token|
|
|
token.sub(/\Ais_latest(?=:|\z)/, 'latest')
|
|
}.join(' ')
|
|
elsif (key.match?(/\A(?:conditions|drill)\.[0-9]+\.field\z/) &&
|
|
value == 'is_latest')
|
|
result[key] = 'latest'
|
|
end
|
|
if (['columns', 'children', 'order', 'sort', 'child_sort'].include?(key) &&
|
|
value.match?(/(?:name|aliases)_[a-z]+_[a-z0-9_]+/))
|
|
aliases ||= new('tags').column_aliases.to_h { [_1[:key], _1[:value]] }
|
|
result[key] = result[key].gsub(/(?:name|aliases)_[a-z0-9_]+/) { aliases.fetch(_1, _1) }
|
|
end
|
|
if (scope != 'language' && key.match?(/\Aconditions\.[0-9]+\.field\z/) &&
|
|
value.match?(/(?:name|aliases)_[a-z0-9_]+/))
|
|
predicates ||= new('tags').condition_aliases.to_h { [_1[:key], _1[:value]] }
|
|
result[key] = value.gsub(/(?:name|aliases)_[a-z0-9_]+/) { predicates.fetch(_1, _1) }
|
|
end
|
|
end
|
|
end
|
|
|
|
COMMON = { 'resource_id' => :integer, 'version_no' => :integer,
|
|
'event_type' => :enum, 'user' => :integer, 'created_at' => :datetime }.freeze
|
|
DEFINITIONS = {
|
|
'posts' => [PostVersion, ['post_id'], {
|
|
'title' => :string, 'url' => :string, 'thumbnail_base' => :string,
|
|
'tags_json' => :json, 'parent_post_ids' => :string, 'video_ms' => :integer,
|
|
'original_created_from' => :datetime, 'original_created_before' => :datetime }],
|
|
'tags' => [TagVersion, ['tag_id'], {
|
|
'name' => :string, 'names' => :json, 'category' => :enum, 'aliases' => :string,
|
|
'parent_tag_ids' => :string, 'deprecated_at' => :datetime }],
|
|
'nico_tags' => [NicoTagVersion, ['tag_id'], {
|
|
'name' => :string, 'linked_tags' => :string }],
|
|
'tag_descriptions' => [TagDescriptionVersion, ['tag_id', 'language_code'], {
|
|
'language_code' => :enum, 'body' => :string,
|
|
'source_language_code' => :enum, 'source_version_no' => :integer }],
|
|
'tag_groups' => [TagGroupVersion, ['tag_group_id'], {
|
|
'name' => :string, 'description' => :string,
|
|
'members' => :json, 'placements' => :json }],
|
|
'materials' => [MaterialVersion, ['material_id'], {
|
|
'url' => :string, 'parent_id' => :integer, 'tag_id' => :integer,
|
|
'tag_name' => :string, 'tag_category' => :enum,
|
|
'discarded_at' => :datetime, 'updated_at' => :datetime,
|
|
'updated_by_user_id' => :integer, 'export_paths_json' => :json,
|
|
'file_blob_id' => :integer, 'file_filename' => :string,
|
|
'file_content_type' => :string, 'file_byte_size' => :integer,
|
|
'file_checksum' => :string, 'file_sha256' => :string,
|
|
'source_kind' => :enum, 'source_uri' => :string, 'source_path' => :string,
|
|
'source_file_id' => :string, 'normalized_source_key' => :string }] }.freeze
|
|
OPERATORS = {
|
|
string: ['eq', 'ne', 'like', 'in', 'not_in'],
|
|
enum: ['eq', 'ne', 'in', 'not_in'], boolean: ['eq', 'ne', 'in', 'not_in'],
|
|
integer: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range', 'in', 'not_in'],
|
|
datetime: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range', 'in', 'not_in'],
|
|
interval: ['range'] }.freeze
|
|
LABELS = {
|
|
'resource_id' => 'counts:columns.resourceId', 'version_no' => 'counts:columns.version',
|
|
'latest' => 'counts:columns.latest', 'event_type' => 'counts:eventType',
|
|
'user' => 'history:actor', 'created_at' => 'counts:columns.changedAt',
|
|
'thumbnail' => 'counts:columns.thumbnailDisplay',
|
|
'thumbnail_base' => 'counts:columns.thumbnailURL',
|
|
'title' => 'counts:columns.postTitle', 'url' => 'counts:columns.postURL',
|
|
'tags' => 'counts:columns.tags', 'tags_json' => 'counts:columns.tags',
|
|
'video_ms' => 'shared:labels.duration', 'name' => 'counts:columns.name',
|
|
'names' => 'counts:columns.names', 'category' => 'common:categoryLabel',
|
|
'aliases' => 'tags:aliases', 'deprecated' => 'counts:columns.deprecated',
|
|
'original_created_from' => 'counts:columns.originalFrom',
|
|
'original_created_before' => 'counts:columns.originalBefore',
|
|
'language_code' => 'counts:columns.language' }.freeze
|
|
CHILD_LABELS = {
|
|
'id' => 'counts:columns.resourceId', 'versionNo' => 'history:version',
|
|
'kind' => 'counts:columns.kind', 'name' => 'history:name',
|
|
'category' => 'common:categoryLabel', 'sections' => 'counts:columns.sections',
|
|
'names' => 'counts:columns.names', 'aliases' => 'tags:aliases',
|
|
'languageCode' => 'counts:columns.language',
|
|
'scriptCode' => 'history:nameChanges.script',
|
|
'autoGenerated' => 'history:nameChanges.automatic',
|
|
'tagId' => 'counts:attributes.tag_id', 'tagGroupId' => 'counts:resources.tag_groups',
|
|
'sectionName' => 'counts:attributes.section_name',
|
|
'description' => 'counts:attributes.description',
|
|
'children' => 'counts:attributes.children', 'profile' => 'counts:attributes.profile',
|
|
'path' => 'counts:attributes.path' }.freeze
|
|
|
|
attr_reader :model, :keys, :fields, :resource
|
|
|
|
def initialize(resource)
|
|
@resource = resource
|
|
@model, @keys, attributes = DEFINITIONS.fetch(resource) { raise ArgumentError }
|
|
@fields = COMMON.merge(attributes)
|
|
end
|
|
|
|
def table = model.table_name
|
|
def time_column = 'created_at'
|
|
def column(key)
|
|
raise ArgumentError unless fields.key?(key)
|
|
|
|
{ 'resource_id' => keys.first, 'user' => 'created_by_user_id' }.fetch(key, key)
|
|
end
|
|
|
|
def expression(key, row = 'v') = "#{ row }.#{ column(key) }"
|
|
def changes = fields.keys - COMMON.keys - ['updated_at', 'updated_by_user_id']
|
|
def axes(history)
|
|
fields.reject { |_key, type| type == :json }.keys +
|
|
(resource == 'posts' ? ['tags'] : []) + (history ? ['changed_field'] : [])
|
|
end
|
|
|
|
def metadata
|
|
fields.map do |key, type|
|
|
{ key:, type:, nullable: model.columns_hash.fetch(column(key)).null,
|
|
operators: OPERATORS.fetch(type, []) }
|
|
end
|
|
end
|
|
|
|
def field_label(key) = LABELS.fetch(key, "counts:attributes.#{ key }")
|
|
|
|
def self.name_key(code) = "name_#{ code.to_s.downcase.tr('-', '_') }"
|
|
def self.aliases_key(code) = "aliases_#{ code.to_s.downcase.tr('-', '_') }"
|
|
|
|
def name_columns
|
|
@name_columns ||= begin
|
|
codes = Language.select(:code).order(:created_at, :code).map do |language|
|
|
{ code: language.code, language_code: language.code }
|
|
end
|
|
names = codes.map do |locale|
|
|
{ key: self.class.name_key(locale[:code]), label: 'counts:localeName',
|
|
name_locale: locale }
|
|
end
|
|
aliases = codes.map do |locale|
|
|
{ key: self.class.aliases_key(locale[:code]), label: 'counts:localeAliases',
|
|
name_locale: locale, multiple_values: true }
|
|
end
|
|
names + aliases
|
|
end
|
|
end
|
|
|
|
# Old locale-qualified predicates keep their script restriction at the input boundary.
|
|
def legacy_name_columns
|
|
@legacy_name_columns ||= Locale.select(:code, :language_code, :script_code)
|
|
.order(:code).flat_map do |locale|
|
|
codes = { code: locale.code, language_code: locale.language_code,
|
|
script_code: locale.script_code }
|
|
code = locale.code == locale.language_code ? "#{ locale.code }_#{ locale.script_code }" :
|
|
locale.code
|
|
[{ key: self.class.name_key(code), label: 'counts:localeName',
|
|
name_locale: codes, legacy: true },
|
|
{ key: self.class.aliases_key(code), label: 'counts:localeAliases',
|
|
name_locale: codes, multiple_values: true, legacy: true }]
|
|
end
|
|
end
|
|
|
|
def column_aliases
|
|
legacy_name_columns.map do |field|
|
|
language = field[:name_locale][:language_code]
|
|
value = if field[:multiple_values]
|
|
self.class.aliases_key(language)
|
|
else self.class.name_key(language)
|
|
end
|
|
{ key: field[:key], value: }
|
|
end
|
|
end
|
|
|
|
def condition_aliases
|
|
legacy_name_columns.filter_map do |field|
|
|
code = field[:name_locale][:code]
|
|
key = field[:multiple_values] ? self.class.aliases_key(code) : self.class.name_key(code)
|
|
next if key == field[:key]
|
|
|
|
{ key:, value: field[:key] }
|
|
end
|
|
end
|
|
|
|
def name_column(key)
|
|
name_columns.find { _1[:key] == key } || legacy_name_columns.find { _1[:key] == key }
|
|
end
|
|
|
|
def name_expression(key, row = 'v')
|
|
if key.start_with?('aliases_')
|
|
source, value = alias_source(key, row)
|
|
return "CAST(COALESCE((SELECT JSON_ARRAYAGG(#{ value }) OVER (" +
|
|
'ORDER BY locale_alias.ordinal ROWS BETWEEN UNBOUNDED PRECEDING ' +
|
|
"AND UNBOUNDED FOLLOWING) FROM #{ source } WHERE #{ value } IS NOT NULL " +
|
|
'LIMIT 1), JSON_ARRAY()) AS CHAR) COLLATE utf8mb4_bin'
|
|
end
|
|
locale = name_column(key).fetch(:name_locale)
|
|
connection = model.connection
|
|
path = connection.quote("$.#{ locale[:language_code].to_json }")
|
|
value = "JSON_UNQUOTE(NULLIF(JSON_EXTRACT(#{ row }.names, " +
|
|
"CONCAT(#{ path }, '.name')), CAST('null' AS JSON)))"
|
|
return value unless locale[:script_code]
|
|
|
|
script = connection.quote(locale[:script_code])
|
|
"CASE WHEN LOWER(JSON_UNQUOTE(JSON_EXTRACT(#{ row }.names, " +
|
|
"CONCAT(#{ path }, '.script_code')))) = LOWER(#{ script }) " +
|
|
"THEN #{ value } END"
|
|
end
|
|
|
|
def alias_source(key, row = 'v')
|
|
locale = name_column(key).fetch(:name_locale)
|
|
connection = model.connection
|
|
path = connection.quote("$.#{ locale[:language_code].to_json }.aliases[*]")
|
|
source = "JSON_TABLE(COALESCE(#{ row }.names, JSON_OBJECT()), #{ path } " +
|
|
"COLUMNS (ordinal FOR ORDINALITY, name TEXT PATH '$.name', " +
|
|
"script TEXT PATH '$.script_code')) locale_alias"
|
|
return [source, 'locale_alias.name'] unless locale[:script_code]
|
|
|
|
script = connection.quote(locale[:script_code])
|
|
value = "CASE WHEN LOWER(locale_alias.script) = LOWER(#{ script }) " +
|
|
'THEN locale_alias.name END'
|
|
[source, value]
|
|
end
|
|
|
|
def output_fields
|
|
content = case resource
|
|
when 'posts'
|
|
['thumbnail', 'title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms',
|
|
'original_created_from', 'original_created_before']
|
|
when 'tags' then ['name', 'names', 'category', 'aliases', 'parents', 'deprecated']
|
|
else fields.keys - COMMON.keys
|
|
end
|
|
keys = ['resource_id', 'version_no', 'latest'] + (content - ['thumbnail']) +
|
|
['event_type', 'user', 'created_at']
|
|
keys.unshift('thumbnail') if content.include?('thumbnail')
|
|
keys.flat_map do |key|
|
|
next name_columns if key == 'name' && resource == 'tags'
|
|
next [] if key == 'names' && resource == 'tags'
|
|
next [] if key == 'aliases' && resource == 'tags'
|
|
label = if key == 'parents'
|
|
resource == 'tags' ? 'tags:parents' : 'shared:labels.parentPosts'
|
|
else field_label(key)
|
|
end
|
|
field = { key:, label: }
|
|
children = child_fields(key)
|
|
field[:children] = children if children.any?
|
|
[field]
|
|
end
|
|
end
|
|
|
|
def child_fields(key, depth = 4)
|
|
keys = case key
|
|
when 'tags' then ['id', 'versionNo', 'kind', 'name', 'category', 'sections', 'aliases']
|
|
when 'names' then ['languageCode', 'scriptCode', 'name', 'aliases', 'autoGenerated']
|
|
when 'aliases' then ['languageCode', 'name', 'scriptCode']
|
|
when 'parents' then resource == 'tags' ? ['id', 'name'] : ['id']
|
|
when 'members' then ['tagId', 'tagGroupId', 'versionNo']
|
|
when 'export_paths_json' then ['profile', 'path']
|
|
when 'placements', 'children'
|
|
scalar = ['sectionName', 'description', 'tagId', 'tagGroupId']
|
|
scalar + (depth > 0 ? ['children'] : [])
|
|
else []
|
|
end
|
|
keys.flat_map do |child|
|
|
next [] if child == 'aliases' && key == 'tags'
|
|
if child == 'name' && ['tags', 'parents'].include?(key)
|
|
next name_columns
|
|
end
|
|
label = key == 'members' && child == 'tagId' ?
|
|
'counts:columns.resourceId' : CHILD_LABELS.fetch(child)
|
|
field = { key: child, label: }
|
|
children = child_fields(child, child == 'children' ? depth - 1 : depth)
|
|
# Language is attached to top-level aliases, not to aliases inside a language entry.
|
|
if child == 'aliases' && key == 'names'
|
|
children = children.reject { _1[:key] == 'languageCode' }
|
|
end
|
|
field[:children] = children if children.any?
|
|
[field]
|
|
end
|
|
end
|
|
|
|
def condition_fields
|
|
fields = output_fields
|
|
if resource == 'tags'
|
|
fields = fields.map do |field|
|
|
next field unless field[:key] == 'parents'
|
|
|
|
field.merge(children: field[:children] + legacy_name_columns + [
|
|
{ key: 'name', label: 'history:name' },
|
|
{ key: 'names', label: 'counts:columns.names', children: child_fields('names') }])
|
|
end
|
|
fields += legacy_name_columns + [{ key: 'name', label: 'counts:columns.name' },
|
|
{ key: 'names', label: 'counts:columns.names', children: child_fields('names') },
|
|
{ key: 'aliases', label: 'tags:aliases', children: child_fields('aliases') }]
|
|
end
|
|
if resource == 'posts'
|
|
fields = fields.map do |field|
|
|
next field unless field[:key] == 'tags'
|
|
|
|
field.merge(children: field[:children] + legacy_name_columns + [
|
|
{ key: 'name', label: 'history:name' },
|
|
{ key: 'names', label: 'counts:columns.names', children: child_fields('names') },
|
|
{ key: 'aliases', label: 'tags:aliases', children: child_fields('aliases') }])
|
|
end
|
|
end
|
|
fields
|
|
end
|
|
|
|
def filter_metadata = @filter_metadata ||= CountsCondition.fields(self)
|
|
end
|