このコミットが含まれているのは:
2026-10-28 00:28:29 +09:00
コミット d6cf992622
27個のファイルの変更、630行の追加、60行の削除
+8
ファイルの表示
@@ -20,4 +20,12 @@ class CountsTablesController < ApplicationController
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
def candidates
resolve_locale!
render json: CountsCandidates.new(request.query_parameters.merge(
'resource' => request.path_parameters[:resource])).call
rescue ArgumentError, TypeError, KeyError
head :bad_request
end
end
+55
ファイルの表示
@@ -0,0 +1,55 @@
# Bounded, authorised candidates at the same recorded point as the Counts query.
class CountsCandidates
KEYS = ['resource', 'field', 'query', 'category', 'page', 'locale', 'at',
'execution_at', 'timezone'].freeze
LIMIT = 20
def initialize(params)
QueryState.validate!(params, KEYS)
raise ArgumentError unless params.fetch('resource') == 'posts'
raise ArgumentError unless ['tags', 'tags_json'].include?(params.fetch('field'))
@params = params
@page = Integer(params.fetch('page', '1'), 10)
raise ArgumentError unless @page.between?(1, 100_000)
raise ArgumentError if params.fetch('query', '').bytesize > 1000
category = params['category']
raise ArgumentError if category.present? && !Tag.categories.key?(category)
end
def call
options = @params.slice('locale', 'at', 'execution_at', 'timezone')
.merge('definition' => 'latest', 'resource' => 'tags')
internal = CountsPopulation.new(options)
external = CountsPopulation.new(options.merge('resource' => 'nico_tags',
'execution_at' => internal.at.iso8601(6)))
language = Locale.find_by!(code: @params.fetch('locale', 'ja')).language_code
connection = TagVersion.connection
path = connection.quote("$.#{ language.to_json }.name")
name = "COALESCE(JSON_UNQUOTE(JSON_EXTRACT(v.names, #{ path })), v.name)"
sources = [[internal, 'internal', name, 'v.category'],
[external, 'external', 'v.name', 'NULL']].map do |population, kind, title, category|
population.scope.except(:select).select(Arel.sql(
"CONCAT('#{ kind }:', v.tag_id) AS value, v.tag_id AS id, " +
"#{ title } AS name, #{ category } AS category, '#{ kind }' AS kind")).to_sql
end
clauses = []
binds = []
if @params['query'].present?
clauses << '(name LIKE ? OR CAST(id AS CHAR) = ?)'
binds += ["%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%", @params['query']]
end
if @params['category'].present?
clauses << 'category = ?'
binds << @params['category']
end
where = clauses.empty? ? '' : "WHERE #{ clauses.join(' AND ') }"
sql = <<~SQL
SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM (#{ sources.join(' UNION ALL ') }) choices
#{ where } ORDER BY kind, id LIMIT #{ LIMIT + 1 } OFFSET #{ (@page - 1) * LIMIT }
SQL
rows = connection.select_all(ApplicationRecord.sanitize_sql_array([sql, *binds])).to_a
{ rows: rows.first(LIMIT), page: @page, more: rows.length > LIMIT,
execution_at: internal.at.iso8601(6) }
end
end
+83 -2
ファイルの表示
@@ -44,12 +44,31 @@ class CountsCondition
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,
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) }]
result.each do |field|
next if field[:type] == :interval
if field[:multiple]
field[:operators] = field[:operators] - ['in', 'not_in'] + ['any', 'all', 'none']
end
field[:input_types] = ['single', 'multiple']
if ['category', 'tag_category'].include?(field[:key].split('.').last)
field[:candidate_source] = 'categories'
field[:choices] = Tag.categories.keys.map { |value|
{ value:, label: "common:categories.#{ value }" }
}
elsif definition.resource == 'posts' && ['tags', 'tags_json'].include?(field[:key])
field[:candidate_source] = 'tags'
field[:set_type] = 'tag_reference'
end
field[:input_types] << 'list' if field[:candidate_source]
end
result
end
def initialize(population)
@@ -61,11 +80,17 @@ class CountsCondition
def apply(scope, entry)
raise ArgumentError unless entry.is_a?(Hash)
raise ArgumentError if (entry.keys - ['field', 'operator', 'value', 'end', 'blank']).any?
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
raise ArgumentError if (entry.keys - allowed).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'])
input = entry.fetch('input', 'single')
raise ArgumentError unless ['single', 'multiple', 'list'].include?(input)
if input == 'list' && !field[:candidate_source]
raise ArgumentError
end
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] != ''
@@ -84,6 +109,10 @@ class CountsCondition
end
return scope.where(Arel.sql(sql))
end
if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
return set_condition(scope, entry, field)
end
raise ArgumentError if entry.key?('values') || input != 'single'
return scope if values.all? { _1.nil? || _1 == '' }
parsed = values.map { parse(_1, type) }
@@ -122,6 +151,58 @@ class CountsCondition
private
def set_condition(scope, entry, field)
return scope unless entry.key?('values')
values = entry['values']
raise ArgumentError unless values.is_a?(Array) && values.length <= 100
raise ArgumentError if [entry['value'], entry['end']].any? { !_1.nil? && _1 != '' }
parsed = values.map do |value|
unless (value.is_a?(String) || value.is_a?(Numeric) || [true, false].include?(value))
raise ArgumentError
end
raise ArgumentError if value.to_s.bytesize > 8192
if field[:set_type] == 'tag_reference'
raise ArgumentError unless value.is_a?(String) &&
value.match?(/\A(?:internal|external):[1-9][0-9]{0,17}\z/)
value
else
raise ArgumentError if value == '' && ![:string, :enum].include?(field[:type])
result = value == '' ? '' : parse(value.to_s, field[:type])
validate_enum!(field[:key], result) if field[:type] == :enum
result
end
end
raise ArgumentError unless parsed.uniq == parsed
operator = entry['operator']
if parsed.empty?
return ['in', 'any'].include?(operator) ? scope.none : scope
end
source, expression = source_for(field[:key])
if field[:set_type] == 'tag_reference'
source = post_tag_source
id = "COALESCE(#{ scalar('tag_snapshot.item', 'tag_id', :integer) }, " +
"#{ scalar('tag_snapshot.item', 'external_tag_id', :integer) })"
expression = "CONCAT(CASE WHEN #{ scalar('tag_snapshot.item', 'tag_id') } IS NULL " +
"THEN 'external:' ELSE 'internal:' END, #{ id })"
end
predicates = parsed.map do |_value|
clause = "#{ expression } = ?"
clause = "EXISTS (SELECT 1 FROM #{ source } WHERE #{ clause })" if source
clause
end
conjunction = operator == 'all' ? ' AND ' : ' OR '
predicate = "(#{ predicates.join(conjunction) })"
if ['not_in', 'none'].include?(operator)
predicate = source ? "NOT #{ predicate }" :
"(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })"
end
scope.where(predicate, *parsed)
end
def parse(value, type)
return nil if value.nil? || value == ''
+24 -1
ファイルの表示
@@ -10,7 +10,21 @@ class CountsPivotLayout
(1...keys.length).select { order.first(_1).sort == keys.first(_1).sort }
end
def self.headers(values, depths, next_values: nil, grand: false)
def self.label_key(row_count, column_count, row_depth, column_depth)
total = row_count + column_count
collapsed = total - row_depth - column_depth
return nil if collapsed.zero?
return 'grandTotal' if total >= 3 && collapsed == total
if (collapsed == 1 &&
((row_count > 1 && row_depth < row_count) ||
(column_count > 1 && column_depth < column_count)))
return 'subtotal'
end
'singleTotal'
end
def self.headers(values, depths, next_values: nil, grand: false, axis_count: nil,
other_count: 0, direction: :row)
result = []
values.each_with_index do |tuple, index|
result << { values: tuple, kind: 'data' }
@@ -23,6 +37,15 @@ class CountsPivotLayout
end
end
result << { values: [], kind: 'grand_total' } if grand
count = axis_count || values.first&.length || 0
result.each do |header|
depth = header[:values].length
label = if header[:kind] != 'data'
direction == :row ? label_key(count, other_count, depth, other_count) :
label_key(other_count, count, other_count, depth)
end
header.merge!(depth:, axis_count: count, label_key: label)
end
result
end
end
+3
ファイルの表示
@@ -53,6 +53,9 @@ class CountsPivotTotals
column_depth = record['column_depth'].to_i
{ row: (0...row_depth).map { record["v#{ _1 }"] },
column: (0...column_depth).map { record["v#{ row_depth + _1 }"] },
row_depth:, column_depth:,
label_key: CountsPivotLayout.label_key(@axes.rows.length, @axes.columns.length,
row_depth, column_depth),
count: record['count'].to_i }
end
end
+15 -1
ファイルの表示
@@ -103,7 +103,21 @@ class CountsPopulation
conditions = QueryState.records(params, 'conditions')
raise ArgumentError unless conditions.is_a?(Array) && conditions.length <= 100
conditions.each { apply_condition(_1) }
values = QueryState.records(params, 'condition_values')
values.each do |entry|
index = entry.fetch('condition')
raise ArgumentError unless index.between?(0, conditions.length - 1)
raise ArgumentError unless conditions[index]['values'] == true
end
conditions.each_with_index do |entry, index|
if entry.key?('values')
raise ArgumentError unless entry['values'] == true
entry = entry.merge('values' => values.select { _1['condition'] == index }
.map { _1.fetch('value') })
end
apply_condition(entry)
end
drills = QueryState.records(params, 'drill')
raise ArgumentError unless drills.is_a?(Array) && drills.length <= 100
+4 -3
ファイルの表示
@@ -46,9 +46,10 @@ class CountsResourceDefinition
'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'],
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',
+7 -4
ファイルの表示
@@ -143,8 +143,8 @@ class CountsTableExport
end
def total_label(header)
key = header[:kind] == 'grand_total' ?
(@query.axes.all.length == 1 ? 'singleTotal' : 'grandTotal') : 'subtotal'
key = header[:label_key] || (header[:kind] == 'grand_total' ?
(@query.axes.all.length == 1 ? 'singleTotal' : 'grandTotal') : 'subtotal')
@labels.fetch(key, '')
end
@@ -152,9 +152,10 @@ class CountsTableExport
values = header[:values].each_with_index.map { |value, index|
summary_value(value, index, page)
}
if header[:kind] == 'data' && @previous_pivot_row&.fetch(:kind) == 'data'
if @previous_pivot_row
values.each_index do |index|
if header[:values].first(index + 1) == @previous_pivot_row[:values].first(index + 1)
if (@previous_pivot_row[:values].length > index &&
header[:values].first(index + 1) == @previous_pivot_row[:values].first(index + 1))
values[index] = ''
end
end
@@ -234,6 +235,8 @@ class CountsTableExport
{ row: row[:values], column: column[:values], kind:,
row_kind: row[:kind], column_kind: column[:kind],
row_depth: row[:values].length, column_depth: column[:values].length,
label_key: CountsPivotLayout.label_key(@query.axes.rows.length, @query.axes.columns.length,
row[:values].length, column[:values].length),
count: @pivot_lookup.fetch([row[:values], column[:values]], 0) }
end
+11 -5
ファイルの表示
@@ -5,7 +5,8 @@ class CountsTableQuery
KEYS = ['resource', 'definition', 'mode', 'at', 'execution_at', 'from', 'through',
'timezone', 'rows', 'cols', 'conditions', 'drill', 'tags', 'tag_refs', 'match',
'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale', 'measures', 'column_order'].freeze
'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale',
'measures', 'column_order', 'condition_values'].freeze
attr_reader :population, :axes, :page, :limit, :mode
def initialize(params)
@@ -145,14 +146,19 @@ class CountsTableQuery
end
column_depths = CountsPivotLayout.depths(column_keys, column_sort, measures: false)
row_headers = CountsPivotLayout.headers(row_values, row_depths,
next_values: next_row && row_keys.map { next_row[_1] }, grand: axes.rows.any?)
next_values: next_row && row_keys.map { next_row[_1] }, grand: axes.rows.any?,
axis_count: axes.rows.length, other_count: axes.columns.length)
if axes.rows.empty? && axes.columns.any?
row_headers = [{ values: [], kind: 'grand_total' }]
row_headers = CountsPivotLayout.headers([[]], [], axis_count: 0,
other_count: axes.columns.length).map { _1.merge(kind: 'grand_total') }
end
column_headers = CountsPivotLayout.headers(column_values, column_depths,
grand: axes.columns.any?)
grand: axes.columns.any?, axis_count: axes.columns.length,
other_count: axes.rows.length, direction: :column)
if axes.columns.empty? && axes.rows.any?
column_headers = [{ values: [], kind: 'grand_total' }]
column_headers = CountsPivotLayout.headers([[]], [], axis_count: 0,
other_count: axes.rows.length, direction: :column)
.map { _1.merge(kind: 'grand_total') }
end
totals = CountsPivotTotals.new(population, axes, row_headers, column_headers).call
{ group_count: total,
+5 -4
ファイルの表示
@@ -1,7 +1,8 @@
# Public query grammar shared with frontend/src/lib/queryState.ts.
class QueryState
SCHEMAS = {
'conditions' => ['field', 'operator', 'value', 'end', 'blank'],
'conditions' => ['field', 'operator', 'value', 'end', 'blank', 'input', 'values'],
'condition_values' => ['condition', 'value', 'value_type'],
'drill' => ['field', 'grain', 'value', 'value_type'],
'tag_refs' => ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
'column_order' => ['value', 'value_type'],
@@ -40,11 +41,11 @@ class QueryState
_root, index, field = name.split('.')
record = records[index.to_i] ||= {}
if ['blank', 'negative', 'missing'].include?(field)
if ['blank', 'negative', 'missing', 'values'].include?(field)
raise ArgumentError unless ['0', '1'].include?(value)
value = value == '1'
elsif ['tag_id', 'external_tag_id', 'axis'].include?(field)
elsif ['tag_id', 'external_tag_id', 'axis', 'condition'].include?(field)
raise ArgumentError unless value.match?(/\A[0-9]{1,18}\z/)
value = value.to_i
@@ -56,7 +57,7 @@ class QueryState
records.sort.map do |_index, record|
type = record.delete('value_type')
if ['drill', 'measures', 'column_order'].include?(key)
if ['drill', 'measures', 'column_order', 'condition_values'].include?(key)
raise ArgumentError unless ['null', 'string', 'number', 'boolean'].include?(type)
raise ArgumentError unless type == 'null' || record.key?('value')
+1
ファイルの表示
@@ -1,5 +1,6 @@
Rails.application.routes.draw do
get 'counts/definitions/:resource', to: 'counts_tables#definition'
get 'counts/definitions/:resource/candidates', to: 'counts_tables#candidates'
get 'counts/query', to: 'counts_tables#index'
post 'counts/export/jobs', to: 'counts_exports#create_job'
get 'counts/export/jobs/:token', to: 'counts_exports#job_status'