# 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) 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 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'], enum: ['eq', 'ne'], boolean: ['eq', 'ne'], integer: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'], datetime: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'], 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 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.map do |key| 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', 'names'] when 'names' then ['languageCode', 'scriptCode', 'name', 'aliases', 'autoGenerated'] when 'aliases' resource == 'tags' ? ['languageCode', 'name', 'scriptCode'] : ['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.map do |child| 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. children = children.reject { _1[:key] == 'languageCode' } if child == 'aliases' field[:children] = children if children.any? field end end def filter_metadata = @filter_metadata ||= CountsCondition.fields(self) end