このコミットが含まれているのは:
2026-11-08 01:56:45 +09:00
コミット b9d7c4fef5
8個のファイルの変更、472行の追加、140行の削除
+54 -12
ファイルの表示
@@ -1,5 +1,6 @@
class CountsAxes
MAX_AXES = 8
MASKED_TEXT = '********'.freeze
MASKED_VALUE = '__counts_private__'.freeze
PUBLIC_FIELDS = ['resource_id', 'version_no', 'event_type', 'user',
'created_at'].freeze
@@ -8,6 +9,33 @@ class CountsAxes
GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze
def self.private_field?(field)
!PUBLIC_FIELDS.include?(field) && field != 'user.id'
end
def self.visible_ids_sql(definition)
"SELECT counts_visible.id FROM #{ definition.table } counts_visible WHERE " +
HistoryAccess.sql(definition.model, 'counts_visible')
end
def self.visibility_sql(definition, field)
return 'TRUE' if HistoryAccess.admin?
source = visible_ids_sql(definition)
visible = "v.id IN (#{ source })"
return visible unless field == 'changed_field'
"(#{ visible }) AND (previous.id IS NULL OR previous.id IN (#{ source }))"
end
def self.masked_sql(expression, definition, field)
return expression if HistoryAccess.admin? || !private_field?(field)
visible = visibility_sql(definition, field)
quoted = ApplicationRecord.connection.quote(MASKED_VALUE)
"CASE WHEN #{ visible } THEN #{ expression } ELSE #{ quoted } END"
end
def self.time_expression(expression, grain, timezone)
raise ArgumentError unless GRAINS.include?(grain)
@@ -61,13 +89,9 @@ class CountsAxes
@definition.fields[field] == :datetime ? self.class.time_expression(
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
end
if !HistoryAccess.admin? && !PUBLIC_FIELDS.include?(field)
visible = HistoryAccess.sql(@definition.model, 'v')
if field == 'changed_field'
visible = "(#{ visible }) AND (#{ HistoryAccess.sql(@definition.model, 'previous') })"
end
expression = "CASE WHEN #{ visible } " +
"THEN CAST((#{ expression }) AS CHAR) ELSE '#{ MASKED_VALUE }' END"
if !HistoryAccess.admin? && self.class.private_field?(field)
expression = self.class.masked_sql(
"CAST((#{ expression }) AS CHAR)", @definition, field)
end
expression
end
@@ -78,10 +102,12 @@ class CountsAxes
selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" }
identities = ['v.id']
if rows.any? { _1['field'] == 'tags' }
# Raw identities stay inside COUNT; masked tag occurrences remain additive.
identities << "COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
"CONCAT('external:', tag_entry.external_tag_id))"
identities << 'changed.field_key' if rows.any? { _1['field'] == 'changed_field' }
identities << self.class.masked_sql(
"COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
"CONCAT('external:', tag_entry.external_tag_id))", @definition, 'tags')
if rows.any? { _1['field'] == 'changed_field' }
identities << self.class.masked_sql('changed.field_key', @definition, 'changed_field')
end
end
members = identities.each_index.map { "counts_member_#{ _1 }" }
selected += identities.each_with_index.map { |expression, index|
@@ -99,8 +125,17 @@ class CountsAxes
def joins(fields = all.map { _1['field'] })
result = ''
if fields.include?('tags')
document = 'CAST(v.tags_json AS JSON)'
unless HistoryAccess.admin?
visible = self.class.visible_ids_sql(@definition)
result += " LEFT JOIN (#{ visible }) counts_tags_visible " +
'ON counts_tags_visible.id = v.id '
document = "CASE WHEN counts_tags_visible.id IS NOT NULL " +
"THEN #{ document } ELSE JSON_ARRAY(" +
"JSON_OBJECT('tag_id', 0)) END"
end
result += <<~SQL.squish
JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS (
JOIN JSON_TABLE(#{ document }, '$[*]' COLUMNS (
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
@@ -113,6 +148,13 @@ class CountsAxes
condition = @definition.changes.map do |field|
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
end.join(' OR ')
unless HistoryAccess.admin?
visible = self.class.visibility_sql(@definition, 'changed_field')
marker = ApplicationRecord.connection.quote(MASKED_VALUE)
fields += " UNION ALL SELECT #{ marker } AS field_key"
condition = "((#{ visible }) AND (#{ condition })) OR " +
"(NOT (#{ visible }) AND changed.field_key = #{ marker })"
end
result += " JOIN (#{ fields }) changed ON (#{ condition })"
end
result
+41 -2
ファイルの表示
@@ -82,14 +82,32 @@ class CountsCondition
def apply(scope, entry)
raise ArgumentError unless entry.is_a?(Hash)
if entry['operator'] == 'axis_eq'
raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty?
value = entry['value']
unless value.nil? || value.is_a?(String) || value.is_a?(Numeric) || [true, false].include?(value)
unless (value.nil? || value.is_a?(String) || value.is_a?(Numeric) ||
[true, false].include?(value))
raise ArgumentError
end
raise ArgumentError if value.is_a?(String) && value.bytesize > 8192
if (!HistoryAccess.admin? && CountsAxes.private_field?(entry['field']) &&
(value == CountsAxes::MASKED_VALUE ||
(value == CountsAxes::MASKED_TEXT &&
(@definition.fields[entry['field']] != :string ||
['tags', 'changed_field'].include?(entry['field'])))))
field = entry.fetch('field')
raise ArgumentError unless @definition.axes(@population.history).include?(field)
if entry.key?('grain')
unless (@definition.fields[field] == :datetime &&
CountsAxes::GRAINS.include?(entry['grain']))
raise ArgumentError
end
end
return scope.none
end
scope = @population.condition_scope(scope, entry.fetch('field'))
return @population.axis_condition(scope, entry)
end
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
@@ -111,6 +129,13 @@ class CountsCondition
source, expression = source_for(field[:key])
type = field[:type]
# Preserve typed input compatibility; display markers are never real typed values.
if (!HistoryAccess.admin? && CountsAxes.private_field?(field[:key]) &&
entry['value'] == CountsAxes::MASKED_TEXT && type != :string && !entry['blank'])
return scope.none unless entry['operator'] == 'ne'
return @population.condition_scope(scope, field[:key])
end
blank = blank_sql(expression, type)
if entry['blank']
sql = if source && field[:collection]
@@ -119,6 +144,7 @@ class CountsCondition
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
else blank
end
scope = @population.condition_scope(scope, field[:key])
return scope.where(Arel.sql(sql))
end
if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
@@ -131,6 +157,7 @@ class CountsCondition
validate_enum!(field[:key], parsed.first) if type == :enum
operator = entry['operator']
if type == :interval
scope = @population.condition_scope(scope, field[:key])
return interval(scope, source, expression, parsed)
end
clauses = []
@@ -158,6 +185,7 @@ class CountsCondition
elsif operator == 'ne'
predicate = "(NOT (#{ predicate }) OR #{ blank })"
end
scope = @population.condition_scope(scope, field[:key])
scope.where(predicate, *binds)
end
@@ -168,6 +196,13 @@ class CountsCondition
values = entry['values']
raise ArgumentError unless values.is_a?(Array) && values.length <= 100
if (!HistoryAccess.admin? && CountsAxes.private_field?(field[:key]) &&
values.include?(CountsAxes::MASKED_TEXT) &&
(field[:type] != :string || field[:set_type] == 'tag_reference'))
return scope.none if entry['operator'] == 'all'
values = values - [CountsAxes::MASKED_TEXT]
end
raise ArgumentError if [entry['value'], entry['end']].any? { !_1.nil? && _1 != '' }
parsed = values.map do |value|
@@ -191,7 +226,10 @@ class CountsCondition
operator = entry['operator']
if parsed.empty?
return ['in', 'any'].include?(operator) ? scope.none : scope
return scope.none if ['in', 'any'].include?(operator)
return scope if entry['values'].empty?
return @population.condition_scope(scope, field[:key])
end
source, expression = source_for(field[:key])
if field[:set_type] == 'tag_reference'
@@ -212,6 +250,7 @@ class CountsCondition
predicate = source ? "NOT #{ predicate }" :
"(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })"
end
scope = @population.condition_scope(scope, field[:key])
scope.where(predicate, *parsed)
end
+10 -99
ファイルの表示
@@ -70,6 +70,13 @@ class CountsPopulation
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
end
# Each predicate keeps its own visibility restriction, including OR branches.
def condition_scope(scope, field)
return scope if HistoryAccess.admin? || !CountsAxes.private_field?(field)
scope.where(Arel.sql(CountsAxes.visibility_sql(definition, field)))
end
def axis_condition(scope, entry)
raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE
@@ -192,88 +199,7 @@ class CountsPopulation
def apply_condition(entry)
@condition ||= CountsCondition.new(self)
unless summary_private_condition?(entry)
@scope = @condition.apply(@scope, entry)
return
end
return if masked_noop?(entry)
# Evaluate private predicates only on authorised versions. On other versions,
# the entire field is the same opaque marker, regardless of its stored value.
original = @scope
visible = private_visibility_sql(entry['field'])
authorised = authorised_private_condition(original.where(Arel.sql(visible)), entry)
@scope = if masked_match?(entry)
authorised.or(original.where(Arel.sql("NOT (#{ visible })")))
else
authorised
end
end
def authorised_private_condition(scope, entry)
marker = '********'
if entry['operator'] == 'axis_eq'
return scope.none if entry['value'] == marker &&
definition.fields[entry['field']] != :string
return @condition.apply(scope, entry)
end
field = definition.filter_metadata.find { _1[:key] == entry['field'] }
return @condition.apply(scope, entry) unless field
return @condition.apply(scope, entry) if field[:type] == :string &&
field[:set_type] != 'tag_reference'
if entry['value'] == marker
return scope.none if ['eq', 'gt', 'ge', 'lt', 'le', 'range'].include?(entry['operator'])
return scope if entry['operator'] == 'ne'
end
if entry['values'].is_a?(Array) && entry['values'].include?(marker)
return scope.none if entry['operator'] == 'all'
return @condition.apply(scope, entry.merge('values' => entry['values'] - [marker]))
end
@condition.apply(scope, entry)
end
def summary_private_condition?(entry)
params.fetch('mode', 'summary') == 'summary' && !@authorisation_flg &&
!HistoryAccess.admin? && !CountsAxes::PUBLIC_FIELDS.include?(entry['field']) &&
entry['field'] != 'user.id'
end
def private_visibility_sql(field)
visible = HistoryAccess.sql(definition.model, 'v')
return visible unless field == 'changed_field'
"(#{ visible }) AND (#{ HistoryAccess.sql(definition.model, 'previous') })"
end
def masked_noop?(entry)
return false if entry['operator'] == 'axis_eq' || entry['blank']
set = ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
return !entry.key?('values') if set
[entry['value'], entry['end']].all? { _1.nil? || _1 == '' }
end
def masked_match?(entry)
marker = '********'
return false if entry['blank']
value = entry['value']
case entry['operator']
when 'axis_eq', 'eq' then value == marker
when 'ne' then value != marker
when 'like'
pattern = Regexp.escape(value.to_s).gsub('%', '.*').gsub('_', '.')
/\A#{ pattern }\z/i.match?(marker)
when 'in', 'any' then entry.fetch('values', []).include?(marker)
when 'not_in', 'none' then !entry.fetch('values', []).include?(marker)
when 'all' then entry.fetch('values', []).all? { _1 == marker }
else false
end
@scope = @condition.apply(@scope, entry)
end
def snapshot_membership(value)
@@ -290,10 +216,7 @@ class CountsPopulation
def apply_tags
raise ArgumentError unless definition.resource == 'posts'
private = params.fetch('mode', 'summary') == 'summary' && !@authorisation_flg &&
!HistoryAccess.admin?
original = @scope if private
@scope = HistoryAccess.visible(@scope, 'v') if private
@scope = condition_scope(@scope, 'tags')
if params['tag'].present?
@scope = @scope.where(snapshot_membership(
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
@@ -317,21 +240,9 @@ class CountsPopulation
expression = parts.empty? ? 'FALSE' : "(#{ parts.join(' OR ') })"
literal[:negative] ? "NOT #{ expression }" : expression
end
if predicates.empty?
@scope = original if private && params['tag'].blank?
return
end
return if predicates.empty?
any = params['match'] == 'any' || raw.split.any? { _1.downcase == 'type:or' }
@scope = @scope.where(Arel.sql("(#{ predicates.join(any ? ' OR ' : ' AND ') })"))
return unless private && params['tag'].blank?
# A masked tag collection cannot match a real tag. Negative terms match
# the opaque marker independently of any hidden snapshot membership.
hidden = any ? literals.any? { _1[:negative] } : literals.all? { _1[:negative] }
if hidden
visible = HistoryAccess.sql(definition.model, 'v')
@scope = @scope.or(original.where(Arel.sql("NOT (#{ visible })")))
end
end
end
+1 -1
ファイルの表示
@@ -253,7 +253,7 @@ class CountsTableExport
end
def summary_value(value, index, page)
return '********' if value == CountsAxes::MASKED_VALUE
return CountsAxes::MASKED_TEXT if value == CountsAxes::MASKED_VALUE
field = @query.axes.all.fetch(index).fetch('field')
return @labels.fetch('bot', '') if field == 'user' && value.nil?
+2 -3
ファイルの表示
@@ -220,9 +220,8 @@ class CountsTableQuery
private
# Summary predicates are evaluated against public coordinates or the masked
# value by CountsPopulation. Never accept a private server-only coordinate in
# user-supplied ordering/measure state.
# Private predicates require visibility independently of their operator.
# Ordering/measure state must not select a server-only masked coordinate.
def validate_summary_conditions!
return if mode != 'summary' || HistoryAccess.admin?