331 行
14 KiB
Ruby
331 行
14 KiB
Ruby
# Typed predicates over the same recorded fields used by Counts output.
|
|
# Multi-valued comparisons use EXISTS; inequality means no matching element.
|
|
class CountsCondition
|
|
CHILD_TYPES = { 'id' => :integer, 'versionNo' => :integer, 'tagId' => :integer,
|
|
'tagGroupId' => :integer, 'autoGenerated' => :boolean,
|
|
'category' => :enum, 'kind' => :enum, 'sections' => :interval }.freeze
|
|
|
|
def self.fields(definition)
|
|
result = definition.metadata.map { _1.merge(labels: [definition.field_label(_1[:key])]) }
|
|
visit = lambda do |fields, prefix, labels|
|
|
fields.each do |field|
|
|
key = [prefix, field[:key]].compact.join('.')
|
|
path_labels = labels + [field[:label]]
|
|
type = if prefix
|
|
CHILD_TYPES.fetch(field[:key], :string)
|
|
elsif key == 'is_latest'
|
|
:boolean
|
|
elsif key == 'parents'
|
|
:integer
|
|
elsif key == 'deprecated'
|
|
:datetime
|
|
elsif key == 'thumbnail'
|
|
:string
|
|
else
|
|
definition.fields.fetch(key, :string)
|
|
end
|
|
type = :string if field[:children] || type == :json
|
|
type = :integer if ['parents', 'members'].include?(key)
|
|
physical = result.find { _1[:key] == key }
|
|
nullable = prefix.present? || (physical ? physical[:nullable] : key != 'is_latest')
|
|
nullable = false if field[:children]
|
|
entry = { key:, type:, labels: path_labels, nullable:,
|
|
multiple: prefix.present? || field[:children].present?,
|
|
collection: field[:children].present? || type == :interval,
|
|
operators: CountsResourceDefinition::OPERATORS.fetch(type) }
|
|
result.reject! { _1[:key] == key }
|
|
result << entry
|
|
visit.call(field[:children] || [], key, path_labels)
|
|
end
|
|
end
|
|
visit.call(definition.output_fields, nil, [])
|
|
# The raw JSON alias has the same membership semantics as the public tag field.
|
|
if definition.resource == 'posts'
|
|
result.find { _1[:key] == 'tags_json' }.merge!(type: :string, multiple: true, collection: true,
|
|
operators: CountsResourceDefinition::OPERATORS.fetch(:string))
|
|
end
|
|
result + [{ key: 'user.id', type: :integer, nullable: true,
|
|
labels: ['history:actor', 'counts:columns.resourceId'],
|
|
operators: CountsResourceDefinition::OPERATORS.fetch(:integer) },
|
|
{ key: 'user.name', type: :string, nullable: true,
|
|
labels: ['history:actor', 'history:name'],
|
|
operators: CountsResourceDefinition::OPERATORS.fetch(:string) }]
|
|
end
|
|
|
|
def initialize(population)
|
|
@population = population
|
|
@definition = population.definition
|
|
@connection = @definition.model.connection
|
|
@language = Locale.find_by!(code: population.params.fetch('locale', 'ja')).language_code
|
|
end
|
|
|
|
def apply(scope, entry)
|
|
raise ArgumentError unless entry.is_a?(Hash)
|
|
raise ArgumentError if (entry.keys - ['field', 'operator', 'value', 'end', 'blank']).any?
|
|
|
|
field = @definition.filter_metadata.find { _1[:key] == entry.fetch('field') }
|
|
raise ArgumentError unless field && field[:operators].include?(entry.fetch('operator'))
|
|
raise ArgumentError unless [true, false, nil].include?(entry['blank'])
|
|
values = [entry['value'], entry['end']]
|
|
raise ArgumentError unless values.all? { _1.nil? || (_1.is_a?(String) && _1.bytesize <= 8192) }
|
|
if entry['operator'] != 'range' && !values[1].nil? && values[1] != ''
|
|
raise ArgumentError
|
|
end
|
|
|
|
source, expression = source_for(field[:key])
|
|
type = field[:type]
|
|
blank = blank_sql(expression, type)
|
|
if entry['blank']
|
|
sql = if source && field[:collection]
|
|
"NOT EXISTS (SELECT 1 FROM #{ source })"
|
|
elsif source
|
|
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
|
|
else blank
|
|
end
|
|
return scope.where(Arel.sql(sql))
|
|
end
|
|
return scope if values.all? { _1.nil? || _1 == '' }
|
|
|
|
parsed = values.map { parse(_1, type) }
|
|
validate_enum!(field[:key], parsed.first) if type == :enum
|
|
operator = entry['operator']
|
|
if type == :interval
|
|
return interval(scope, source, expression, parsed)
|
|
end
|
|
clauses = []
|
|
binds = []
|
|
if operator == 'range'
|
|
['>=', '<='].each_with_index do |comparison, index|
|
|
next if parsed[index].nil?
|
|
|
|
clauses << "#{ expression } #{ comparison } ?"
|
|
binds << parsed[index]
|
|
end
|
|
raise ArgumentError if parsed.all? && parsed.first > parsed.last
|
|
elsif !parsed.first.nil?
|
|
comparison = { 'eq' => '=', 'ne' => '=', 'gt' => '>', 'lt' => '<',
|
|
'ge' => '>=', 'le' => '<=', 'like' => 'LIKE' }.fetch(operator)
|
|
clauses << "#{ expression } #{ comparison } ?"
|
|
binds << parsed.first
|
|
end
|
|
return scope if clauses.empty?
|
|
|
|
predicate = clauses.join(' AND ')
|
|
if source
|
|
predicate = "EXISTS (SELECT 1 FROM #{ source } WHERE #{ predicate })"
|
|
predicate = "NOT (#{ predicate })" if operator == 'ne'
|
|
elsif operator == 'ne'
|
|
predicate = "(NOT (#{ predicate }) OR #{ blank })"
|
|
end
|
|
scope.where(predicate, *binds)
|
|
end
|
|
|
|
private
|
|
|
|
def parse(value, type)
|
|
return nil if value.nil? || value == ''
|
|
|
|
case type
|
|
when :integer, :interval
|
|
raise ArgumentError unless value.match?(/\A-?[0-9]{1,19}\z/)
|
|
|
|
number = Integer(value, 10)
|
|
raise ArgumentError unless number.between?(-9_223_372_036_854_775_808, 9_223_372_036_854_775_807)
|
|
|
|
number
|
|
when :boolean
|
|
raise ArgumentError unless ['true', 'false'].include?(value)
|
|
|
|
value == 'true' ? 1 : 0
|
|
when :datetime then @population.timestamp(value)
|
|
else value
|
|
end
|
|
end
|
|
|
|
def validate_enum!(key, value)
|
|
allowed = case key.split('.').last
|
|
when 'event_type' then VersionRecord::DEFAULT_EVENT_TYPE_MAP.values
|
|
when 'category', 'tag_category' then TagVersion.categories.keys
|
|
when 'kind' then ['internal', 'external']
|
|
when 'source_kind' then Material::SOURCE_KINDS
|
|
end
|
|
raise ArgumentError if value && allowed && !allowed.include?(value)
|
|
end
|
|
|
|
def blank_sql(expression, type)
|
|
if [:string, :enum].include?(type)
|
|
"(#{ expression } IS NULL OR #{ expression } = '')"
|
|
else
|
|
"#{ expression } IS NULL"
|
|
end
|
|
end
|
|
|
|
def scalar(document, key, type = :string)
|
|
path = @connection.quote("$.#{ key }")
|
|
value = "JSON_EXTRACT(#{ document }, #{ path })"
|
|
text = "JSON_UNQUOTE(NULLIF(#{ value }, CAST('null' AS JSON)))"
|
|
return "CAST(#{ text } AS SIGNED)" if type == :integer
|
|
return "CASE #{ text } WHEN 'true' THEN 1 WHEN 'false' THEN 0 END" if type == :boolean
|
|
|
|
text
|
|
end
|
|
|
|
def json_table(document, path, name)
|
|
"JSON_TABLE(COALESCE(#{ document }, JSON_ARRAY()), #{ @connection.quote(path) } " +
|
|
"COLUMNS (item JSON PATH '$')) #{ name }"
|
|
end
|
|
|
|
def names_source(document, alias_name)
|
|
source = "JSON_TABLE(COALESCE(JSON_KEYS(#{ document }), JSON_ARRAY()), '$[*]' " +
|
|
"COLUMNS (language_code TEXT PATH '$')) #{ alias_name }"
|
|
path = "CONCAT('$.', JSON_QUOTE(#{ alias_name }.language_code))"
|
|
[source, "JSON_EXTRACT(#{ document }, #{ path })", "#{ alias_name }.language_code"]
|
|
end
|
|
|
|
def point
|
|
@point ||= @population.history ? @population.at :
|
|
@population.timestamp(@population.params['at']) || @population.at
|
|
end
|
|
|
|
def name_expression(row)
|
|
language_path = @connection.quote("$.#{ @language.to_json }.name")
|
|
name = "JSON_UNQUOTE(JSON_EXTRACT(#{ row }.names, #{ language_path }))"
|
|
"COALESCE(#{ name }, #{ row }.name)"
|
|
end
|
|
|
|
def parent_source
|
|
column = @definition.resource == 'posts' ? 'parent_post_ids' : 'parent_tag_ids'
|
|
ids = "TRIM(REGEXP_REPLACE(COALESCE(v.#{ column }, ''), '[[:space:]]+', ' '))"
|
|
document = "CONCAT('[', REPLACE(#{ ids }, ' ', ','), ']')"
|
|
source = json_table(document, '$[*]', 'parent_entry')
|
|
id = 'CAST(parent_entry.item AS UNSIGNED)'
|
|
return [source, id] if @definition.resource == 'posts'
|
|
|
|
bound = @connection.quote(point)
|
|
source += ' LEFT JOIN tag_versions parent_version ON parent_version.tag_id = ' + id +
|
|
" AND parent_version.created_at <= #{ bound } AND " +
|
|
HistoryAccess.sql(TagVersion, 'parent_version') +
|
|
' AND NOT EXISTS (SELECT 1 FROM tag_versions newer_parent ' +
|
|
'WHERE newer_parent.tag_id = parent_version.tag_id ' +
|
|
"AND newer_parent.created_at <= #{ bound } " +
|
|
'AND newer_parent.version_no > parent_version.version_no)'
|
|
[source, id]
|
|
end
|
|
|
|
def post_tag_source
|
|
source = json_table('v.tags_json', '$[*]', 'tag_snapshot')
|
|
source += ' LEFT JOIN tag_versions referenced_tag ON referenced_tag.tag_id = ' +
|
|
scalar('tag_snapshot.item', 'tag_id', :integer) +
|
|
' AND referenced_tag.version_no = ' + scalar('tag_snapshot.item', 'version_no', :integer) +
|
|
" AND referenced_tag.created_at <= #{ @connection.quote(point) } AND " +
|
|
HistoryAccess.sql(TagVersion, 'referenced_tag')
|
|
source
|
|
end
|
|
|
|
def language_field(source, document, parts, inherited_language = nil)
|
|
return [source, scalar(document, 'name')] if parts.empty?
|
|
return [source, inherited_language] if parts == ['languageCode'] && inherited_language
|
|
|
|
if parts.first == 'aliases'
|
|
aliases = "JSON_EXTRACT(#{ document }, '$.aliases')"
|
|
source += ' JOIN ' + json_table(aliases, '$[*]', 'alias_entry')
|
|
child = parts[1] || 'name'
|
|
return [source, inherited_language] if child == 'languageCode' && inherited_language
|
|
columns = { 'scriptCode' => 'script_code', 'name' => 'name' }
|
|
key = columns.fetch(child) { raise ArgumentError }
|
|
return [source, scalar('alias_entry.item', key)]
|
|
end
|
|
key = { 'name' => 'name', 'scriptCode' => 'script_code',
|
|
'autoGenerated' => 'auto_generated' }.fetch(parts.first) { raise ArgumentError }
|
|
[source, scalar(document, key, CHILD_TYPES.fetch(parts.first, :string))]
|
|
end
|
|
|
|
def source_for(key)
|
|
root, *parts = key.split('.')
|
|
return [nil, "(#{ @population.latest_sql })"] if root == 'is_latest'
|
|
if root == 'user' && parts.any?
|
|
expression = parts.first == 'id' ? 'v.created_by_user_id' :
|
|
'(SELECT actor.name FROM users actor WHERE actor.id = v.created_by_user_id)'
|
|
return [nil, expression]
|
|
end
|
|
return [nil, 'v.thumbnail_base'] if root == 'thumbnail'
|
|
return [nil, 'v.deprecated_at'] if root == 'deprecated'
|
|
if root == 'parents'
|
|
source, id = parent_source
|
|
return [source, parts.first == 'name' ? name_expression('parent_version') : id]
|
|
end
|
|
if root == 'tags' || root == 'tags_json'
|
|
return post_field(parts)
|
|
end
|
|
if root == 'names' || root == 'aliases'
|
|
source, document, language = names_source('v.names', 'name_language')
|
|
parts = ['aliases', *parts] if root == 'aliases'
|
|
return language_field(source, document, parts, language)
|
|
end
|
|
if root == 'members' || root == 'placements'
|
|
path = '$[*]' + parts.take_while { _1 == 'children' }.map { '.children[*]' }.join
|
|
source = json_table("v.#{ root }", path, 'structure_entry')
|
|
if root == 'members' && parts.empty?
|
|
value = "COALESCE(#{ scalar('structure_entry.item', 'tag_id', :integer) }, " +
|
|
"#{ scalar('structure_entry.item', 'tag_group_id', :integer) })"
|
|
return [source, value]
|
|
end
|
|
leaf = parts.last || 'sectionName'
|
|
leaf = 'sectionName' if leaf == 'children'
|
|
column = leaf.gsub(/[A-Z]/) { "_#{ _1.downcase }" }
|
|
return [source, scalar('structure_entry.item', column, CHILD_TYPES.fetch(leaf, :string))]
|
|
end
|
|
if root == 'export_paths_json'
|
|
source, value, profile = names_source('v.export_paths_json', 'export_profile')
|
|
return [source, parts.first == 'profile' ? profile : "JSON_UNQUOTE(#{ value })"]
|
|
end
|
|
return [nil, name_expression('v')] if root == 'name' && @definition.resource == 'tags'
|
|
|
|
[nil, @definition.expression(root)]
|
|
end
|
|
|
|
def post_field(parts)
|
|
source = post_tag_source
|
|
document = 'tag_snapshot.item'
|
|
case parts.first
|
|
when 'names'
|
|
names, entry, language = names_source('referenced_tag.names', 'tag_language')
|
|
language_field("#{ source } JOIN #{ names }", entry, parts.drop(1), language)
|
|
when 'sections'
|
|
sections = "JSON_EXTRACT(#{ document }, '$.sections')"
|
|
source += ' JOIN ' + json_table(sections, '$[*]', 'section_entry')
|
|
[source, 'section_entry.item']
|
|
when 'id'
|
|
[source, "COALESCE(#{ scalar(document, 'tag_id', :integer) }, " +
|
|
"#{ scalar(document, 'external_tag_id', :integer) })"]
|
|
when 'versionNo' then [source, scalar(document, 'version_no', :integer)]
|
|
when 'kind'
|
|
[source, "CASE WHEN #{ scalar(document, 'tag_id') } IS NULL " +
|
|
"THEN 'external' ELSE 'internal' END"]
|
|
when 'category'
|
|
[source, "COALESCE(#{ scalar(document, 'category') }, referenced_tag.category)"]
|
|
else
|
|
[source, "COALESCE(#{ scalar(document, 'name') }, referenced_tag.name)"]
|
|
end
|
|
end
|
|
|
|
def interval(scope, source, document, bounds)
|
|
raise ArgumentError if bounds.compact.any?(&:negative?)
|
|
raise ArgumentError if bounds.all? && bounds.first > bounds.last
|
|
|
|
first = scalar(document, 'begin_ms', :integer)
|
|
last = scalar(document, 'end_ms', :integer)
|
|
clauses = []
|
|
values = []
|
|
if bounds.first
|
|
clauses << "(#{ last } IS NULL OR #{ last } > ?)"
|
|
values << bounds.first
|
|
end
|
|
if bounds.last
|
|
clauses << "#{ first } < ?"
|
|
values << bounds.last
|
|
end
|
|
scope.where("EXISTS (SELECT 1 FROM #{ source } WHERE #{ clauses.join(' AND ') })", *values)
|
|
end
|
|
end
|