このコミットが含まれているのは:
@@ -72,14 +72,27 @@ class CountsAxes
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def count_expression
|
def grouped_sql(scope, indexes = all.each_index.to_a)
|
||||||
return 'COUNT(DISTINCT v.id)' unless rows.any? { _1['field'] == 'tags' }
|
keys = indexes.map { "a#{ _1 }" }
|
||||||
|
selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" }
|
||||||
# Keep tag occurrences even when several private tags share one masked label.
|
identities = ['v.id']
|
||||||
values = ['v.id', "COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
|
if rows.any? { _1['field'] == 'tags' }
|
||||||
"CONCAT('external:', tag_entry.external_tag_id))"]
|
# Raw identities stay inside COUNT; masked tag occurrences remain additive.
|
||||||
values << 'changed.field_key' if rows.any? { _1['field'] == 'changed_field' }
|
identities << "COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
|
||||||
"COUNT(DISTINCT #{ values.join(', ') })"
|
"CONCAT('external:', tag_entry.external_tag_id))"
|
||||||
|
identities << 'changed.field_key' if rows.any? { _1['field'] == 'changed_field' }
|
||||||
|
end
|
||||||
|
members = identities.each_index.map { "counts_member_#{ _1 }" }
|
||||||
|
selected += identities.each_with_index.map { |expression, index|
|
||||||
|
"#{ expression } AS #{ members[index] }"
|
||||||
|
}
|
||||||
|
# Evaluate correlated visibility subqueries before grouping. LIMIT prevents
|
||||||
|
# derived-table merging, so ONLY_FULL_GROUP_BY sees ordinary projected columns.
|
||||||
|
projection = scope.except(:select, :order).select(Arel.sql(selected.join(', ')))
|
||||||
|
.limit(18_446_744_073_709_551_615).to_sql
|
||||||
|
fields = keys + ["COUNT(DISTINCT #{ members.join(', ') }) AS count"]
|
||||||
|
grouping = keys.empty? ? '' : " GROUP BY #{ keys.join(', ') }"
|
||||||
|
"SELECT #{ fields.join(', ') } FROM (#{ projection }) counts_values#{ grouping }"
|
||||||
end
|
end
|
||||||
|
|
||||||
def joins(fields = all.map { _1['field'] })
|
def joins(fields = all.map { _1['field'] })
|
||||||
|
|||||||
@@ -41,17 +41,16 @@ class CountsCandidates
|
|||||||
.to_sql
|
.to_sql
|
||||||
end
|
end
|
||||||
clauses = []
|
clauses = []
|
||||||
binds = []
|
|
||||||
if @params['category'].present?
|
if @params['category'].present?
|
||||||
clauses << 'category = ?'
|
clauses << ApplicationRecord.sanitize_sql_array(['category = ?', @params['category']])
|
||||||
binds << @params['category']
|
|
||||||
end
|
end
|
||||||
where = clauses.empty? ? '' : "WHERE #{ clauses.join(' AND ') }"
|
where = clauses.empty? ? '' : "WHERE #{ clauses.join(' AND ') }"
|
||||||
sql = <<~SQL
|
sql = <<~SQL
|
||||||
SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM (#{ sources.join(' UNION ALL ') }) choices
|
SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM (#{ sources.join(' UNION ALL ') }) choices
|
||||||
#{ where } ORDER BY kind, id LIMIT #{ LIMIT + 1 } OFFSET #{ (@page - 1) * LIMIT }
|
#{ where } ORDER BY kind, id LIMIT #{ LIMIT + 1 } OFFSET #{ (@page - 1) * LIMIT }
|
||||||
SQL
|
SQL
|
||||||
rows = connection.select_all(ApplicationRecord.sanitize_sql_array([sql, *binds])).to_a
|
# Sources already contain quoted search terms. Do not reinterpret their % or ? as binds.
|
||||||
|
rows = connection.select_all(sql).to_a
|
||||||
rows.each { _1['names'] = JSON.parse(_1['names']) if _1['names'].is_a?(String) }
|
rows.each { _1['names'] = JSON.parse(_1['names']) if _1['names'].is_a?(String) }
|
||||||
{ rows: rows.first(LIMIT), page: @page, more: rows.length > LIMIT,
|
{ rows: rows.first(LIMIT), page: @page, more: rows.length > LIMIT,
|
||||||
execution_at: internal.at.iso8601(6) }
|
execution_at: internal.at.iso8601(6) }
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ class CountsExporter
|
|||||||
'summary_columns', 'nested_json', 'child_sort', 'labels'].freeze
|
'summary_columns', 'nested_json', 'child_sort', 'labels'].freeze
|
||||||
attr_reader :format, :resource
|
attr_reader :format, :resource
|
||||||
|
|
||||||
|
def self.with_read_snapshot(&block)
|
||||||
|
# A caller-owned transaction already owns the read view (including fixtures).
|
||||||
|
# MySQL cannot change isolation inside it. Standalone workers always start RR.
|
||||||
|
options = ApplicationRecord.connection.transaction_open? ? {} : { isolation: :repeatable_read }
|
||||||
|
ApplicationRecord.transaction(**options, &block)
|
||||||
|
end
|
||||||
|
|
||||||
def initialize(params, language_code:)
|
def initialize(params, language_code:)
|
||||||
params = CountsResourceDefinition.normalise_params(params)
|
params = CountsResourceDefinition.normalise_params(params)
|
||||||
if params.key?('definition')
|
if params.key?('definition')
|
||||||
@@ -52,7 +59,7 @@ class CountsExporter
|
|||||||
return @table_export.body(progress:, phase:) if @table_export
|
return @table_export.body(progress:, phase:) if @table_export
|
||||||
|
|
||||||
Enumerator.new do |output|
|
Enumerator.new do |output|
|
||||||
ApplicationRecord.transaction(isolation: :repeatable_read) do
|
self.class.with_read_snapshot do
|
||||||
processed = 0
|
processed = 0
|
||||||
first_row = true
|
first_row = true
|
||||||
started = false
|
started = false
|
||||||
|
|||||||
@@ -41,32 +41,25 @@ class CountsPivotTotals
|
|||||||
}.join(' AND ') }.map { "(#{ _1 })" }.join(' OR ')
|
}.join(' AND ') }.map { "(#{ _1 })" }.join(' OR ')
|
||||||
scope = scope.where(predicate, *tuples.flatten)
|
scope = scope.where(predicate, *tuples.flatten)
|
||||||
end
|
end
|
||||||
grouped = expressions.each_with_index.map { |expression, index|
|
grouped = indexes.each_with_index.map { |key, index|
|
||||||
"#{ expression } AS v#{ index }"
|
"a#{ key } AS v#{ index }"
|
||||||
}
|
}
|
||||||
# Fixed-width result coordinates, independent of NULL and of translated labels.
|
# Fixed-width result coordinates, independent of NULL and of translated labels.
|
||||||
padding = (expressions.length...@axes.all.length).map { "NULL AS v#{ _1 }" }
|
padding = (expressions.length...@axes.all.length).map { "NULL AS v#{ _1 }" }
|
||||||
fields = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] +
|
fields = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] +
|
||||||
grouped + padding + ['COUNT(DISTINCT v.id) AS count']
|
grouped + padding + ['count']
|
||||||
if additive
|
if additive
|
||||||
# Sum full row tuples, not page cells; preserve DISTINCT inside each tuple.
|
# Sum full row tuples, not page cells; preserve DISTINCT inside each tuple.
|
||||||
leaf = retained.map { @axes.expressions[_1] }
|
inner = @axes.grouped_sql(scope, retained)
|
||||||
selected = leaf.each_with_index.map do |expression, index|
|
coordinates = indexes.map { "a#{ _1 }" }
|
||||||
"#{ expression } AS k#{ index }"
|
|
||||||
end
|
|
||||||
selected << "#{ @axes.count_expression } AS count"
|
|
||||||
inner = scope.group(*leaf.map { Arel.sql(_1) })
|
|
||||||
.select(Arel.sql(selected.join(', '))).to_sql
|
|
||||||
coordinates = indexes.map { "k#{ retained.index(_1) }" }
|
|
||||||
projected = coordinates.each_with_index.map { |key, index| "#{ key } AS v#{ index }" }
|
projected = coordinates.each_with_index.map { |key, index| "#{ key } AS v#{ index }" }
|
||||||
output = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] +
|
output = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] +
|
||||||
projected + padding + ['SUM(count) AS count']
|
projected + padding + ['SUM(count) AS count']
|
||||||
grouping = coordinates.empty? ? '' : " GROUP BY #{ coordinates.join(', ') }"
|
grouping = coordinates.empty? ? '' : " GROUP BY #{ coordinates.join(', ') }"
|
||||||
"SELECT #{ output.join(', ') } FROM (#{ inner }) leaf#{ grouping }"
|
"SELECT #{ output.join(', ') } FROM (#{ inner }) leaf#{ grouping }"
|
||||||
else
|
else
|
||||||
scope = scope.group(*expressions.map { Arel.sql(_1) }) if expressions.any?
|
inner = @axes.grouped_sql(scope, indexes)
|
||||||
scope.optimizer_hints('MAX_EXECUTION_TIME(10000)')
|
"SELECT #{ fields.join(', ') } FROM (#{ inner }) grouped"
|
||||||
.select(Arel.sql(fields.join(', '))).to_sql
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class CountsTableExport
|
|||||||
|
|
||||||
def body(progress: nil, phase: nil)
|
def body(progress: nil, phase: nil)
|
||||||
Enumerator.new do |output|
|
Enumerator.new do |output|
|
||||||
ApplicationRecord.transaction(isolation: :repeatable_read) do
|
CountsExporter.with_read_snapshot do
|
||||||
processed = 0
|
processed = 0
|
||||||
started = false
|
started = false
|
||||||
first = true
|
first = true
|
||||||
|
|||||||
@@ -84,19 +84,9 @@ class CountsTableQuery
|
|||||||
cells: page == 1 ? [{ values: [], count: record_counts[:visible] }] : [],
|
cells: page == 1 ? [{ values: [], count: record_counts[:visible] }] : [],
|
||||||
axis_labels: {} }
|
axis_labels: {} }
|
||||||
end
|
end
|
||||||
expressions = axes.expressions
|
|
||||||
selected = expressions.each_with_index.map do |expression, index|
|
|
||||||
"#{ expression } AS a#{ index }"
|
|
||||||
end
|
|
||||||
source = summary_population.scope
|
source = summary_population.scope
|
||||||
source = source.joins(axes.joins) unless axes.joins.empty?
|
source = source.joins(axes.joins) unless axes.joins.empty?
|
||||||
sql = if expressions.empty?
|
sql = axes.grouped_sql(source)
|
||||||
source.except(:select).select('COUNT(DISTINCT v.id) AS count').to_sql
|
|
||||||
else
|
|
||||||
source.except(:select).group(*expressions.map { Arel.sql(_1) })
|
|
||||||
.select(Arel.sql((selected + ["#{ axes.count_expression } AS count"]).join(', ')))
|
|
||||||
.to_sql
|
|
||||||
end
|
|
||||||
prefix = "WITH cells AS (#{ sql })"
|
prefix = "WITH cells AS (#{ sql })"
|
||||||
column_values = limited_columns(prefix)
|
column_values = limited_columns(prefix)
|
||||||
row_keys = axes.rows.each_index.map { "a#{ _1 }" }
|
row_keys = axes.rows.each_index.map { "a#{ _1 }" }
|
||||||
@@ -354,7 +344,6 @@ class CountsTableQuery
|
|||||||
end
|
end
|
||||||
|
|
||||||
def measure_relation values
|
def measure_relation values
|
||||||
row_expressions = axes.expressions.first(axes.rows.length)
|
|
||||||
source = summary_population.scope.except(:select, :order)
|
source = summary_population.scope.except(:select, :order)
|
||||||
# Omitted multi-value column axes still determine full-cell membership.
|
# Omitted multi-value column axes still determine full-cell membership.
|
||||||
membership = summary_population.scope.except(:select, :order)
|
membership = summary_population.scope.except(:select, :order)
|
||||||
@@ -368,11 +357,7 @@ class CountsTableQuery
|
|||||||
expression = axes.expressions[axes.rows.length + index]
|
expression = axes.expressions[axes.rows.length + index]
|
||||||
source = source.where("(#{ expression }) <=> ?", value)
|
source = source.where("(#{ expression }) <=> ?", value)
|
||||||
end
|
end
|
||||||
selected = row_expressions.each_with_index.map { |expression, index|
|
axes.grouped_sql(source, axes.rows.each_index.to_a)
|
||||||
"#{ expression } AS a#{ index }"
|
|
||||||
} + ["#{ axes.count_expression } AS count"]
|
|
||||||
source = source.group(*row_expressions.map { Arel.sql(_1) }) if row_expressions.any?
|
|
||||||
source.select(Arel.sql(selected.join(', '))).to_sql
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def axis_order_sql key, direction = 'asc', qualifier: nil
|
def axis_order_sql key, direction = 'asc', qualifier: nil
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ RSpec.describe 'Counts native GET acknowledgement', type: :request do
|
|||||||
include ActiveSupport::Testing::TimeHelpers
|
include ActiveSupport::Testing::TimeHelpers
|
||||||
around do |example|
|
around do |example|
|
||||||
Dir.mktmpdir('counts-download-request') do |path|
|
Dir.mktmpdir('counts-download-request') do |path|
|
||||||
allow(CountsExportDownload).to receive(:directory).and_return(Pathname.new(path))
|
@counts_download_dir = Pathname.new(path)
|
||||||
example.run
|
example.run
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(CountsExportDownload).to receive(:directory).and_return(@counts_download_dir)
|
||||||
|
end
|
||||||
|
|
||||||
def ready_job(format)
|
def ready_job(format)
|
||||||
CountsExportJobs.pulse
|
CountsExportJobs.pulse
|
||||||
post '/counts/export/jobs', params: { resource: 'posts', definition: 'history',
|
post '/counts/export/jobs', params: { resource: 'posts', definition: 'history',
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ RSpec.describe 'Counts export contracts' do
|
|||||||
block.call(page)
|
block.call(page)
|
||||||
end
|
end
|
||||||
allow(CountsTableQuery).to receive(:new).and_return(query)
|
allow(CountsTableQuery).to receive(:new).and_return(query)
|
||||||
allow(ApplicationRecord).to receive(:transaction).with(isolation: :repeatable_read).and_yield
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'reports aggregation, actual output progress and regular-row metadata' do
|
it 'reports aggregation, actual output progress and regular-row metadata' do
|
||||||
@@ -175,11 +174,15 @@ end
|
|||||||
RSpec.describe 'Counts download acknowledgement' do
|
RSpec.describe 'Counts download acknowledgement' do
|
||||||
around do |example|
|
around do |example|
|
||||||
Dir.mktmpdir('counts-ack-spec') do |path|
|
Dir.mktmpdir('counts-ack-spec') do |path|
|
||||||
allow(CountsExportDownload).to receive(:directory).and_return(Pathname.new(path))
|
@counts_ack_dir = Pathname.new(path)
|
||||||
HistoryAccess.set(user: nil) { example.run }
|
HistoryAccess.set(user: nil) { example.run }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(CountsExportDownload).to receive(:directory).and_return(@counts_ack_dir)
|
||||||
|
end
|
||||||
|
|
||||||
it 'binds an acknowledgement to the owner, job, file token and fresh attempt' do
|
it 'binds an acknowledgement to the owner, job, file token and fresh attempt' do
|
||||||
CountsExportJobs.pulse
|
CountsExportJobs.pulse
|
||||||
payload = { 'resource' => 'posts', 'definition' => 'history', 'mode' => 'details',
|
payload = { 'resource' => 'posts', 'definition' => 'history', 'mode' => 'details',
|
||||||
|
|||||||
@@ -132,11 +132,14 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
|
|
||||||
it 'rejects private predicates and supplied measure coordinates before SQL labels escape' do
|
it 'rejects private predicates and supplied measure coordinates before SQL labels escape' do
|
||||||
probes = [{ 'resource_id' => PostVersion.first.post_id.to_s },
|
probes = [{ 'resource_id' => PostVersion.first.post_id.to_s },
|
||||||
{ 'tags' => 'A' }, { 'tag_refs.0.name' => 'A', 'tag_refs.0.tag_id' => tags.first.id.to_s },
|
{ 'tags' => 'A' },
|
||||||
{ 'conditions.0.field' => 'title', 'conditions.0.operator' => 'eq',
|
{ 'tag_refs' => '', 'tag_refs.0.name' => 'A', 'tag_refs.0.tag_id' => tags.first.id.to_s },
|
||||||
|
{ 'conditions' => '', 'conditions.0.field' => 'title', 'conditions.0.operator' => 'eq',
|
||||||
'conditions.0.value' => 'hidden-title-0' },
|
'conditions.0.value' => 'hidden-title-0' },
|
||||||
{ 'drill.0.field' => 'tags', 'drill.0.value' => "internal:#{ tags.first.id }" },
|
{ 'drill' => '', 'drill.0.field' => 'tags', 'drill.0.value_type' => 'string',
|
||||||
{ 'cols' => 'tags', 'column_order.0.value' => CountsAxes::MASKED_VALUE }]
|
'drill.0.value' => "internal:#{ tags.first.id }" },
|
||||||
|
{ 'cols' => 'tags', 'column_order' => '', 'column_order.0.value_type' => 'string',
|
||||||
|
'column_order.0.value' => CountsAxes::MASKED_VALUE }]
|
||||||
HistoryAccess.set(user: nil) do
|
HistoryAccess.set(user: nil) do
|
||||||
probes.each do |probe|
|
probes.each do |probe|
|
||||||
expect { CountsTableQuery.new(params.merge(probe)) }
|
expect { CountsTableQuery.new(params.merge(probe)) }
|
||||||
@@ -148,6 +151,7 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
it 'keeps public column sorting usable while guessed private coordinates match nothing' do
|
it 'keeps public column sorting usable while guessed private coordinates match nothing' do
|
||||||
first = result(nil, 'rows' => 'event_type', 'cols' => 'tags')
|
first = result(nil, 'rows' => 'event_type', 'cols' => 'tags')
|
||||||
probe = result(nil, 'rows' => 'event_type', 'cols' => 'tags',
|
probe = result(nil, 'rows' => 'event_type', 'cols' => 'tags',
|
||||||
|
'column_order' => '', 'column_order.0.value_type' => 'string',
|
||||||
'column_order.0.value' => "internal:#{ tags.first.id }")
|
'column_order.0.value' => "internal:#{ tags.first.id }")
|
||||||
expect(probe[:column_values]).to eq(first[:column_values])
|
expect(probe[:column_values]).to eq(first[:column_values])
|
||||||
expect(probe[:cells]).to eq(first[:cells])
|
expect(probe[:cells]).to eq(first[:cells])
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
|
|||||||
expect(Rails.env.test?).to be(true)
|
expect(Rails.env.test?).to be(true)
|
||||||
expect(ApplicationRecord.connection.adapter_name).to match(/mysql/i)
|
expect(ApplicationRecord.connection.adapter_name).to match(/mysql/i)
|
||||||
expect(ApplicationRecord.connection_pool.size).to be >= 3
|
expect(ApplicationRecord.connection_pool.size).to be >= 3
|
||||||
|
@main_connection = ApplicationRecord.connection
|
||||||
|
# A leaked fixture pin makes worker threads share the main session. Fail before
|
||||||
|
# starting a worker rather than diagnosing IS_USED_LOCK(NULL) after a timeout.
|
||||||
|
expect(@main_connection.open_transactions).to eq(0)
|
||||||
@prefix = "shared_ids_#{ SecureRandom.hex(8) }"
|
@prefix = "shared_ids_#{ SecureRandom.hex(8) }"
|
||||||
@tag = create(:tag, primary_name: "#{ @prefix }_owner")
|
@tag = create(:tag, primary_name: "#{ @prefix }_owner")
|
||||||
@group = TagGroup.create!(name: "#{ @prefix }_owner", version_no: 1)
|
@group = TagGroup.create!(name: "#{ @prefix }_owner", version_no: 1)
|
||||||
@@ -42,13 +46,20 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def await_queue queue
|
def await_queue queue
|
||||||
Timeout.timeout(15) { queue.pop }
|
value = Timeout.timeout(15) { queue.pop }
|
||||||
|
raise value if value.is_a?(Exception)
|
||||||
|
|
||||||
|
value
|
||||||
end
|
end
|
||||||
|
|
||||||
def worker &block
|
def worker &block
|
||||||
Thread.new do
|
Thread.new do
|
||||||
Thread.current.report_on_exception = false
|
Thread.current.report_on_exception = false
|
||||||
ApplicationRecord.connection_pool.with_connection(&block)
|
ApplicationRecord.connection_pool.with_connection do |connection|
|
||||||
|
raise 'Shared ID worker reused the main connection' if connection.equal?(@main_connection)
|
||||||
|
|
||||||
|
block.call(connection)
|
||||||
|
end
|
||||||
end.tap { @threads << _1 }
|
end.tap { @threads << _1 }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -86,11 +97,16 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
|
|||||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||||
id = create_resource(kind).id
|
id = create_resource(kind).id
|
||||||
end
|
end
|
||||||
|
raise 'Shared ID creation did not acquire a named lock' unless lock_name
|
||||||
|
|
||||||
ready << [id, connection.select_value('SELECT CONNECTION_ID()').to_i, lock_name]
|
ready << [id, connection.select_value('SELECT CONNECTION_ID()').to_i, lock_name]
|
||||||
await_queue(@release)
|
await_queue(@release)
|
||||||
raise ActiveRecord::Rollback if rollback
|
raise ActiveRecord::Rollback if rollback
|
||||||
end
|
end
|
||||||
id
|
id
|
||||||
|
rescue StandardError => error
|
||||||
|
ready << error
|
||||||
|
raise
|
||||||
end
|
end
|
||||||
[thread, await_queue(ready)]
|
[thread, await_queue(ready)]
|
||||||
end
|
end
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする