このコミットが含まれているのは:
@@ -33,8 +33,8 @@ class CountsAxes
|
||||
def initialize(population)
|
||||
@population = population
|
||||
@definition = population.definition
|
||||
@rows = parse(population.params.fetch('rows', '[]'))
|
||||
@columns = parse(population.params.fetch('cols', '[]'))
|
||||
@rows = parse(population.params.fetch('rows', ''))
|
||||
@columns = parse(population.params.fetch('cols', ''))
|
||||
@all = rows + columns
|
||||
raise ArgumentError if all.length > 8 || all.map { _1['field'] }.uniq.length != all.length
|
||||
end
|
||||
@@ -81,7 +81,7 @@ class CountsAxes
|
||||
private
|
||||
|
||||
def parse(value)
|
||||
axes = JSON.parse(value)
|
||||
axes = QueryState.axes(value)
|
||||
raise ArgumentError unless axes.is_a?(Array)
|
||||
|
||||
axes.each do |axis|
|
||||
|
||||
@@ -6,16 +6,23 @@ class CountsDefaults
|
||||
|
||||
def self.entries(resource)
|
||||
result = [{ definition: 'history', mode: 'summary', params: {
|
||||
'rows' => [{ field: 'created_at', grain: 'month' }].to_json,
|
||||
'cols' => [{ field: 'event_type' }].to_json,
|
||||
'sort' => 'a0:desc', 'conditions' => '[]' } }]
|
||||
'rows' => 'created_at:month',
|
||||
'cols' => 'event_type',
|
||||
'sort' => 'a0:desc', 'conditions' => '' } }]
|
||||
result << { definition: 'latest', mode: 'summary', params: {
|
||||
'rows' => '', 'cols' => '', 'conditions' => '', 'tags' => '' } }
|
||||
if resource == 'posts'
|
||||
result += [{ definition: 'latest', mode: 'summary', params: {
|
||||
'rows' => '[]', 'cols' => '[]', 'conditions' => '[]', 'tags' => '' } },
|
||||
{ definition: 'latest', mode: 'details', params: {
|
||||
'columns' => POST_COLUMNS.join(','), 'order' => POST_COLUMNS.join(','),
|
||||
'children' => 'tags.name,parents.id', 'nested_json' => '0',
|
||||
'conditions' => '[]', 'tags' => '' } }]
|
||||
result += [{ definition: 'latest', mode: 'details', params: {
|
||||
'columns' => POST_COLUMNS.join(' '), 'order' => POST_COLUMNS.join(' '),
|
||||
'children' => 'tags.name parents.id', 'nested_json' => '0',
|
||||
'conditions' => '', 'tags' => '' } }]
|
||||
end
|
||||
if resource == 'tags'
|
||||
columns = ['resource_id', 'version_no', 'is_latest', 'name', 'category',
|
||||
'aliases', 'parents', 'deprecated', 'event_type', 'user', 'created_at']
|
||||
result << { definition: 'latest', mode: 'details', params: {
|
||||
'columns' => columns.join(' '), 'order' => columns.join(' '),
|
||||
'children' => 'aliases.name parents.name', 'nested_json' => '0' } }
|
||||
end
|
||||
result
|
||||
end
|
||||
@@ -26,8 +33,8 @@ class CountsDefaults
|
||||
entry[:mode] == params.fetch('mode', 'summary')
|
||||
end&.fetch(:params) || {}
|
||||
if settings.key?('sort') && params.key?('rows')
|
||||
actual = JSON.parse(params['rows'])
|
||||
expected = JSON.parse(settings.fetch('rows'))
|
||||
actual = QueryState.axes(params['rows'])
|
||||
expected = QueryState.axes(settings.fetch('rows'))
|
||||
compatible = actual.is_a?(Array) && actual.first.is_a?(Hash) &&
|
||||
actual.first['field'] == expected.first['field'] &&
|
||||
actual.first.fetch('grain', 'month') == expected.first['grain']
|
||||
|
||||
@@ -20,10 +20,11 @@ class CountsExportDownload
|
||||
|
||||
def self.prepare(exporter, owner, progress: nil, check: nil, identifier: nil)
|
||||
FileUtils.mkdir_p(directory, mode: 0o700)
|
||||
authority = HistoryAccess.authority
|
||||
slot = acquire_slot
|
||||
|
||||
begin
|
||||
generate(exporter, owner, progress:, check:, identifier:)
|
||||
generate(exporter, owner, progress:, check:, identifier:, authority:)
|
||||
ensure
|
||||
slot.close
|
||||
end
|
||||
@@ -40,7 +41,7 @@ class CountsExportDownload
|
||||
raise Busy
|
||||
end
|
||||
|
||||
def self.generate(exporter, owner, progress: nil, check: nil, identifier: nil)
|
||||
def self.generate(exporter, owner, progress: nil, check: nil, identifier: nil, authority: HistoryAccess.authority)
|
||||
FileUtils.mkdir_p(directory, mode: 0o700)
|
||||
Dir.glob(directory.join('counts-*.export')).each do |path|
|
||||
File.unlink(path) if File.mtime(path) < Time.current - EXPIRY
|
||||
@@ -80,7 +81,7 @@ class CountsExportDownload
|
||||
path = finished_path
|
||||
token = verifier.generate({ 'file' => File.basename(path),
|
||||
'resource' => exporter.resource, 'format' => exporter.format,
|
||||
'owner' => owner }, expires_in: EXPIRY, purpose: 'counts_export')
|
||||
'owner' => owner, **authority }, expires_in: EXPIRY, purpose: 'counts_export')
|
||||
complete = true
|
||||
token
|
||||
ensure
|
||||
@@ -92,6 +93,7 @@ class CountsExportDownload
|
||||
def self.find(token, owner)
|
||||
metadata = verifier.verified(token, purpose: 'counts_export')
|
||||
return unless metadata.is_a?(Hash) && metadata['owner'] == owner
|
||||
return unless HistoryAccess.authorised_artifact?(metadata)
|
||||
return unless metadata['file']&.match?(/\Acounts-[a-zA-Z0-9_-]+\.export\z/)
|
||||
return unless CountsResourceDefinition::DEFINITIONS.key?(metadata['resource'])
|
||||
return unless ['json', 'csv'].include?(metadata['format'])
|
||||
|
||||
@@ -103,6 +103,7 @@ class CountsExportJobs
|
||||
'language' => language, 'resource' => exporter.resource,
|
||||
'format' => exporter.format, 'status' => 'queued',
|
||||
'processed' => 0, 'total' => nil,
|
||||
**HistoryAccess.authority,
|
||||
'expires_at' => (Time.current + CountsExportDownload::EXPIRY).to_f }
|
||||
write(state)
|
||||
verifier.generate({ 'id' => id, 'owner' => owner },
|
||||
@@ -126,6 +127,8 @@ class CountsExportJobs
|
||||
state = read(id)
|
||||
return unless state && state['owner'] == owner && state['expires_at'] > Time.current.to_f
|
||||
|
||||
return unless HistoryAccess.authorised_artifact?(state)
|
||||
|
||||
if cancel && ['queued', 'running', 'ready'].include?(state['status'])
|
||||
state['status'] = state['status'] == 'running' ? 'cancelling' : 'cancelled'
|
||||
remove_download(state)
|
||||
@@ -182,6 +185,22 @@ class CountsExportJobs
|
||||
end
|
||||
|
||||
def self.perform(state)
|
||||
actor = state['actor_id'] && User.find_by(id: state['actor_id'])
|
||||
actor = nil unless state['admin'] && actor&.admin? && !actor.banned?
|
||||
HistoryAccess.set(user: actor) do
|
||||
unless HistoryAccess.authorised_artifact?(state)
|
||||
synchronise do
|
||||
current = read(state.fetch('id'))
|
||||
current['status'] = 'failed'
|
||||
write(current)
|
||||
end
|
||||
return
|
||||
end
|
||||
perform_authorised(state)
|
||||
end
|
||||
end
|
||||
|
||||
def self.perform_authorised(state)
|
||||
id = state.fetch('id')
|
||||
token = nil
|
||||
begin
|
||||
|
||||
@@ -21,7 +21,7 @@ class CountsExportProjection
|
||||
'tags' => ['id', 'versionNo', 'kind', 'name', 'category', 'sections', 'names'],
|
||||
'names' => NAME_FIELDS,
|
||||
'aliases' => ['languageCode', 'name', 'scriptCode'],
|
||||
'parents' => ['id'] }.freeze
|
||||
'parents' => ['id', 'name'] }.freeze
|
||||
|
||||
def self.default_columns(resource)
|
||||
fields = { 'posts' => ['title', 'url', 'tags', 'parents', 'video_ms'],
|
||||
@@ -56,7 +56,7 @@ class CountsExportProjection
|
||||
CountsQuery::COLUMNS.fetch(resource)
|
||||
end
|
||||
defaults = summary ? allowed : self.class.default_columns(resource)
|
||||
@columns = params.fetch('columns', defaults.join(',')).split(',')
|
||||
@columns = QueryState.list(params.fetch('columns', defaults.join(' ')))
|
||||
raise ArgumentError if (@columns - allowed).any? || @columns.uniq != @columns
|
||||
|
||||
@headers = JSON.parse(params.fetch('headers', JSON.generate(@columns)))
|
||||
@@ -64,8 +64,8 @@ class CountsExportProjection
|
||||
@headers.all? { _1.is_a?(String) && _1.length <= 200 })
|
||||
raise ArgumentError
|
||||
end
|
||||
@order = params.fetch('order', '').split(',')
|
||||
@sorts = params.fetch('child_sort', '').split(',').map do |entry|
|
||||
@order = QueryState.list(params.fetch('order', ''))
|
||||
@sorts = QueryState.list(params.fetch('child_sort', '')).map do |entry|
|
||||
parts = entry.split(':', -1)
|
||||
raise ArgumentError unless parts.length == 2
|
||||
|
||||
@@ -74,12 +74,28 @@ class CountsExportProjection
|
||||
|
||||
[path, direction]
|
||||
end
|
||||
@selected = params.key?('children') ? params['children'].split(',').to_set :
|
||||
@selected = params.key?('children') ? QueryState.list(params['children']).to_set :
|
||||
self.class.default_children(resource).to_set
|
||||
valid = CHILDREN.flat_map do |key, fields|
|
||||
paths(fields, key)
|
||||
end
|
||||
raise ArgumentError if @selected && (@selected.to_a - valid).any?
|
||||
prune = lambda do |fields, prefix|
|
||||
fields.each do |key|
|
||||
path = "#{ prefix }.#{ key }"
|
||||
children = fields_for(key)
|
||||
next if children.empty?
|
||||
|
||||
prune.call(children, path)
|
||||
@selected.delete(path) unless children.any? { @selected.include?("#{ path }.#{ _1 }") }
|
||||
end
|
||||
end
|
||||
CHILDREN.each { |key, fields| prune.call(fields, key) }
|
||||
pairs = @columns.zip(@headers).select do |key, _header|
|
||||
!CHILDREN.key?(key) || CHILDREN[key].any? { @selected.include?("#{ key }.#{ _1 }") }
|
||||
end
|
||||
@columns = pairs.map(&:first)
|
||||
@headers = pairs.map(&:last)
|
||||
allowed_sorts = valid.map do |path|
|
||||
parts = path.split('.')
|
||||
field = parts.pop
|
||||
|
||||
@@ -14,7 +14,7 @@ class CountsExporter
|
||||
return
|
||||
end
|
||||
allowed = (CountsQuery::KEYS + CountsStateQuery::KEYS + EXTRA_KEYS).uniq
|
||||
raise ArgumentError if (params.keys - allowed).any?
|
||||
QueryState.validate!(params, allowed)
|
||||
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||
|
||||
@params = params
|
||||
@@ -65,8 +65,8 @@ class CountsExporter
|
||||
sort: export_sort,
|
||||
child_sort: sort_metadata(@params.fetch('child_sort', '')),
|
||||
columns: @projection.metadata,
|
||||
children: @params['children']&.split(','),
|
||||
field_order: @params.fetch('order', '').split(','), timezone: 'UTC',
|
||||
children: @params['children']&.split(' '),
|
||||
field_order: @QueryState.list(params.fetch('order', '')), timezone: 'UTC',
|
||||
count: @mode == 'summary' ? page[:group_count] : page[:count],
|
||||
totals: page[:totals], coverage: page[:coverage] }
|
||||
output << (format == 'json' ? "{\"metadata\":#{ meta.to_json },\"data\":[" : "\uFEFF")
|
||||
@@ -103,7 +103,7 @@ class CountsExporter
|
||||
end
|
||||
|
||||
def sort_metadata(value)
|
||||
value.split(',').map do |entry|
|
||||
value.split(' ').map do |entry|
|
||||
key, direction = entry.split(':', 2)
|
||||
{ key:, direction: }
|
||||
end
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
class CountsPivotLayout
|
||||
def self.depths(keys, sorts, measures: true)
|
||||
return [] if keys.length < 2 || sorts.first&.first == 'count' ||
|
||||
sorts.first&.first&.start_with?('measure_')
|
||||
sorts.first&.first&.match?(/\Am[0-9]+\z/)
|
||||
|
||||
relevant = sorts.map(&:first).select { keys.include?(_1) || (measures && (_1 == 'count' ||
|
||||
_1.start_with?('measure_'))) }
|
||||
_1.match?(/\Am[0-9]+\z/))) }
|
||||
order = (relevant + keys).uniq
|
||||
(1...keys.length).select { order.first(_1).sort == keys.first(_1).sort }
|
||||
end
|
||||
|
||||
@@ -14,6 +14,7 @@ class CountsPopulation
|
||||
|
||||
@scope = definition.model.all
|
||||
make_population
|
||||
@scope = HistoryAccess.visible(@scope, 'v')
|
||||
apply_filters
|
||||
rescue TZInfo::InvalidTimezoneIdentifier
|
||||
raise ArgumentError
|
||||
@@ -62,7 +63,8 @@ class CountsPopulation
|
||||
@scope = @scope.from("#{ table } v").select('v.*')
|
||||
keys = definition.keys.map { "previous.#{ _1 } = v.#{ _1 }" }.join(' AND ')
|
||||
@scope = @scope.joins("LEFT JOIN #{ table } previous ON #{ keys } " +
|
||||
'AND previous.version_no = v.version_no - 1')
|
||||
'AND previous.version_no = v.version_no - 1 AND ' +
|
||||
HistoryAccess.sql(definition.model, 'previous'))
|
||||
@scope = @scope.where("v.#{ definition.time_column } <= ?", at)
|
||||
unless history
|
||||
point = timestamp(params['at']) || at
|
||||
@@ -92,11 +94,11 @@ class CountsPopulation
|
||||
@scope = @scope.where("#{ definition.expression(key) } = ?", value.to_i)
|
||||
end
|
||||
apply_tags if params['tags'].present? || params['tag'].present?
|
||||
conditions = JSON.parse(params.fetch('conditions', '[]'))
|
||||
conditions = QueryState.records(params, 'conditions')
|
||||
raise ArgumentError unless conditions.is_a?(Array) && conditions.length <= 100
|
||||
|
||||
conditions.each { apply_condition(_1) }
|
||||
drills = JSON.parse(params.fetch('drill', '[]'))
|
||||
drills = QueryState.records(params, 'drill')
|
||||
raise ArgumentError unless drills.is_a?(Array) && drills.length <= 100
|
||||
|
||||
drills.each do |entry|
|
||||
@@ -190,7 +192,7 @@ class CountsPopulation
|
||||
return unless params['tags'].present?
|
||||
|
||||
locale = Locale.find_by!(code: params.fetch('locale', 'ja'))
|
||||
refs = PostTagSearch.parse_references(params['tag_refs'], params['tags'].split)
|
||||
refs = PostTagSearch.parse_references(params, params['tags'].split)
|
||||
raw = params['tags']
|
||||
literals = PostTagSearch.literals(locale, raw, references: refs)
|
||||
predicates = literals.map do |literal|
|
||||
|
||||
@@ -20,6 +20,7 @@ class CountsQuery
|
||||
@id_column = resource == 'posts' ? 'post_id' : 'tag_id'
|
||||
@params = params
|
||||
@latest_expression = latest_expression
|
||||
scope = HistoryAccess.visible(scope)
|
||||
@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?
|
||||
@@ -33,7 +34,7 @@ class CountsQuery
|
||||
@limit = integer('limit', 20)
|
||||
raise ArgumentError if limit > 100 || page > 1_000_000
|
||||
|
||||
columns = params.fetch('columns', COLUMNS.fetch(resource).join(',')).split(',')
|
||||
columns = QueryState.list(params.fetch('columns', COLUMNS.fetch(resource).join(' ')))
|
||||
if columns.empty? || columns.uniq != columns || (columns - COLUMNS.fetch(resource)).any?
|
||||
raise ArgumentError
|
||||
end
|
||||
@@ -92,7 +93,7 @@ class CountsQuery
|
||||
value = @params.fetch('sort', mode == 'details' ? 'created_at:desc' : 'group:asc')
|
||||
return [] if value.empty?
|
||||
|
||||
values = value.split(',', -1).map { _1.split(':', -1) }
|
||||
values = QueryState.list(value).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)
|
||||
|
||||
@@ -46,11 +46,12 @@ class CountsStateQuery
|
||||
# Keep totals, rows, and detail pages on one repeatable-read database snapshot.
|
||||
ApplicationRecord.transaction do
|
||||
unless @history_loaded
|
||||
@post_start = PostVersion.minimum(:created_at)
|
||||
@tag_start = TagVersion.minimum(:created_at)
|
||||
@post_start = HistoryAccess.visible(PostVersion.all).minimum(:created_at)
|
||||
@tag_start = HistoryAccess.visible(TagVersion.all).minimum(:created_at)
|
||||
@history_loaded = true
|
||||
end
|
||||
result = { page: @page, limit: @limit, at: @at.iso8601(6),
|
||||
result = { population_count: HistoryAccess.population_count(
|
||||
@resource == 'posts' ? PostVersion : TagVersion), page: @page, limit: @limit, at: @at.iso8601(6),
|
||||
coverage: { basis: 'recorded_snapshots', exact: false,
|
||||
post_history_from: @post_start&.iso8601(6),
|
||||
tag_history_from: @tag_start&.iso8601(6) } }
|
||||
@@ -131,7 +132,10 @@ class CountsStateQuery
|
||||
end
|
||||
|
||||
def state_sql(table, boundary, live: false)
|
||||
sql = "SELECT v.* FROM #{ table } v WHERE #{ latest_condition(table, 'v', boundary) }"
|
||||
model = { 'post_versions' => PostVersion, 'tag_versions' => TagVersion,
|
||||
'nico_tag_versions' => NicoTagVersion }.fetch(table)
|
||||
sql = "SELECT v.* FROM #{ table } v WHERE #{ latest_condition(table, 'v', boundary) } " +
|
||||
"AND (#{ HistoryAccess.sql(model, 'v') })"
|
||||
live ? "#{ sql } AND v.event_type <> 'discard'" : sql
|
||||
end
|
||||
|
||||
@@ -350,7 +354,7 @@ class CountsStateQuery
|
||||
|
||||
def order_sql(expressions, default, final)
|
||||
raw = @params.fetch('sort', default)
|
||||
sorts = raw.empty? ? [] : raw.split(',', -1).map { _1.split(':', -1) }
|
||||
sorts = raw.empty? ? [] : QueryState.list(raw).map { _1.split(':', -1) }
|
||||
raise ArgumentError if sorts.map(&:first).uniq.length != sorts.length
|
||||
raise ArgumentError if sorts.length > expressions.length
|
||||
|
||||
|
||||
@@ -7,20 +7,20 @@ class CountsTableExport
|
||||
attr_reader :format, :resource
|
||||
|
||||
def initialize(params)
|
||||
raise ArgumentError if (params.keys - CountsTableQuery::KEYS - EXTRA).any?
|
||||
QueryState.validate!(params, CountsTableQuery::KEYS + EXTRA + ['measures'])
|
||||
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||
|
||||
params = CountsDefaults.for(params).merge(params)
|
||||
unless params.key?('children')
|
||||
params = params.merge('children' => CountsExportProjection.default_children(
|
||||
params.fetch('resource', 'posts')).join(','))
|
||||
params.fetch('resource', 'posts')).join(' '))
|
||||
end
|
||||
@params = params
|
||||
@labels = JSON.parse(params.fetch('labels', '{}'))
|
||||
raise ArgumentError unless @labels.is_a?(Hash)
|
||||
@selected = params.key?('children') ? params['children'].split(',') : nil
|
||||
@order = params.fetch('order', '').split(',')
|
||||
@child_sort = params.fetch('child_sort', '').split(',').map { _1.split(':', -1) }
|
||||
@selected = params.key?('children') ? QueryState.list(params['children']) : nil
|
||||
@order = QueryState.list(params.fetch('order', ''))
|
||||
@child_sort = QueryState.list(params.fetch('child_sort', '')).map { _1.split(':', -1) }
|
||||
@child_sort.each do |path, direction, *extra|
|
||||
raise ArgumentError if extra.any? || !['asc', 'desc'].include?(direction)
|
||||
raise ArgumentError unless path&.match?(/\A[a-zA-Z_]+(?:\.[a-zA-Z]+)*\/[a-zA-Z]+\z/)
|
||||
@@ -29,12 +29,13 @@ class CountsTableExport
|
||||
@resource = params.fetch('resource', 'posts')
|
||||
raise ArgumentError unless ['json', 'csv'].include?(format)
|
||||
|
||||
@query = CountsTableQuery.new(params.slice(*CountsTableQuery::KEYS)
|
||||
@query = CountsTableQuery.new(params.select { |key, _value| CountsTableQuery::KEYS.include?(key) ||
|
||||
key == 'measures' || QueryState.valid_key?(key) }
|
||||
.merge('page' => '1', 'limit' => '100'))
|
||||
@summary = @query.mode == 'summary'
|
||||
default_columns = @summary ? @query.axes.all.map { _1.fetch('field') } + ['count'] :
|
||||
CountsExportProjection.default_columns(resource)
|
||||
@columns = params.fetch('columns', default_columns.join(',')).split(',')
|
||||
@columns = QueryState.list(params.fetch('columns', default_columns.join(' ')))
|
||||
default_headers = @columns.map { @labels.fetch(_1, _1) }
|
||||
@headers = JSON.parse(params.fetch('headers', default_headers.to_json))
|
||||
raise ArgumentError unless @headers.is_a?(Array) && @headers.all? { _1.is_a?(String) }
|
||||
@@ -66,7 +67,8 @@ class CountsTableExport
|
||||
unless started
|
||||
metadata = page.except(:versions, :cells, :axis_labels, :column_values,
|
||||
:row_headers, :column_headers, :pivot_totals).merge(
|
||||
resource:, filters: @params.slice(*CountsTableQuery::KEYS),
|
||||
resource:, filters: @params.select { |key, _value| CountsTableQuery::KEYS.include?(key) ||
|
||||
key == 'measures' || QueryState.valid_key?(key) },
|
||||
fields: @query.population.definition.metadata,
|
||||
columns: @columns, column_labels: @headers,
|
||||
order: @params['order'], children: @params['children'],
|
||||
@@ -138,13 +140,23 @@ class CountsTableExport
|
||||
end
|
||||
|
||||
def total_label(header)
|
||||
@labels.fetch(header[:kind] == 'grand_total' ? 'grandTotal' : 'subtotal', '')
|
||||
key = header[:kind] == 'grand_total' ?
|
||||
(@query.axes.all.length == 1 ? 'singleTotal' : 'grandTotal') : 'subtotal'
|
||||
@labels.fetch(key, '')
|
||||
end
|
||||
|
||||
def pivot_row_labels(header, page)
|
||||
values = header[:values].each_with_index.map { |value, index|
|
||||
summary_value(value, index, page)
|
||||
}
|
||||
if header[:kind] == 'data' && @previous_pivot_row&.fetch(:kind) == 'data'
|
||||
values.each_index do |index|
|
||||
if header[:values].first(index + 1) == @previous_pivot_row[:values].first(index + 1)
|
||||
values[index] = ''
|
||||
end
|
||||
end
|
||||
end
|
||||
@previous_pivot_row = header
|
||||
values << total_label(header) unless header[:kind] == 'data'
|
||||
leading = [@query.axes.rows.length, @query.axes.columns.any? ? 1 : 0].max
|
||||
Array.new(leading) { values[_1] || '' }
|
||||
|
||||
@@ -5,11 +5,11 @@ 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'].freeze
|
||||
'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale', 'measures', 'column_order'].freeze
|
||||
attr_reader :population, :axes, :page, :limit, :mode
|
||||
|
||||
def initialize(params)
|
||||
raise ArgumentError if (params.keys - KEYS).any?
|
||||
QueryState.validate!(params, KEYS + ['measures'])
|
||||
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||
|
||||
params = CountsDefaults.for(params).slice(*KEYS).merge(params)
|
||||
@@ -24,14 +24,14 @@ class CountsTableQuery
|
||||
raise ArgumentError unless page.between?(1, 1_000_000) && limit.between?(1, 100)
|
||||
raise ArgumentError unless ['summary', 'details'].include?(mode)
|
||||
|
||||
@sort = params.fetch('sort', '').split(',').map { _1.split(':', -1) }
|
||||
@sort = QueryState.list(params.fetch('sort', '')).map { _1.split(':', -1) }
|
||||
allowed = mode == 'summary' ? axes.all.each_index.map { "a#{ _1 }" } + ['count'] :
|
||||
@definition.fields.keys + ['is_latest'] +
|
||||
(@definition.resource == 'posts' ? ['tags', 'parents'] : []) +
|
||||
(@definition.resource == 'tags' ? ['parents', 'deprecated'] : [])
|
||||
raise ArgumentError if @sort.map(&:first).uniq.length != @sort.length
|
||||
@sort.each do |key, direction, *extra|
|
||||
measure = mode == 'summary' && key&.match?(/\Ameasure_[0-9a-f]+\z/)
|
||||
measure = mode == 'summary' && key&.match?(/\Am[0-9]{1,2}\z/)
|
||||
measure_values(key) if measure
|
||||
if extra.any? || (!allowed.include?(key) && !measure) || !['asc', 'desc'].include?(direction)
|
||||
raise ArgumentError
|
||||
@@ -46,7 +46,8 @@ class CountsTableQuery
|
||||
end
|
||||
|
||||
def metadata
|
||||
{ page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
|
||||
{ population_count_scope: 'all_recorded_versions', population_count: HistoryAccess.population_count(@definition.model),
|
||||
page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
|
||||
definition: population.history ? 'history' : 'latest',
|
||||
timezone: population.timezone.identifier, rows: axes.rows, cols: axes.columns,
|
||||
fields: @definition.metadata,
|
||||
@@ -76,7 +77,7 @@ class CountsTableQuery
|
||||
measure_joins = []
|
||||
extras = []
|
||||
row_sort = @sort.each_with_index.filter_map do |(key, direction), index|
|
||||
if key == 'count' || key.start_with?('measure_')
|
||||
if key == 'count' || key.match?(/\Am[0-9]+\z/)
|
||||
values = key == 'count' ? [] : measure_values(key)
|
||||
relation = measure_relation(values)
|
||||
matches = row_keys.map { "grouped.#{ _1 } <=> sort#{ index }.#{ _1 }" }
|
||||
@@ -125,7 +126,12 @@ class CountsTableQuery
|
||||
row_values = records.map { |record| row_keys.map { record[_1] } }.uniq
|
||||
row_depths = CountsPivotLayout.depths(row_keys, @sort)
|
||||
column_keys = axes.columns.each_index.map { "a#{ axes.rows.length + _1 }" }
|
||||
column_depths = CountsPivotLayout.depths(column_keys, @sort, measures: false)
|
||||
column_sort = @sort
|
||||
if @params.key?('column_order') && column_keys.any? &&
|
||||
!(@sort.first&.first == 'count' || @sort.first&.first&.match?(/\Am[0-9]+\z/))
|
||||
column_sort = [[column_keys.first, 'asc']] + @sort
|
||||
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?)
|
||||
if axes.rows.empty? && axes.columns.any?
|
||||
@@ -197,6 +203,15 @@ class CountsTableQuery
|
||||
order = directions.map { |key, direction|
|
||||
"#{ key } IS NULL ASC, #{ key } #{ direction.upcase }"
|
||||
}.join(', ')
|
||||
custom = QueryState.records(@params, 'column_order')
|
||||
if custom.any?
|
||||
raise ArgumentError unless custom.map { _1['value'] }.uniq.length == custom.length
|
||||
|
||||
cases = custom.each_with_index.map do |record, index|
|
||||
"WHEN #{ keys.first } <=> #{ @connection.quote(record['value']) } THEN #{ index }"
|
||||
end.join(' ')
|
||||
order = "CASE #{ cases } ELSE #{ custom.length } END, #{ order }"
|
||||
end
|
||||
values = @connection.select_rows(<<~SQL)
|
||||
#{ prefix }
|
||||
SELECT /*+ MAX_EXECUTION_TIME(10000) */ DISTINCT #{ keys.join(', ') } FROM cells
|
||||
@@ -225,7 +240,11 @@ class CountsTableQuery
|
||||
end
|
||||
|
||||
def measure_values(key)
|
||||
value = JSON.parse([key.delete_prefix('measure_')].pack('H*'))
|
||||
records = QueryState.records(@params, 'measures').select { _1['key'] == key }
|
||||
raise ArgumentError if records.empty?
|
||||
raise ArgumentError unless records.map { _1['axis'] }.sort == (0...records.length).to_a
|
||||
|
||||
value = records.sort_by { _1['axis'] }.map { _1['value'] }
|
||||
unless (value.is_a?(Array) && value.length <= axes.columns.length &&
|
||||
value.all? { _1.nil? || _1.is_a?(String) || _1.is_a?(Numeric) })
|
||||
raise ArgumentError
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Shared literal parsing and reference resolution for current and snapshot post search.
|
||||
class PostTagSearch
|
||||
def self.parse_references(raw, literals)
|
||||
return nil if raw.blank?
|
||||
return nil unless raw.key?('tag_refs')
|
||||
|
||||
references = JSON.parse(raw)
|
||||
references = QueryState.records(raw, 'tag_refs')
|
||||
valid = references.is_a?(Array) && references.all? do |reference|
|
||||
next false unless reference.is_a?(Hash) && reference['name'].is_a?(String)
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Public query grammar shared with frontend/src/lib/queryState.ts.
|
||||
class QueryState
|
||||
SCHEMAS = {
|
||||
'conditions' => ['field', 'operator', 'value', 'end', 'blank'],
|
||||
'drill' => ['field', 'grain', 'value', 'value_type'],
|
||||
'tag_refs' => ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
|
||||
'column_order' => ['value', 'value_type'],
|
||||
'measures' => ['key', 'axis', 'value', 'value_type'] }.freeze
|
||||
|
||||
def self.list(value)
|
||||
return [] if value.nil? || value == ''
|
||||
raise ArgumentError unless value.is_a?(String) && value.bytesize <= 32_768
|
||||
|
||||
values = value.split(/ /, -1)
|
||||
raise ArgumentError if values.length > 200 || values.any?(&:empty?) || values.uniq != values
|
||||
|
||||
values
|
||||
end
|
||||
|
||||
def self.valid_key?(key)
|
||||
root, index, field, extra = key.split('.', -1)
|
||||
!extra && SCHEMAS.key?(root) && index&.match?(/\A(?:0|[1-9][0-9]?)\z/) &&
|
||||
SCHEMAS[root].include?(field)
|
||||
end
|
||||
|
||||
def self.validate!(params, allowed)
|
||||
raise ArgumentError if params.length > 1000
|
||||
params.each do |key, value|
|
||||
raise ArgumentError unless allowed.include?(key) || valid_key?(key)
|
||||
raise ArgumentError unless value.is_a?(String) && value.bytesize <= 131_072
|
||||
end
|
||||
SCHEMAS.each_key { records(params, _1) }
|
||||
end
|
||||
|
||||
def self.records(params, key)
|
||||
records = {}
|
||||
params.each do |name, value|
|
||||
next unless name.start_with?("#{ key }.")
|
||||
raise ArgumentError unless params.key?(key) && valid_key?(name)
|
||||
|
||||
_root, index, field = name.split('.')
|
||||
record = records[index.to_i] ||= {}
|
||||
if ['blank', 'negative', 'missing'].include?(field)
|
||||
raise ArgumentError unless ['0', '1'].include?(value)
|
||||
|
||||
value = value == '1'
|
||||
elsif ['tag_id', 'external_tag_id', 'axis'].include?(field)
|
||||
raise ArgumentError unless value.match?(/\A[0-9]{1,18}\z/)
|
||||
|
||||
value = value.to_i
|
||||
end
|
||||
record[field] = value
|
||||
end
|
||||
raise ArgumentError if params.key?(key) && params[key] != ''
|
||||
raise ArgumentError unless records.keys.sort == (0...records.length).to_a
|
||||
|
||||
records.sort.map do |_index, record|
|
||||
type = record.delete('value_type')
|
||||
if ['drill', 'measures', 'column_order'].include?(key)
|
||||
raise ArgumentError unless ['null', 'string', 'number', 'boolean'].include?(type)
|
||||
|
||||
raise ArgumentError unless type == 'null' || record.key?('value')
|
||||
|
||||
value = record['value']
|
||||
record['value'] = case type
|
||||
when 'null' then nil
|
||||
when 'number'
|
||||
raise ArgumentError unless value&.match?(/\A-?[0-9]{1,18}\z/)
|
||||
Integer(value, 10)
|
||||
when 'boolean'
|
||||
raise ArgumentError unless ['true', 'false'].include?(value)
|
||||
value == 'true'
|
||||
else value
|
||||
end
|
||||
end
|
||||
record
|
||||
end
|
||||
end
|
||||
|
||||
def self.axes(value)
|
||||
list(value).map do |token|
|
||||
field, grain, extra = token.split(':', -1)
|
||||
raise ArgumentError if extra || !field.match?(/\A[a-z_]+\z/) || grain == ''
|
||||
|
||||
grain ? { 'field' => field, 'grain' => grain } : { 'field' => field }
|
||||
end
|
||||
end
|
||||
end
|
||||
新しいイシューから参照
ユーザーをブロックする