コミットを比較

..
2 コミット
作成者 SHA1 メッセージ 日付
Codex Night Worker dcaf6d5c8e AI: address issue #283 2026-11-12 01:53:46 +09:00
みてるぞ f05d8a4b3a 集計で独裁者以外がほとんど検索条件を指定できないバグ (#444) (#452)
Reviewed-on: #452
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-11-09 00:03:26 +09:00
32個のファイルの変更、631行の追加、420行の削除
+10 -1
ファイルの表示
@@ -100,9 +100,18 @@ class PostVersionsController < ApplicationController
def external_tag_names_for snapshots def external_tag_names_for snapshots
ids = snapshots.flatten.filter_map { _1['external_tag_id'] }.uniq ids = snapshots.flatten.filter_map { _1['external_tag_id'] }.uniq
ExternalTag.where(id: ids).pluck(:id, :platform, :name).to_h { |id, platform, name| names = ExternalTag.where(id: ids).pluck(:id, :platform, :name).to_h { |id, platform, name|
[id, "#{ platform }:#{ name }"] [id, "#{ platform }:#{ name }"]
} }
missing_ids = ids - names.keys
HistoryAccess.visible(NicoTagVersion.all)
.where(tag_id: missing_ids)
.order(:tag_id, version_no: :desc)
.pluck(:tag_id, :name)
.each { |id, name| names[id] ||= name }
names
end end
def snapshot_tag_literals snapshots, tag_names, external_tag_names def snapshot_tag_literals snapshots, tag_names, external_tag_names
+6 -19
ファイルの表示
@@ -1,11 +1,8 @@
class ExternalTag < ApplicationRecord class ExternalTag < ApplicationRecord
before_create { SharedResourceIdAllocator.assign!(self) } before_create { SharedResourceIdAllocator.assign!(self) }
# Must run before dependent association callbacks.
before_destroy :prevent_destroy
enum :platform, nico: 'nico' enum :platform, nico: 'nico'
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys } validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
validate :immutable_identity, on: :update
has_many :post_external_tags, dependent: :delete_all has_many :post_external_tags, dependent: :delete_all
has_many :posts, through: :post_external_tags has_many :posts, through: :post_external_tags
@@ -19,21 +16,11 @@ class ExternalTag < ApplicationRecord
has_many :linked_tags, through: :nico_tag_relations, source: :tag has_many :linked_tags, through: :nico_tag_relations, source: :tag
def snapshot_linked_tag_ids = linked_tags.distinct.order(:id).pluck(:id) def snapshot_linked_tag_names language_code = 'ja'
linked_tags
def delete .joins(:tag_names)
raise ActiveRecord::ReadOnlyRecord, '外部タグは削除できません.' .where(tag_names: { language_code:, primary_flg: true })
end .order('tag_names.name')
.pluck('tag_names.name')
private
def immutable_identity
errors.add(:platform, 'は変更できません.') if will_save_change_to_platform?
errors.add(:name, 'は変更できません.') if will_save_change_to_name?
end
def prevent_destroy
errors.add(:base, '外部タグは削除できません.')
throw(:abort)
end end
end end
+16 -2
ファイルの表示
@@ -4,7 +4,7 @@ require 'digest'
class HistoryAccess < ActiveSupport::CurrentAttributes class HistoryAccess < ActiveSupport::CurrentAttributes
attribute :user attribute :user
class Forbidden < StandardError; end class Forbidden < StandardError; end
ARTIFACT_POLICY = 'counts-masked-summary-2'.freeze ARTIFACT_POLICY = 'counts-private-predicates-4'.freeze
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'], KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
'nico_tag_versions' => ['tag_id'], 'nico_tag_versions' => ['tag_id'],
@@ -106,7 +106,21 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
snapshot_reference(TagVersion, row), snapshot_reference(TagVersion, row),
snapshot_reference(NicoTagVersion, row, external: true)] snapshot_reference(NicoTagVersion, row, external: true)]
when 'nico_tag_versions' when 'nico_tag_versions'
[list_reference('tag_versions', 'tag_id', "#{ row }.linked_tag_ids")] # Legacy linked_tags snapshots contain names, not IDs. Match recorded names
# conservatively; never expose a private linked name through an external tag.
names = "JSON_MERGE_PRESERVE(JSON_ARRAY(private_tag.name), " +
"COALESCE(JSON_EXTRACT(private_tag.names, '$**.name'), JSON_ARRAY()))"
# DISTINCT prevents merging this independent name set into the correlated scan.
# Keep byte-distinct names even when the column collation considers them equal.
private_names = 'SELECT DISTINCT private_name.name, ' +
'CAST(private_name.name AS BINARY) AS name_bytes FROM tag_versions private_tag ' +
"JOIN (#{ discarded_keys('tag_versions', 'tag_id') }) blocked " +
'ON blocked.tag_id = private_tag.tag_id ' +
"JOIN JSON_TABLE(#{ names }, '$[*]' COLUMNS (name TEXT PATH '$')) private_name " +
"WHERE private_name.name <> ''"
["NOT EXISTS (SELECT 1 FROM (#{ private_names }) private_name WHERE " +
"LOCATE(CONCAT(' ', private_name.name, ' '), " +
"CONCAT(' ', COALESCE(#{ row }.linked_tags, ''), ' ')) > 0)"]
when 'tag_versions' when 'tag_versions'
[list_reference('tag_versions', 'tag_id', "#{ row }.parent_tag_ids")] [list_reference('tag_versions', 'tag_id', "#{ row }.parent_tag_ids")]
when 'tag_description_versions' when 'tag_description_versions'
+1 -6
ファイルの表示
@@ -3,10 +3,5 @@ class NicoTagVersion < ApplicationRecord
belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions
validates :event_type, inclusion: { in: ['create', 'update'] } validates :name, presence: true
def name
tag = external_tag
"#{ tag.platform }:#{ tag.name }" if tag
end
end end
+1 -4
ファイルの表示
@@ -139,8 +139,6 @@ class CountsVersionRepr
result = {} result = {}
table = model.table_name table = model.table_name
comparison = @inclusive ? '<=' : '<' comparison = @inclusive ? '<=' : '<'
name = model == NicoTagVersion ?
CountsResourceDefinition.new('nico_tags').expression('name', table) : 'name'
requests.each_slice(100) do |batch| requests.each_slice(100) do |batch|
queries = batch.map do |id, point| queries = batch.map do |id, point|
scope = HistoryAccess.visible(model.where(tag_id: id)) scope = HistoryAccess.visible(model.where(tag_id: id))
@@ -150,8 +148,7 @@ class CountsVersionRepr
"AND newer.version_no > #{ table }.version_no)", point) "AND newer.version_no > #{ table }.version_no)", point)
quoted = model.connection.quote(point.iso8601(6)) quoted = model.connection.quote(point.iso8601(6))
names = model == TagVersion ? 'names' : 'JSON_OBJECT() AS names' names = model == TagVersion ? 'names' : 'JSON_OBJECT() AS names'
scope.select("tag_id, #{ name } AS name, #{ names }, " + scope.select("tag_id, name, #{ names }, #{ quoted } AS reference_point").to_sql
"#{ quoted } AS reference_point").to_sql
end end
next if queries.empty? next if queries.empty?
+56 -13
ファイルの表示
@@ -1,12 +1,41 @@
class CountsAxes class CountsAxes
MAX_AXES = 8 MAX_AXES = 8
MASKED_TEXT = '********'.freeze
MASKED_VALUE = '__counts_private__'.freeze MASKED_VALUE = '__counts_private__'.freeze
PUBLIC_FIELDS = ['created_at', 'event_type'].freeze PUBLIC_FIELDS = ['resource_id', 'version_no', 'event_type', 'user',
'created_at'].freeze
class TooManyAxes < ArgumentError; end class TooManyAxes < ArgumentError; end
class PrivateCondition < ArgumentError; end class PrivateCondition < ArgumentError; end
GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze 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) def self.time_expression(expression, grain, timezone)
raise ArgumentError unless GRAINS.include?(grain) raise ArgumentError unless GRAINS.include?(grain)
@@ -60,13 +89,9 @@ class CountsAxes
@definition.fields[field] == :datetime ? self.class.time_expression( @definition.fields[field] == :datetime ? self.class.time_expression(
expression, axis.fetch('grain', 'month'), @population.timezone) : expression expression, axis.fetch('grain', 'month'), @population.timezone) : expression
end end
if !HistoryAccess.admin? && !PUBLIC_FIELDS.include?(field) if !HistoryAccess.admin? && self.class.private_field?(field)
visible = HistoryAccess.sql(@definition.model, 'v') expression = self.class.masked_sql(
if field == 'changed_field' "CAST((#{ expression }) AS CHAR)", @definition, field)
visible = "(#{ visible }) AND (#{ HistoryAccess.sql(@definition.model, 'previous') })"
end
expression = "CASE WHEN #{ visible } " +
"THEN CAST((#{ expression }) AS CHAR) ELSE '#{ MASKED_VALUE }' END"
end end
expression expression
end end
@@ -77,10 +102,12 @@ class CountsAxes
selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" } selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" }
identities = ['v.id'] identities = ['v.id']
if rows.any? { _1['field'] == 'tags' } if rows.any? { _1['field'] == 'tags' }
# Raw identities stay inside COUNT; masked tag occurrences remain additive. identities << self.class.masked_sql(
identities << "COALESCE(CONCAT('internal:', tag_entry.tag_id), " + "COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
"CONCAT('external:', tag_entry.external_tag_id))" "CONCAT('external:', tag_entry.external_tag_id))", @definition, 'tags')
identities << 'changed.field_key' if rows.any? { _1['field'] == 'changed_field' } if rows.any? { _1['field'] == 'changed_field' }
identities << self.class.masked_sql('changed.field_key', @definition, 'changed_field')
end
end end
members = identities.each_index.map { "counts_member_#{ _1 }" } members = identities.each_index.map { "counts_member_#{ _1 }" }
selected += identities.each_with_index.map { |expression, index| selected += identities.each_with_index.map { |expression, index|
@@ -98,8 +125,17 @@ class CountsAxes
def joins(fields = all.map { _1['field'] }) def joins(fields = all.map { _1['field'] })
result = '' result = ''
if fields.include?('tags') 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 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, tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
@@ -112,6 +148,13 @@ class CountsAxes
condition = @definition.changes.map do |field| condition = @definition.changes.map do |field|
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })" "(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
end.join(' OR ') 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 })" result += " JOIN (#{ fields }) changed ON (#{ condition })"
end end
result result
+2 -4
ファイルの表示
@@ -28,18 +28,16 @@ class CountsCandidates
[external, 'external', 'NULL', 'JSON_OBJECT()']] [external, 'external', 'NULL', 'JSON_OBJECT()']]
sources = sources.map do |population, kind, category, names| sources = sources.map do |population, kind, category, names|
scope = population.scope.except(:select) scope = population.scope.except(:select)
name = population.definition.expression('name')
if @params['query'].present? if @params['query'].present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%" pattern = "%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%"
scope = scope.where("(#{ name } LIKE ? OR CAST(v.tag_id AS CHAR) = ? OR EXISTS (" + scope = scope.where("(v.name LIKE ? OR CAST(v.tag_id AS CHAR) = ? OR EXISTS (" +
"SELECT 1 FROM JSON_TABLE(COALESCE(JSON_EXTRACT(#{ names }, '$**.name'), " + "SELECT 1 FROM JSON_TABLE(COALESCE(JSON_EXTRACT(#{ names }, '$**.name'), " +
"JSON_ARRAY()), '$[*]' COLUMNS (name TEXT PATH '$')) candidate_name " + "JSON_ARRAY()), '$[*]' COLUMNS (name TEXT PATH '$')) candidate_name " +
'WHERE candidate_name.name LIKE ?))', pattern, @params['query'], pattern) 'WHERE candidate_name.name LIKE ?))', pattern, @params['query'], pattern)
end end
scope.select(Arel.sql( scope.select(Arel.sql(
"CONCAT('#{ kind }:', v.tag_id) AS value, v.tag_id AS id, " + "CONCAT('#{ kind }:', v.tag_id) AS value, v.tag_id AS id, " +
"#{ name } AS name, #{ names } AS names, " + "v.name AS name, #{ names } AS names, #{ category } AS category, '#{ kind }' AS kind"))
"#{ category } AS category, '#{ kind }' AS kind"))
.to_sql .to_sql
end end
clauses = [] clauses = []
+41 -2
ファイルの表示
@@ -82,14 +82,32 @@ class CountsCondition
def apply(scope, entry) def apply(scope, entry)
raise ArgumentError unless entry.is_a?(Hash) raise ArgumentError unless entry.is_a?(Hash)
if entry['operator'] == 'axis_eq' if entry['operator'] == 'axis_eq'
raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty? raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty?
value = entry['value'] 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 raise ArgumentError
end end
raise ArgumentError if value.is_a?(String) && value.bytesize > 8192 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) return @population.axis_condition(scope, entry)
end end
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values'] allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
@@ -111,6 +129,13 @@ class CountsCondition
source, expression = source_for(field[:key]) source, expression = source_for(field[:key])
type = field[:type] 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) blank = blank_sql(expression, type)
if entry['blank'] if entry['blank']
sql = if source && field[:collection] sql = if source && field[:collection]
@@ -119,6 +144,7 @@ class CountsCondition
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))" "NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
else blank else blank
end end
scope = @population.condition_scope(scope, field[:key])
return scope.where(Arel.sql(sql)) return scope.where(Arel.sql(sql))
end end
if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator']) 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 validate_enum!(field[:key], parsed.first) if type == :enum
operator = entry['operator'] operator = entry['operator']
if type == :interval if type == :interval
scope = @population.condition_scope(scope, field[:key])
return interval(scope, source, expression, parsed) return interval(scope, source, expression, parsed)
end end
clauses = [] clauses = []
@@ -158,6 +185,7 @@ class CountsCondition
elsif operator == 'ne' elsif operator == 'ne'
predicate = "(NOT (#{ predicate }) OR #{ blank })" predicate = "(NOT (#{ predicate }) OR #{ blank })"
end end
scope = @population.condition_scope(scope, field[:key])
scope.where(predicate, *binds) scope.where(predicate, *binds)
end end
@@ -168,6 +196,13 @@ class CountsCondition
values = entry['values'] values = entry['values']
raise ArgumentError unless values.is_a?(Array) && values.length <= 100 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 != '' } raise ArgumentError if [entry['value'], entry['end']].any? { !_1.nil? && _1 != '' }
parsed = values.map do |value| parsed = values.map do |value|
@@ -191,7 +226,10 @@ class CountsCondition
operator = entry['operator'] operator = entry['operator']
if parsed.empty? 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 end
source, expression = source_for(field[:key]) source, expression = source_for(field[:key])
if field[:set_type] == 'tag_reference' if field[:set_type] == 'tag_reference'
@@ -212,6 +250,7 @@ class CountsCondition
predicate = source ? "NOT #{ predicate }" : predicate = source ? "NOT #{ predicate }" :
"(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })" "(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })"
end end
scope = @population.condition_scope(scope, field[:key])
scope.where(predicate, *parsed) scope.where(predicate, *parsed)
end end
+9 -1
ファイルの表示
@@ -70,6 +70,13 @@ class CountsPopulation
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE' @authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
end 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) def axis_condition(scope, entry)
raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE
@@ -106,7 +113,7 @@ class CountsPopulation
else else
raise ArgumentError unless definition.axes(history).include?(key) raise ArgumentError unless definition.axes(history).include?(key)
scope = scope.where("#{ definition.expression(key) } <=> ?", entry['value']) scope = scope.where(definition.expression(key) => entry['value'])
end end
scope scope
end end
@@ -209,6 +216,7 @@ class CountsPopulation
def apply_tags def apply_tags
raise ArgumentError unless definition.resource == 'posts' raise ArgumentError unless definition.resource == 'posts'
@scope = condition_scope(@scope, 'tags')
if params['tag'].present? if params['tag'].present?
@scope = @scope.where(snapshot_membership( @scope = @scope.where(snapshot_membership(
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }")) "#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
+3 -18
ファイルの表示
@@ -105,25 +105,12 @@ class CountsResourceDefinition
def time_column = 'created_at' def time_column = 'created_at'
def column(key) def column(key)
raise ArgumentError unless fields.key?(key) raise ArgumentError unless fields.key?(key)
# Keep the V1 Counts field name at the API boundary only.
return 'linked_tag_ids' if resource == 'nico_tags' && key == 'linked_tags'
{ 'resource_id' => keys.first, 'user' => 'created_by_user_id' }.fetch(key, key) { 'resource_id' => keys.first, 'user' => 'created_by_user_id' }.fetch(key, key)
end end
def expression(key, row = 'v') def expression(key, row = 'v') = "#{ row }.#{ column(key) }"
if resource == 'nico_tags' && key == 'name' def changes = fields.keys - COMMON.keys - ['updated_at', 'updated_by_user_id']
return "(SELECT CONCAT(external_tags.platform, ':', external_tags.name) " +
"FROM external_tags WHERE external_tags.id = #{ row }.tag_id)"
end
"#{ row }.#{ column(key) }"
end
def changes
columns = fields.keys - COMMON.keys - ['updated_at', 'updated_by_user_id']
resource == 'nico_tags' ? columns - ['name'] : columns
end
def axes(history) def axes(history)
fields.reject { |_key, type| type == :json }.keys + fields.reject { |_key, type| type == :json }.keys +
(resource == 'posts' ? ['tags'] : []) + (history ? ['changed_field'] : []) (resource == 'posts' ? ['tags'] : []) + (history ? ['changed_field'] : [])
@@ -131,9 +118,7 @@ class CountsResourceDefinition
def metadata def metadata
fields.map do |key, type| fields.map do |key, type|
nullable = (resource == 'nico_tags' && key == 'name') || { key:, type:, nullable: model.columns_hash.fetch(column(key)).null,
model.columns_hash.fetch(column(key)).null
{ key:, type:, nullable:,
operators: OPERATORS.fetch(type, []) } operators: OPERATORS.fetch(type, []) }
end end
end end
+1 -3
ファイルの表示
@@ -201,8 +201,6 @@ class CountsStateQuery
end end
def listing def listing
external_name = CountsResourceDefinition.new('nico_tags')
.expression('name', 'external_version')
catalog = <<~SQL catalog = <<~SQL
, tag_counts AS ( , tag_counts AS (
SELECT membership.kind, membership.tag_id, COUNT(*) AS post_count SELECT membership.kind, membership.tag_id, COUNT(*) AS post_count
@@ -211,7 +209,7 @@ class CountsStateQuery
SELECT 'internal' AS kind, tag_id, name, names, SELECT 'internal' AS kind, tag_id, name, names,
deprecated_at FROM tag_at WHERE event_type <> 'discard' deprecated_at FROM tag_at WHERE event_type <> 'discard'
UNION ALL UNION ALL
SELECT tc.kind, tc.tag_id, #{ external_name } AS name, JSON_OBJECT() AS names, SELECT tc.kind, tc.tag_id, external_version.name, JSON_OBJECT() AS names,
NULL AS deprecated_at NULL AS deprecated_at
FROM tag_counts tc FROM tag_counts tc
LEFT JOIN tag_at internal_version LEFT JOIN tag_at internal_version
+1 -1
ファイルの表示
@@ -253,7 +253,7 @@ class CountsTableExport
end end
def summary_value(value, index, page) 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') field = @query.axes.all.fetch(index).fetch('field')
return @labels.fetch('bot', '') if field == 'user' && value.nil? return @labels.fetch('bot', '') if field == 'user' && value.nil?
+2 -12
ファイルの表示
@@ -182,7 +182,6 @@ class CountsTableQuery
order = sort_sql { detail_expression(_1) } order = sort_sql { detail_expression(_1) }
query = query.order(Arel.sql((order + ['v.id ASC']).join(', '))) query = query.order(Arel.sql((order + ['v.id ASC']).join(', ')))
.limit(limit).offset((page - 1) * limit) .limit(limit).offset((page - 1) * limit)
query = query.includes(:external_tag) if @definition.resource == 'nico_tags'
rows = query.to_a rows = query.to_a
if ['posts', 'tags'].include?(@definition.resource) if ['posts', 'tags'].include?(@definition.resource)
point = population.history ? population.at : point = population.history ? population.at :
@@ -221,19 +220,11 @@ class CountsTableQuery
private private
# Public timeline predicates may include hidden records. Private predicates must # Private predicates require visibility independently of their operator.
# never become a count oracle, even when the selected axes themselves are public. # Ordering/measure state must not select a server-only masked coordinate.
def validate_summary_conditions! def validate_summary_conditions!
return if mode != 'summary' || HistoryAccess.admin? return if mode != 'summary' || HistoryAccess.admin?
fields = QueryState.records(@params, 'conditions') + QueryState.records(@params, 'drill')
private_fields = fields.any? { !CountsAxes::PUBLIC_FIELDS.include?(_1['field']) }
keys = ['tags', 'tag', 'kind', 'resource_id', 'user_id']
private_parameters = keys.any? do |key|
@params.any? { |name, value| (name == key || name.start_with?("#{ key }.")) && value != '' }
end
raise CountsAxes::PrivateCondition if private_fields || private_parameters
['column_order', 'measures'].each do |key| ['column_order', 'measures'].each do |key|
# Coordinates are compared only to already masked expressions, never raw # Coordinates are compared only to already masked expressions, never raw
# values. Public coordinates remain sortable; a guessed private ID cannot match. # values. Public coordinates remain sortable; a guessed private ID cannot match.
@@ -495,7 +486,6 @@ class CountsTableQuery
table = model.table_name table = model.table_name
scope = HistoryAccess.visible(model.where(tag_id: ids)).where('created_at <= ?', point) scope = HistoryAccess.visible(model.where(tag_id: ids)).where('created_at <= ?', point)
scope = scope.includes(:external_tag) if model == NicoTagVersion
bound = @connection.quote(point) bound = @connection.quote(point)
if population.history && @definition.resource == 'posts' if population.history && @definition.resource == 'posts'
key = kind == 'internal' ? 'tag_id' : 'external_tag_id' key = kind == 'internal' ? 'tag_id' : 'external_tag_id'
+2 -2
ファイルの表示
@@ -1,5 +1,4 @@
class NicoTagVersionRecorder < VersionRecorder class NicoTagVersionRecorder < VersionRecorder
EVENT_TYPES = ['create', 'update'].freeze
def self.record! external_tag:, event_type:, created_by_user: def self.record! external_tag:, event_type:, created_by_user:
new(external_tag:, event_type:, created_by_user:).record! new(external_tag:, event_type:, created_by_user:).record!
end end
@@ -15,7 +14,8 @@ class NicoTagVersionRecorder < VersionRecorder
def record_key = :external_tag def record_key = :external_tag
def snapshot_attributes def snapshot_attributes
{ linked_tag_ids: @record.snapshot_linked_tag_ids.join(' ') } { name: "#{ @record.platform }:#{ @record.name }",
linked_tags: @record.snapshot_linked_tag_names.join(' ') }
end end
def tracks_version_no_on_record? = false def tracks_version_no_on_record? = false
-5
ファイルの表示
@@ -1,5 +0,0 @@
class AddLinkedTagIdsToNicoTagVersions < ActiveRecord::Migration[8.0]
def change
add_column :nico_tag_versions, :linked_tag_ids, :text
end
end
-42
ファイルの表示
@@ -1,42 +0,0 @@
class MigrateLinkedTagsInNicoTagVersions < ActiveRecord::Migration[8.0]
class MigrationTagVersion < ActiveRecord::Base
self.table_name = 'tag_versions'
end
class MigrationNicoTagVersion < ActiveRecord::Base
self.table_name = 'nico_tag_versions'
end
def up
# 除票外部タグの恢復はないので除票履歴があれば全消しでよいはず.
execute <<~SQL
DELETE
t1
FROM
nico_tag_versions t1
INNER JOIN
nico_tag_versions t2
ON
t1.tag_id = t2.tag_id
AND t2.event_type = 'discard'
SQL
MigrationNicoTagVersion.find_each do |ntv|
linked_tag_ids = ntv.linked_tags.split.map { |name|
# 厳密には一意ぢゃないけどめんだぅだからこれでいいや.
MigrationTagVersion
.order(created_at: :desc, id: :desc)
.find_by("names ->> '$.ja.name' = ?", name)
&.tag_id
}.compact.uniq.sort.join(' ')
ntv.update_columns(linked_tag_ids:)
end
change_column_null :nico_tag_versions, :linked_tag_ids, false
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
-6
ファイルの表示
@@ -1,6 +0,0 @@
class RemoveLinkedTagsFromNicoTagVersions < ActiveRecord::Migration[8.0]
def change
remove_column :nico_tag_versions, :linked_tags
remove_column :nico_tag_versions, :name
end
end
+5
ファイルの表示
@@ -0,0 +1,5 @@
class AddForeignKeyOnDeerjikistsTagId < ActiveRecord::Migration[8.0]
def change
add_foreign_key :deerjikists, :tags, column: :tag_id
end
end
生成ファイル
+3 -2
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_11_08_020000) do ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -301,9 +301,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_11_08_020000) do
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
t.integer "version_no", null: false t.integer "version_no", null: false
t.string "event_type", null: false t.string "event_type", null: false
t.string "name", null: false
t.text "linked_tags", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
t.text "linked_tag_ids", null: false
t.index ["created_at"], name: "index_nico_tag_versions_on_created_at" t.index ["created_at"], name: "index_nico_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
+15 -2
ファイルの表示
@@ -9,8 +9,21 @@
# end # end
ApplicationRecord.transaction do ApplicationRecord.transaction do
Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' } Language.find_or_create_by!(code: 'ja') { _1.name = '日本語' }
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' } Script.find_or_create_by!(code: 'Jpan') { _1.name = '漢字、仮名文字およびラテン文字' }
Region.find_or_create_by!(code: 392) do
_1.alpha_code = 'JP'
_1.name = '日本国'
end
Locale.find_or_create_by!(code: 'ja') do
_1.language_code = 'ja'
_1.script_code = 'Jpan'
_1.region_code = 392
_1.name = '日本語'
end
Language.find_or_create_by!(code: 'ain') { _1.name = 'ヤウンモシルン アイヌ イタㇰ' }
Script.find_or_create_by!(code: 'Kana') { _1.name = 'カナモジ オヨビ ラテンモジ' }
end end
post_url_sanitisation_rules = [ post_url_sanitisation_rules = [
+8 -43
ファイルの表示
@@ -11,63 +11,28 @@ RSpec.describe ExternalTag, type: :model do
expect(external.reload.name).to eq('raw tag[]') expect(external.reload.name).to eq('raw tag[]')
end end
it 'does not allow a persisted external tag to change its platform or name' do it 'deletes post associations without deleting posts or version history' do
external = create(:external_tag, name: 'immutable')
expect { external.update!(name: 'renamed') }
.to raise_error(ActiveRecord::RecordInvalid)
expect(external.errors[:name]).to include('は変更できません.')
external.reload
external.platform = nil
expect { external.save! }.to raise_error(ActiveRecord::RecordInvalid)
expect(external.errors[:platform]).to include('は変更できません.')
expect(external.reload).to have_attributes(platform: 'nico', name: 'immutable')
end
it 'rejects delete, which bypasses destroy callbacks' do
external = create(:external_tag)
expect { external.delete }.to raise_error(ActiveRecord::ReadOnlyRecord)
expect(external.reload).to be_persisted
end
it 'increments the counter through post associations without changing identity' do
external = create(:external_tag, name: 'counter')
post = create(:post)
expect {
PostExternalTag.create!(post:, external_tag: external)
}.to change { external.reload.post_count }.by(1)
expect(external.reload).to have_attributes(platform: 'nico', name: 'counter')
end
it 'does not destroy external tags or their post associations and history' do
external = create(:external_tag) external = create(:external_tag)
post = create(:post) post = create(:post)
PostExternalTag.create!(post:, external_tag: external) PostExternalTag.create!(post:, external_tag: external)
version = NicoTagVersionRecorder.record!( version = NicoTagVersionRecorder.record!(
external_tag: external, event_type: :create, created_by_user: nil) external_tag: external, event_type: :create, created_by_user: nil)
expect { external.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed) external.destroy!
expect(external.reload).to be_persisted expect(PostExternalTag.where(external_tag_id: external.id)).to be_empty
expect(PostExternalTag.exists?(post:, external_tag: external)).to be(true) expect(post.reload.external_tags).to be_empty
expect(post.reload.external_tags).to contain_exactly(external)
expect(version.reload.tag_id).to eq(external.id) expect(version.reload.tag_id).to eq(external.id)
end end
it 'does not destroy an external tag or its links to internal tags' do it 'deletes external links without deleting linked internal tags' do
external = create(:external_tag) external = create(:external_tag)
tag = create(:tag) tag = create(:tag)
relation = NicoTagRelation.create!(nico_tag: external, tag:) NicoTagRelation.create!(nico_tag: external, tag:)
expect { external.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed) external.destroy!
expect(external.reload).to be_persisted expect(NicoTagRelation.where(nico_tag_id: external.id)).to be_empty
expect(relation.reload).to be_persisted
expect(Tag.exists?(tag.id)).to be(true) expect(Tag.exists?(tag.id)).to be(true)
end end
end end
+3 -3
ファイルの表示
@@ -76,7 +76,7 @@ RSpec.describe 'Counts artifact policy and external references' do
end end
end end
it 'does not expose a private linked tag ID through an external history snapshot' do it 'does not expose a private linked tag name through an external history snapshot' do
tag = create(:tag, primary_name: 'private-linked-name') tag = create(:tag, primary_name: 'private-linked-name')
TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name', TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name',
category: 'general', aliases: '', parent_tag_ids: '', created_at: Time.utc(2024), category: 'general', aliases: '', parent_tag_ids: '', created_at: Time.utc(2024),
@@ -84,7 +84,7 @@ RSpec.describe 'Counts artifact policy and external references' do
auto_generated: false, aliases: [] } }) auto_generated: false, aliases: [] } })
external = create(:external_tag, name: 'public-external') external = create(:external_tag, name: 'public-external')
row = NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create', row = NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create',
linked_tag_ids: tag.id.to_s, name: 'nico:public-external', linked_tags: 'private-linked-name',
created_at: Time.utc(2024)) created_at: Time.utc(2024))
post = create(:post) post = create(:post)
snapshot = PostVersion.create!(post:, version_no: 1, event_type: 'create', snapshot = PostVersion.create!(post:, version_no: 1, event_type: 'create',
@@ -99,7 +99,7 @@ RSpec.describe 'Counts artifact policy and external references' do
expect(HistoryAccess.visible(NicoTagVersion.where(id: row.id))).to exist expect(HistoryAccess.visible(NicoTagVersion.where(id: row.id))).to exist
end end
public_row = NicoTagVersion.create!(external_tag: external, version_no: 2, event_type: 'update', public_row = NicoTagVersion.create!(external_tag: external, version_no: 2, event_type: 'update',
linked_tag_ids: '', name: 'nico:public-external', linked_tags: 'private-linked-name-extra',
created_at: Time.utc(2024, 7)) created_at: Time.utc(2024, 7))
HistoryAccess.set(user: nil) do HistoryAccess.set(user: nil) do
expect(HistoryAccess.visible(NicoTagVersion.where(id: public_row.id))).to exist expect(HistoryAccess.visible(NicoTagVersion.where(id: public_row.id))).to exist
+4 -3
ファイルの表示
@@ -10,22 +10,23 @@ RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do
expect(next_tag.id).to be > external.id expect(next_tag.id).to be > external.id
end end
it 'does not reuse IDs after internal deletion while external tags remain' do it 'does not reuse physically deleted IDs in either table' do
tag = create(:tag) tag = create(:tag)
external = create(:external_tag) external = create(:external_tag)
TagName.where(tag_id: tag.id).delete_all TagName.where(tag_id: tag.id).delete_all
tag.destroy! tag.destroy!
expect(ExternalTag.exists?(external.id)).to be(true) external.destroy!
expect(create(:tag).id).to be > external.id expect(create(:tag).id).to be > external.id
expect(create(:external_tag).id).to be > external.id expect(create(:external_tag).id).to be > external.id
end end
it 'retains overlapping IDs after a tag update and a new allocation' do it 'retains existing overlapping IDs without renumbering on update or insert' do
tag = create(:tag) tag = create(:tag)
external = create(:external_tag) external = create(:external_tag)
external.update_columns(id: tag.id) external.update_columns(id: tag.id)
tag.update!(category: :meta) tag.update!(category: :meta)
external.update!(name: 'updated_external')
before_tag = tag.reload.attributes before_tag = tag.reload.attributes
before_external = external.reload.attributes before_external = external.reload.attributes
+5 -15
ファイルの表示
@@ -47,34 +47,24 @@ RSpec.describe VersionRecord, type: :model do
external_tag: nico_tag, external_tag: nico_tag,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
linked_tag_ids: '', name: "nico:#{ nico_tag.name }",
linked_tags: '',
created_at: Time.current, created_at: Time.current,
created_by_user: nil created_by_user: nil
) )
expect { expect {
version.update!(linked_tag_ids: '123') version.update!(name: 'nico:changed')
}.to raise_error(ActiveRecord::ReadOnlyRecord) }.to raise_error(ActiveRecord::ReadOnlyRecord)
end end
it 'rejects discard and restore versions for external tags' do
[:discard, :restore].each do |event_type|
version = NicoTagVersion.new(
external_tag: nico_tag, version_no: 1, event_type:,
linked_tag_ids: '', created_at: Time.current)
expect(version).not_to be_valid
expect(version.errors[:event_type]).not_to be_empty
end
end
it 'prevents NicoTagVersion destroy' do it 'prevents NicoTagVersion destroy' do
version = NicoTagVersion.create!( version = NicoTagVersion.create!(
external_tag: nico_tag, external_tag: nico_tag,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
linked_tag_ids: '', name: "nico:#{ nico_tag.name }",
linked_tags: '',
created_at: Time.current, created_at: Time.current,
created_by_user: nil created_by_user: nil
) )
+196
ファイルの表示
@@ -0,0 +1,196 @@
require 'rails_helper'
RSpec.describe 'Counts masked search', type: :request do
let!(:tag) { create(:tag, primary_name: '秘密のタグ') }
let(:actor) { create(:user, :member) }
let(:admin) { create(:user, :admin) }
let(:base) do
{ 'resource' => 'tags', 'definition' => 'history', 'mode' => 'summary',
'locale' => 'ja', 'execution_at' => '2025-01-01T00:00:00Z',
'timezone' => 'UTC', 'rows' => '', 'cols' => '', 'sort' => '' }
end
before do
[1, 2].each do |number|
TagVersion.create!(tag:, version_no: number,
event_type: number == 1 ? 'create' : 'discard', name: '秘密のタグ',
category: 'general', aliases: '秘密の別名', parent_tag_ids: '',
names: { ja: { name: '秘密のタグ', script_code: 'Jpan', auto_generated: false,
aliases: [{ name: '秘密の別名', script_code: 'Jpan' }] } },
created_by_user: actor, created_at: Time.utc(2023, number))
end
end
def query(extra = {})
get '/counts/query', params: base.merge(extra)
expect(response).to have_http_status(:ok), extra.inspect
response.parsed_body
end
def predicate(field, operator, value, index = 0)
prefix = "conditions.#{ index }"
result = { 'conditions' => '', "#{ prefix }.field" => field,
"#{ prefix }.operator" => operator }
if ['in', 'any', 'all', 'none', 'not_in'].include?(operator)
result.merge!("#{ prefix }.values" => '1', 'condition_values' => '',
"condition_values.#{ index }.condition" => index.to_s,
"condition_values.#{ index }.value_type" => 'string',
"condition_values.#{ index }.value" => value)
else
result["#{ prefix }.value"] = value
end
result
end
it 'rejects every name and alias predicate on private history' do
['name', 'name_ja', 'names.name', 'aliases.name', 'names.aliases.name'].each do |field|
secret = field.include?('aliases') ? '秘密の別名' : '秘密のタグ'
multiple = !['name', 'name_ja'].include?(field)
operators = multiple ? ['any', 'all', 'none'] : ['in', 'not_in']
['eq', 'ne', 'like', *operators].each do |operator|
[secret, '********', '%', ''].each do |value|
expected = (value == '' && ['eq', 'ne', 'like'].include?(operator)) ? 2 : 0
expect(query(predicate(field, operator, value))['count']).to eq(expected)
end
end
blank = predicate(field, 'eq', '').merge('conditions.0.blank' => '1')
expect(query(blank)['count']).to eq(0)
end
end
it 'ignores missing and empty scalar values unless blank is checked' do
['name', 'name_ja', 'names.name', 'aliases.name'].each do |field|
['eq', 'ne', 'like'].each do |operator|
condition = predicate(field, operator, '')
[condition, condition.except('conditions.0.value')].each do |empty|
expect(query(empty)['count']).to eq(2)
expect(query(empty.merge('conditions.0.blank' => '0'))['count']).to eq(2)
expect(query(empty.merge('conditions.0.blank' => '1'))['count']).to eq(0)
end
end
end
end
it 'keeps details totals unchanged for inactive private predicates' do
settings = predicate('name', 'ne', '').merge('mode' => 'details')
result = query(settings)
expect(result['count']).to eq(0)
expect(result['versions']).to be_empty
expect(result['output_count']).to eq(2)
active = query(settings.merge('conditions.0.blank' => '1'))
expect(active['output_count']).to eq(0)
end
it 'ignores missing sets but preserves explicit empty set semantics' do
{ 'name' => ['in', 'not_in'], 'aliases.name' => ['any', 'all', 'none'] }.each do
|field, operators|
operators.each do |operator|
condition = { 'conditions' => '', 'conditions.0.field' => field,
'conditions.0.operator' => operator }
expect(query(condition)['count']).to eq(2)
empty = condition.merge('conditions.0.values' => '1', 'condition_values' => '')
expected = ['in', 'any'].include?(operator) ? 0 : 2
expect(query(empty)['count']).to eq(expected)
expect(query(condition.merge('conditions.0.blank' => '1'))['count']).to eq(0)
# An explicit empty string element is a real value, not an unspecified set.
expect(query(predicate(field, operator, ''))['count']).to eq(0)
end
end
end
it 'ignores empty private predicates regardless of their position' do
first = predicate('name', 'ne', '')
.merge(predicate('resource_id', 'eq', tag.id.to_s, 1))
last = predicate('resource_id', 'eq', tag.id.to_s)
.merge(predicate('aliases.name', 'like', '', 1))
expect(query(first)['count']).to eq(2)
expect(query(last)['count']).to eq(2)
active = predicate('name', 'ne', 'unrelated')
.merge(predicate('aliases.name', 'like', '', 1))
expect(query(active)['count']).to eq(0)
end
it 'searches public literal markers and treats axis predicates as explicit' do
public_tag = create(:tag, primary_name: '********')
TagVersion.create!(tag: public_tag, version_no: 1, event_type: 'create',
name: '********', category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: '********', script_code: 'Jpan', auto_generated: false,
aliases: [] } }, created_at: Time.utc(2023))
expect(query['count']).to eq(3)
['eq', 'ne', 'like', 'in', 'not_in'].each do |operator|
expected = ['ne', 'not_in'].include?(operator) ? 0 : 1
expect(query(predicate('name', operator, '********'))['count']).to eq(expected)
end
blank = predicate('aliases.name', 'eq', '').merge('conditions.0.blank' => '1')
expect(query(blank)['count']).to eq(1)
['********', '', nil].each do |value|
condition = { 'conditions' => '', 'conditions.0.field' => 'name',
'conditions.0.operator' => 'axis_eq',
'conditions.0.value_type' => value.nil? ? 'null' : 'string' }
condition['conditions.0.value'] = value unless value.nil?
expected = value == '********' ? 1 : 0
expect(query(condition)['count']).to eq(expected)
drill = { 'drill' => '', 'drill.0.field' => 'name',
'drill.0.value_type' => value.nil? ? 'null' : 'string' }
drill['drill.0.value'] = value unless value.nil?
expect(query(drill)['count']).to eq(expected)
end
end
it 'keeps public identity and timeline predicates searchable' do
expect(query['count']).to eq(2)
values = { 'resource_id' => tag.id.to_s, 'version_no' => '2',
'event_type' => 'discard', 'user' => actor.id.to_s,
'created_at' => '2023-02-01T00:00:00Z' }
values.each do |field, value|
expected = ['resource_id', 'user'].include?(field) ? 2 : 1
expect(query(predicate(field, 'eq', value))['count']).to eq(expected)
end
condition = predicate('name', 'ne', '秘密のタグ')
.merge(predicate('event_type', 'eq', 'discard', 1))
expect(query(condition)['count']).to eq(0)
expect(query(predicate('category', 'eq', '********'))['count']).to eq(0)
expect(query(predicate('category', 'eq', 'general'))['count']).to eq(0)
end
it 'keeps public predicates available after combining multiple conditions' do
condition = predicate('resource_id', 'eq', tag.id.to_s)
.merge(predicate('event_type', 'eq', 'discard', 1))
expect(query(condition)['count']).to eq(1)
condition.merge!(predicate('name', 'ne', 'unrelated', 2))
expect(query(condition)['count']).to eq(0)
end
it 'keeps real name searches for administrators and excludes private details' do
condition = predicate('name', 'eq', '秘密のタグ')
details = query(condition.merge('mode' => 'details'))
expect(details['versions']).to be_empty
expect(details['output_count']).to eq(0)
masked = query(predicate('name', 'eq', '********').merge('mode' => 'details'))
expect(masked['versions']).to be_empty
expect(masked['output_count']).to eq(0)
sign_in_as(admin)
expect(query(condition)['count']).to eq(2)
expect(query(condition.merge('mode' => 'details'))['versions'].length).to eq(2)
end
it 'rejects private names reached through a post tag snapshot' do
post = create(:post)
PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'post',
url: post.url, tags: '', parent_post_ids: '',
tags_json: [{ tag_id: tag.id, version_no: 1, name: '秘密のタグ',
category: 'general', sections: [] }],
created_at: Time.utc(2024))
['tags.name', 'tags.names.name', 'tags.name_ja'].each do |field|
['eq', 'ne', 'like', 'any', 'all', 'none'].each do |operator|
['秘密のタグ', '********', '%'].each do |value|
settings = predicate(field, operator, value).merge('resource' => 'posts')
expect(query(settings)['count']).to eq(0)
end
end
blank = predicate(field, 'eq', '').merge('resource' => 'posts',
'conditions.0.blank' => '1')
expect(query(blank)['count']).to eq(0)
end
end
end
+21 -100
ファイルの表示
@@ -19,7 +19,7 @@ RSpec.describe 'Counts table API', type: :request do
names: { ja: { name: 'alpha', script_code: 'Jpan', auto_generated: false, aliases: [] } }, names: { ja: { name: 'alpha', script_code: 'Jpan', auto_generated: false, aliases: [] } },
created_at: Time.utc(2023)) created_at: Time.utc(2023))
NicoTagVersion.create!(external_tag: external, version_no: 1, NicoTagVersion.create!(external_tag: external, version_no: 1,
event_type: 'create', linked_tag_ids: '', created_at: Time.utc(2023)) event_type: 'create', name: 'beta', linked_tags: '', created_at: Time.utc(2023))
snapshots = [ snapshots = [
{ tag_id: tag.id, version_no: 1, name: 'alpha', category: 'general', sections: [] }, { tag_id: tag.id, version_no: 1, name: 'alpha', category: 'general', sections: [] },
{ external_tag_id: external.id }] { external_tag_id: external.id }]
@@ -50,96 +50,6 @@ RSpec.describe 'Counts table API', type: :request do
def versions(result) = result.fetch('versions').map { _1.fetch('version_no') } def versions(result) = result.fetch('versions').map { _1.fetch('version_no') }
context 'nico link ID snapshots through the V1 field name' do
let(:base) do
super().merge(resource: 'nico_tags')
end
before do
NicoTagVersion.create!(external_tag: external, version_no: 2,
event_type: 'update', linked_tag_ids: tag.id.to_s,
created_at: Time.utc(2024, 1, 1))
NicoTagVersion.create!(external_tag: external, version_no: 3,
event_type: 'update', linked_tag_ids: '',
created_at: Time.utc(2024, 2, 1))
end
it 'returns ID values under linked_tags without exposing the physical column key' do
result = query
expect(versions(result)).to eq([1, 2, 3])
expect(result['versions'].map { _1.fetch('linked_tags') }).to eq(['', tag.id.to_s, ''])
result['versions'].each do |row|
expect(row).not_to have_key('linked_tag_ids')
expect(row.fetch('name')).to eq('nico:beta')
end
end
it 'advertises the legacy field, label and default columns only' do
get '/counts/definitions/nico_tags', params: { locale: 'ja' }
expect(response).to have_http_status(:ok)
body = response.parsed_body
['fields', 'filter_fields', 'output_fields'].each do |collection|
keys = body.fetch(collection).map { _1.fetch('key') }
expect(keys).to include('linked_tags')
expect(keys).not_to include('linked_tag_ids')
end
field = body.fetch('output_fields').find { _1['key'] == 'linked_tags' }
expect(field.fetch('label')).to eq('counts:attributes.linked_tags')
body.fetch('axes').each_value do |keys|
expect(keys).to include('linked_tags')
expect(keys).not_to include('linked_tag_ids')
end
body.fetch('defaults').select { _1['mode'] == 'details' }.each do |entry|
settings = entry.fetch('params').to_h { [_1.fetch('key'), _1.fetch('value')] }
expect(settings.fetch('columns').split).to include('name', 'linked_tags')
expect(settings.fetch('columns').split).not_to include('linked_tag_ids')
end
end
it 'filters IDs and legacy drills and compares link changes through linked_tags' do
predicate = { conditions: '', 'conditions.0.field' => 'linked_tags',
'conditions.0.operator' => 'eq', 'conditions.0.value' => tag.id.to_s }
expect(versions(query(predicate))).to eq([2])
expect(versions(query(axis('linked_tags', tag.id.to_s, root: 'drill')))).to eq([2])
expect(versions(query(axis('changed_field', 'linked_tags')))).to eq([2, 3])
expect(versions(query(predicate.merge('conditions.0.value' => 'alpha')))).to be_empty
end
it 'sorts ID snapshots through the public field in both directions' do
expect(versions(query(sort: 'linked_tags:asc version_no:asc'))).to eq([1, 3, 2])
expect(versions(query(sort: 'linked_tags:desc version_no:asc'))).to eq([2, 1, 3])
end
it 'restores historical link IDs before applying a latest snapshot filter' do
predicate = { definition: 'latest', at: '2024-01-15T00:00:00Z',
conditions: '', 'conditions.0.field' => 'linked_tags',
'conditions.0.operator' => 'eq', 'conditions.0.value' => tag.id.to_s }
expect(versions(query(predicate))).to eq([2])
expect(versions(query(predicate.except(:at)))).to be_empty
end
it 'groups by linked_tags ID values and accepts the resulting drill condition' do
sign_in_as(create(:user, :admin))
result = query(mode: 'summary', rows: 'linked_tags', cols: '', sort: '')
expect(result.fetch('cells')).to match_array([
{ 'values' => [''], 'count' => 2 },
{ 'values' => [tag.id.to_s], 'count' => 1 }])
expect(versions(query(axis('linked_tags', tag.id.to_s)))).to eq([2])
end
it 'rejects the physical column as a public filter, axis, sort or change field' do
sign_in_as(create(:user, :admin))
[
axis('linked_tag_ids', tag.id.to_s),
axis('changed_field', 'linked_tag_ids'),
{ mode: 'summary', rows: 'linked_tag_ids', cols: '', sort: '' },
{ sort: 'linked_tag_ids:asc' }].each do |invalid|
get '/counts/query', params: base.merge(invalid)
expect(response).to have_http_status(:bad_request), invalid.inspect
end
end
end
it 'identifies a totals timeout without exposing SQL in the error response' do it 'identifies a totals timeout without exposing SQL in the error response' do
allow_any_instance_of(CountsPivotTotals).to receive(:call) allow_any_instance_of(CountsPivotTotals).to receive(:call)
.and_raise(CountsPivotTotals::TimedOut, 'private SQL') .and_raise(CountsPivotTotals::TimedOut, 'private SQL')
@@ -313,10 +223,11 @@ RSpec.describe 'Counts table API', type: :request do
expect(versions(query(tags: 'renamed-alpha'))).to eq([1, 2, 4]) expect(versions(query(tags: 'renamed-alpha'))).to eq([1, 2, 4])
end end
it 'resolves immutable external names across historical snapshots' do it 'resolves external names from current registrations too' do
expect(versions(query(tags: 'nico:beta'))).to eq([1]) external.update!(name: 'renamed-beta')
expect(versions(query(tags: 'nico:renamed-beta'))).to eq([]) expect(versions(query(tags: 'nico:renamed-beta'))).to eq([1])
expect(versions(query(tags: 'not:nico:beta'))).to eq([2, 3, 4]) expect(versions(query(tags: 'nico:beta'))).to eq([])
expect(versions(query(tags: 'not:nico:renamed-beta'))).to eq([2, 3, 4])
end end
end end
@@ -443,15 +354,25 @@ RSpec.describe 'Counts table API', type: :request do
expect(result['cells'].sum { _1['count'] }).to eq(4) expect(result['cells'].sum { _1['count'] }).to eq(4)
expect(response.body).not_to include('hidden title') expect(response.body).not_to include('hidden title')
masked = query(mode: 'summary', rows: 'resource_id', cols: '') masked = query(mode: 'summary', rows: 'resource_id', cols: '')
expect(masked['cells'].map { _1['values'] }).to eq([[CountsAxes::MASKED_VALUE]]) expect(masked['cells'].map { _1['values'] }).to eq([[record.id]])
expect(masked['cells'].first['count']).to eq(4) expect(masked['cells'].first['count']).to eq(4)
expect(query['versions']).to be_empty expect(query['versions']).to be_empty
end end
it 'rejects private summary predicates instead of exposing a count oracle' do it 'allows public identity predicates without exposing private snapshot fields' do
get '/counts/query', params: base.merge(mode: 'summary', resource_id: record.id.to_s) PostVersion.create!(post: record, version_no: 4, event_type: 'discard',
expect(response).to have_http_status(:forbidden) url: record.url, title: 'hidden title', tags: '', tags_json: [],
expect(json).to eq('error' => 'private_condition') parent_post_ids: '', created_at: Time.utc(2024, 3))
get '/counts/query', params: base.merge(mode: 'summary',
resource_id: record.id.to_s, rows: 'resource_id', cols: 'title')
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(4)
expect(json['cells'].sum { _1['count'] }).to eq(4)
expect(json['cells'].map { _1['values'].last })
.to include(CountsAxes::MASKED_VALUE)
expect(response.body).not_to include('hidden title')
end end
it 'reports the combined axis limit without replacing it with a generic 400' do it 'reports the combined axis limit without replacing it with a generic 400' do
+14 -8
ファイルの表示
@@ -38,9 +38,12 @@ RSpec.describe 'NicoTags', type: :request do
end end
it 'filters by nico tag name, linked tag name, and link status' do it 'filters by nico tag name, linked tag name, and link status' do
linked = create(:external_tag, name: 'search_linked') linked = create(:external_tag)
unlinked = create(:external_tag, name: 'search_unlinked') linked.update!(name: 'search_linked')
other = create(:external_tag, name: 'other') unlinked = create(:external_tag)
unlinked.update!(name: 'search_unlinked')
other = create(:external_tag)
other.update!(name: 'other')
destination = create(:tag, category: :general) destination = create(:tag, category: :general)
primary_tag_name_for(destination, 'ja').update!(name: 'destination_search') primary_tag_name_for(destination, 'ja').update!(name: 'destination_search')
NicoTagRelation.create!(nico_tag: linked, tag: destination) NicoTagRelation.create!(nico_tag: linked, tag: destination)
@@ -77,9 +80,11 @@ RSpec.describe 'NicoTags', type: :request do
end end
it 'sorts by name and timestamps' do it 'sorts by name and timestamps' do
older = create(:external_tag, name: 'ordered_a') older = create(:external_tag)
older.update!(name: 'ordered_a')
older.update_columns(created_at: 2.days.ago) older.update_columns(created_at: 2.days.ago)
newer = create(:external_tag, name: 'ordered_b') newer = create(:external_tag)
newer.update!(name: 'ordered_b')
newer.update_columns(created_at: 1.day.ago) newer.update_columns(created_at: 1.day.ago)
older_post_tag = older_post_tag =
PostExternalTag.create!(post: create(:post), external_tag: older) PostExternalTag.create!(post: create(:post), external_tag: older)
@@ -184,8 +189,9 @@ RSpec.describe 'NicoTags', type: :request do
versions = nico_tag.reload.nico_tag_versions.order(:version_no) versions = nico_tag.reload.nico_tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update']) expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.last.linked_tag_ids.split.map(&:to_i)) expect(versions.last.linked_tags.split).to match_array([
.to match_array([linked_a.id, linked_b.id]) 'nico_linked_a',
'nico_linked_b'])
expect(versions.last.created_by_user_id).to eq(admin.id) expect(versions.last.created_by_user_id).to eq(admin.id)
end end
@@ -204,7 +210,7 @@ RSpec.describe 'NicoTags', type: :request do
expect(json).to eq([]) expect(json).to eq([])
expect(nico_tag.reload.linked_tags).to be_empty expect(nico_tag.reload.linked_tags).to be_empty
expect(nico_tag.nico_tag_versions.order(:version_no).last) expect(nico_tag.nico_tag_versions.order(:version_no).last)
.to have_attributes(linked_tag_ids: '', created_by_user: member) .to have_attributes(linked_tags: '', created_by_user: member)
end end
it 'returns the tags field error when a nico tag is specified directly' do it 'returns the tags field error when a nico tag is specified directly' do
+1 -1
ファイルの表示
@@ -20,7 +20,7 @@ RSpec.describe 'Post history privacy', type: :request do
created_at: origin + index.hours) created_at: origin + index.hours)
end end
NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create', NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create',
linked_tag_ids: tag.id.to_s, name: 'nico:external-history-tag', linked_tags: 'private-history-tag',
created_at: origin) created_at: origin)
['create', 'discard', 'restore', 'update'].each_with_index do |event, index| ['create', 'discard', 'restore', 'update'].each_with_index do |event, index|
snapshots = snapshots =
-49
ファイルの表示
@@ -22,55 +22,6 @@ RSpec.describe 'Counts export contracts' do
.and_return(@counts_export_dir) .and_return(@counts_export_dir)
end end
context 'nico link IDs with the V1 export field name' do
let(:tag) { create(:tag, primary_name: 'linked export tag') }
let(:external) { create(:external_tag, name: 'external export tag') }
let(:payload) do
super().merge('resource' => 'nico_tags', 'columns' => 'linked_tags',
'headers' => '["linked_tags"]')
end
before do
NicoTagVersion.create!(external_tag: external, version_no: 1,
event_type: 'create', linked_tag_ids: '', created_at: Time.utc(2023))
NicoTagVersion.create!(external_tag: external, version_no: 2,
event_type: 'update', linked_tag_ids: tag.id.to_s, created_at: Time.utc(2024))
end
it 'exports recorded IDs with the legacy JSON key and metadata' do
result = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
expect(result.fetch('data')).to eq([
{ 'linked_tags' => '' }, { 'linked_tags' => tag.id.to_s }])
expect(result.fetch('metadata').fetch('columns')).to eq(['linked_tags'])
keys = result.fetch('metadata').fetch('fields').map { _1.fetch('key') }
expect(keys).to include('linked_tags')
expect(keys).not_to include('linked_tag_ids')
end
it 'exports the selected legacy CSV header and ID values' do
body = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
expect(CSV.parse(body.delete_prefix("\uFEFF")))
.to eq([['linked_tags'], [''], [tag.id.to_s]])
end
it 'keeps the legacy link column in default JSON exports' do
options = payload.except('columns', 'headers')
result = JSON.parse(CountsTableExport.new(options).body.to_a.join)
expect(result.fetch('metadata').fetch('columns')).to include('linked_tags')
result.fetch('data').each do |row|
expect(row).to have_key('linked_tags')
expect(row).not_to have_key('linked_tag_ids')
end
end
it 'rejects the physical column as a selected CSV or JSON field' do
['csv', 'json'].each do |format|
options = payload.merge('format' => format, 'columns' => 'linked_tag_ids')
expect { CountsTableExport.new(options) }.to raise_error(ArgumentError)
end
end
end
context 'serialization after the authorised query' do context 'serialization after the authorised query' do
let(:rows) { [{ title: "=formula\n\"quoted\"" }, { title: 'second' }] } let(:rows) { [{ title: "=formula\n\"quoted\"" }, { title: 'second' }] }
let(:page) do let(:page) do
+193 -23
ファイルの表示
@@ -69,27 +69,29 @@ RSpec.describe 'Counts masked pivot contracts' do
end end
end end
it 'preserves five tag occurrences after merging private tags and across pages' do it 'counts one opaque collection per private version across pages' do
[nil, guest, member, admin].each do |user| [nil, guest, member, admin].each do |user|
page = result(user) page = result(user)
expect(grand(page)).to eq(5) expected = user == admin ? 5 : 6
expect(page[:cells].sum { _1[:count] }).to eq(5) expect(grand(page)).to eq(expected)
expect(page[:cells].sum { _1[:count] }).to eq(expected)
expect(page).not_to have_key(:excluded_count) expect(page).not_to have_key(:excluded_count)
paged = result(user, 'limit' => '1') paged = result(user, 'limit' => '1')
expect(grand(paged)).to eq(5) expect(grand(paged)).to eq(expected)
expect(result(user, 'limit' => '1', 'page' => '2')[:group_count]) expect(result(user, 'limit' => '1', 'page' => '2')[:group_count])
.to eq(page[:group_count]) .to eq(page[:group_count])
end end
public_page = result(nil) public_page = result(nil)
hidden = public_page[:cells].select { _1[:values].first == CountsAxes::MASKED_VALUE } hidden = public_page[:cells].select { _1[:values].first == CountsAxes::MASKED_VALUE }
expect(hidden.sum { _1[:count] }).to eq(3) expect(hidden.sum { _1[:count] }).to eq(4)
expect(public_page[:axis_labels]['a0']).not_to have_key("internal:#{ tags.first.id }") expect(public_page[:axis_labels]['a0']).not_to have_key("internal:#{ tags.first.id }")
expect(public_page.to_json).not_to include('hidden-title', 'private-discard') expect(public_page.to_json).not_to include('hidden-title', 'private-discard')
end end
it 'retains distinct membership when tags are not a row axis' do it 'retains distinct membership when tags are not a row axis' do
[nil, admin].each do |user| [nil, admin].each do |user|
expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags'))).to eq(3) expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags')))
.to eq(user == admin ? 3 : 5)
expect(result(user, 'rows' => '', 'cols' => '')[:cells].sole[:count]).to eq(5) expect(result(user, 'rows' => '', 'cols' => '')[:cells].sole[:count]).to eq(5)
details = result(user, 'mode' => 'details', 'rows' => '', 'cols' => '') details = result(user, 'mode' => 'details', 'rows' => '', 'cols' => '')
expect(details[:count]).to eq(user == admin ? 5 : 1) expect(details[:count]).to eq(user == admin ? 5 : 1)
@@ -121,14 +123,13 @@ RSpec.describe 'Counts masked pivot contracts' do
it 'counts distinct changed records when tags are not additive row tuples' do it 'counts distinct changed records when tags are not additive row tuples' do
[nil, admin].each do |user| [nil, admin].each do |user|
page = result(user, 'rows' => 'changed_field', 'cols' => 'event_type') page = result(user, 'rows' => 'changed_field', 'cols' => 'event_type')
expect(grand(page)).to eq(2) expect(grand(page)).to eq(user == admin ? 2 : 4)
if user.nil? if user.nil?
expect(page[:cells].map { _1[:values].first }.uniq).to eq([CountsAxes::MASKED_VALUE]) expect(page[:cells].map { _1[:values].first }.uniq).to eq([CountsAxes::MASKED_VALUE])
end end
# Changed discard snapshots have no tags: no full cell contributes here. # Private empty collections contribute the same opaque tuple as populated ones.
empty = result(user, 'rows' => 'changed_field', 'cols' => 'tags') combined = result(user, 'rows' => 'changed_field', 'cols' => 'tags')
expect(empty[:cells]).to be_empty expect(grand(combined)).to eq(user == admin ? 0 : 4)
expect(grand(empty)).to eq(0)
end end
end end
@@ -142,7 +143,7 @@ RSpec.describe 'Counts masked pivot contracts' do
expect(page[:row_headers]).to include(hash_including(values: [nil], kind: 'data')) expect(page[:row_headers]).to include(hash_including(values: [nil], kind: 'data'))
end end
it 'sorts public numeric values numerically alongside a masked group' do it 'sorts public version numbers numerically including formerly hidden records' do
post = PostVersion.order(:id).last.post post = PostVersion.order(:id).last.post
[2, 10].each do |number| [2, 10].each do |number|
PostVersion.create!(post:, version_no: number, event_type: 'update', PostVersion.create!(post:, version_no: number, event_type: 'update',
@@ -151,27 +152,196 @@ RSpec.describe 'Counts masked pivot contracts' do
end end
page = result(nil, 'rows' => 'version_no', 'cols' => '') page = result(nil, 'rows' => 'version_no', 'cols' => '')
values = page[:row_headers].select { _1[:kind] == 'data' }.map { _1[:values].first } values = page[:row_headers].select { _1[:kind] == 'data' }.map { _1[:values].first }
expect(values.reject { _1 == CountsAxes::MASKED_VALUE }.map(&:to_i)).to eq([1, 2, 10]) expect(values.map(&:to_i)).to eq([1, 2, 10])
expect(values.last).to eq(CountsAxes::MASKED_VALUE)
end end
it 'rejects private predicates and supplied measure coordinates before SQL labels escape' do it 'keeps public identity predicates and excludes private attributes' do
probes = [{ 'resource_id' => PostVersion.first.post_id.to_s }, hidden_id = PostVersion.first.post_id.to_s
{ 'tags' => 'A' }, base = { 'rows' => '', 'cols' => '' }
{ 'conditions' => '', 'conditions.0.field' => 'title', 'conditions.0.operator' => 'eq', condition = lambda do |field, operator, value|
'conditions.0.value' => 'hidden-title-0' }, { 'conditions' => '', 'conditions.0.field' => field,
{ 'drill' => '', 'drill.0.field' => 'tags', 'drill.0.value_type' => 'string', 'conditions.0.operator' => operator, 'conditions.0.value' => value }
'drill.0.value' => "internal:#{ tags.first.id }" }, end
expect(result(nil, base.merge('resource_id' => hidden_id))[:cells].sole[:count]).to eq(2)
expect(result(nil, base.merge(condition.call('version_no', 'eq', '2')))[:cells]
.sole[:count]).to eq(2)
expect(result(nil, base.merge(condition.call('user', 'eq', member.id.to_s)))[:cells]
.sole[:count]).to eq(5)
expect(result(nil, base.merge(condition.call('title', 'eq', 'hidden-title-0')))[:cells]
.sole[:count]).to eq(0)
expect(result(nil, base.merge(condition.call('title', 'ne', 'hidden-title-0')))[:cells]
.sole[:count]).to eq(1)
expect(result(nil, base.merge(condition.call('title', 'eq', '********')))[:cells]
.sole[:count]).to eq(0)
expect(result(nil, base.merge(condition.call('video_ms', 'eq', '********')))[:cells]
.sole[:count]).to eq(0)
expect(result(nil, base.merge(condition.call('title', 'like', '%title%')))[:cells]
.sole[:count]).to eq(1)
expect(result(nil, base.merge(condition.call('title', 'like', '%')))[:cells]
.sole[:count]).to eq(1)
expect(result(nil, base.merge('tags' => 'C'))[:cells].sole[:count]).to eq(1)
expect(result(nil, base.merge('tags' => 'not:C'))[:cells].sole[:count]).to eq(0)
end
it 'retains public OR branches while rejecting private predicates on hidden rows' do
HistoryAccess.set(user: nil) do
population = CountsPopulation.new(params.merge('rows' => '', 'cols' => ''),
authorisation_flg: false)
condition = CountsCondition.new(population)
public_scope = condition.apply(population.scope,
{ 'field' => 'event_type', 'operator' => 'eq', 'value' => 'discard' })
private_scope = condition.apply(population.scope,
{ 'field' => 'title', 'operator' => 'ne', 'value' => 'unrelated' })
masked_scope = condition.apply(population.scope,
{ 'field' => 'title', 'operator' => 'eq', 'value' => '********' })
expect(public_scope.except(:select).count).to eq(2)
expect(public_scope.or(private_scope).except(:select).count).to eq(3)
expect(private_scope.or(public_scope).except(:select).count).to eq(3)
expect(public_scope.or(masked_scope).except(:select).count).to eq(2)
expect(public_scope.merge(private_scope).except(:select).count).to eq(0)
end
end
it 'excludes private collections from positive, negative, AND and OR tag searches' do
settings = { 'rows' => '', 'cols' => '' }
{ 'C' => 1, 'not:C' => 0, 'not:unknown' => 1, '********' => 0,
'not:********' => 1, 'not:C C' => 0, 'not:C type:or C' => 1 }.each do |value, count|
expect(result(nil, settings.merge('tags' => value))[:cells].sole[:count]).to eq(count)
end
end
it 'ignores empty numeric and interval bounds without restricting private history' do
settings = { 'rows' => '', 'cols' => '', 'conditions' => '' }
{ 'video_ms' => ['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'range'],
'tags.sections' => ['range'] }.each do |field, operators|
operators.each do |operator|
condition = settings.merge('conditions.0.field' => field,
'conditions.0.operator' => operator)
empty = condition.merge('conditions.0.value' => '')
expect(result(nil, condition)[:cells].sole[:count]).to eq(5)
expect(result(nil, empty)[:cells].sole[:count]).to eq(5)
expect(result(nil, empty.merge('conditions.0.blank' => '0'))[:cells]
.sole[:count]).to eq(5)
if operator == 'range'
empty['conditions.0.end'] = ''
expect(result(nil, empty)[:cells].sole[:count]).to eq(5)
['conditions.0.value', 'conditions.0.end'].each do |key|
active = empty.merge(key => '100')
expect(result(nil, active)[:cells].sole[:count]).to eq(0)
end
end
end
end
end
it 'keeps typed marker sets active after incompatible marker values are removed' do
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
'conditions.0.field' => 'video_ms', 'conditions.0.values' => '1',
'condition_values' => '', 'condition_values.0.condition' => '0',
'condition_values.0.value_type' => 'string',
'condition_values.0.value' => '********' }
['in', 'not_in'].each do |operator|
expected = operator == 'in' ? 0 : 1
condition = settings.merge('conditions.0.operator' => operator)
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
end
end
it 'restricts active numeric comparisons and preserves empty selection semantics' do
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
'conditions.0.field' => 'video_ms' }
['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'range'].each do |operator|
condition = settings.merge('conditions.0.operator' => operator,
'conditions.0.value' => '0')
condition['conditions.0.end'] = '100' if operator == 'range'
# The one public row is NULL; private NULLs and real values never match.
expected = operator == 'ne' ? 1 : 0
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
end
['in', 'not_in'].each do |operator|
condition = settings.merge('conditions.0.operator' => operator,
'conditions.0.values' => '1', 'condition_values' => '')
expected = operator == 'not_in' ? 5 : 0
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
end
blank = settings.merge('conditions.0.operator' => 'eq', 'conditions.0.blank' => '1')
expect(result(nil, blank)[:cells].sole[:count]).to eq(1)
end
it 'preserves empty tag sets and restricts active blank collection predicates' do
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
'conditions.0.field' => 'tags' }
['any', 'all', 'none'].each do |operator|
condition = settings.merge('conditions.0.operator' => operator,
'conditions.0.values' => '1', 'condition_values' => '')
expected = operator == 'any' ? 0 : 5
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
end
blank = settings.merge('conditions.0.operator' => 'eq', 'conditions.0.blank' => '1')
expect(result(nil, blank)[:cells].sole[:count]).to eq(0)
end
it 'rejects server-only coordinates in column order' do
probes = [
{ 'cols' => 'tags', 'column_order' => '', 'column_order.0.value_type' => 'string', { 'cols' => 'tags', 'column_order' => '', 'column_order.0.value_type' => 'string',
'column_order.0.value' => CountsAxes::MASKED_VALUE }] '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)).call }
.to raise_error(CountsAxes::PrivateCondition) .to raise_error(CountsAxes::PrivateCondition)
end end
end end
end end
it 'keeps private collection and change counts independent of stored elements' do
settings = [
{ 'rows' => 'tags', 'cols' => 'changed_field' },
{ 'rows' => 'changed_field tags', 'cols' => '' }]
original = settings.map { result(nil, _1) }
snapshot = tags.map do |tag|
{ tag_id: tag.id, version_no: 1, sections: [] }
end
hidden_posts = PostVersion.where(event_type: 'discard').pluck(:post_id)
PostVersion.where(post_id: hidden_posts).update_all(
title: 'same hidden value', tags_json: snapshot)
expect(settings.map { result(nil, _1) }).to eq(original)
end
it 'excludes private tag and change coordinates from drill conditions' do
['tags', 'changed_field'].each do |field|
settings = { 'rows' => field, 'cols' => '' }
page = result(nil, settings)
cell = page[:cells].find { _1[:values] == [CountsAxes::MASKED_VALUE] }
expect(cell[:count]).to eq(4)
[CountsAxes::MASKED_VALUE, CountsAxes::MASKED_TEXT].each do |value|
drill = { 'drill' => '', 'drill.0.field' => field,
'drill.0.value_type' => 'string', 'drill.0.value' => value }
expect(result(nil, settings.merge(drill))[:cells]).to be_empty
details = result(nil, settings.merge(drill).merge('mode' => 'details'))
expect(details[:versions]).to be_empty
condition = { 'conditions' => '', 'conditions.0.field' => field,
'conditions.0.operator' => 'axis_eq',
'conditions.0.value_type' => 'string', 'conditions.0.value' => value }
expect(result(nil, settings.merge(condition))[:cells]).to be_empty
end
end
end
it 'rejects real private tag and change coordinates for non-admin users' do
[nil, guest, member].each do |user|
{ 'tags' => "internal:#{ tags.first.id }", 'changed_field' => 'title' }.each do
|field, value|
settings = { 'rows' => '', 'cols' => '', 'drill' => '',
'drill.0.field' => field, 'drill.0.value_type' => 'string',
'drill.0.value' => value }
expect(result(user, settings)[:cells].sole[:count]).to eq(0)
expect(result(admin, settings)[:cells].sole[:count]).to be > 0
end
end
end
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',
@@ -187,7 +357,7 @@ RSpec.describe 'Counts masked pivot contracts' do
HistoryAccess.set(user: nil) do HistoryAccess.set(user: nil) do
payload = params.merge('format' => 'json') payload = params.merge('format' => 'json')
json = JSON.parse(CountsTableExport.new(payload).body.to_a.join) json = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(5) expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(6)
expect(json.to_json).not_to include('hidden-title', 'private-discard') expect(json.to_json).not_to include('hidden-title', 'private-discard')
csv = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join csv = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
expect(csv).to include('********') expect(csv).to include('********')
+8 -18
ファイルの表示
@@ -8,23 +8,21 @@ RSpec.describe NicoTagVersionRecorder do
described_class.record!(external_tag:, event_type:, created_by_user: member) described_class.record!(external_tag:, event_type:, created_by_user: member)
end end
it 'records the external association and derives the platform-qualified name' do it 'records the external association and platform-qualified name' do
version = record(:create) version = record(:create)
expect(version).to have_attributes( expect(version).to have_attributes(
external_tag:, version_no: 1, event_type: 'create', external_tag:, version_no: 1, event_type: 'create',
name: 'nico:raw tag[]', linked_tag_ids: '', created_by_user: member) name: 'nico:raw tag[]', linked_tags: '', created_by_user: member)
expect(external_tag.reload.nico_tag_versions).to contain_exactly(version) expect(external_tag.reload.nico_tag_versions).to contain_exactly(version)
end end
it 'uses the latest history number when the record has no version_no column' do it 'uses the latest history number when the record has no version_no column' do
first = record(:create) first = record(:create)
tag = create(:tag) external_tag.update!(name: 'changed')
NicoTagRelation.create!(nico_tag: external_tag, tag:)
second = record(:update) second = record(:update)
expect(second).to have_attributes(version_no: 2, name: 'nico:raw tag[]', expect(second).to have_attributes(version_no: 2, name: 'nico:changed')
linked_tag_ids: tag.id.to_s)
expect(first.reload.name).to eq('nico:raw tag[]') expect(first.reload.name).to eq('nico:raw tag[]')
expect(external_tag.reload.has_attribute?(:version_no)).to be(false) expect(external_tag.reload.has_attribute?(:version_no)).to be(false)
end end
@@ -42,7 +40,7 @@ RSpec.describe NicoTagVersionRecorder do
expect(version).to have_attributes( expect(version).to have_attributes(
external_tag: external, name: 'registered_external:foo', version_no: 1, external_tag: external, name: 'registered_external:foo', version_no: 1,
event_type: 'create', linked_tag_ids: '', created_by_user: member) event_type: 'create', linked_tags: '', created_by_user: member)
end end
it 'returns the latest version without appending an unchanged snapshot' do it 'returns the latest version without appending an unchanged snapshot' do
@@ -66,21 +64,13 @@ RSpec.describe NicoTagVersionRecorder do
expect(external_tag.nico_tag_versions).to contain_exactly(first) expect(external_tag.nico_tag_versions).to contain_exactly(first)
end end
it 'records sorted linked internal names and later link removal' do
it 'rejects discard and restore events for external tags' do
[:discard, :restore].each do |event_type|
expect { record(event_type) }
.to raise_error(ArgumentError, "Invalid event_type: #{ event_type }")
end
end
it 'records sorted linked internal IDs and later link removal' do
tags = ['z_link', 'a_link'].map { |name| create(:tag, primary_name: name, category: :general) } tags = ['z_link', 'a_link'].map { |name| create(:tag, primary_name: name, category: :general) }
tags.each { |tag| NicoTagRelation.create!(nico_tag: external_tag, tag:) } tags.each { |tag| NicoTagRelation.create!(nico_tag: external_tag, tag:) }
expect(record(:create).linked_tag_ids).to eq(tags.map(&:id).sort.join(' ')) expect(record(:create).linked_tags).to eq('a_link z_link')
external_tag.linked_tags = [tags.first] external_tag.linked_tags = [tags.first]
expect(record(:update)).to have_attributes(version_no: 2, linked_tag_ids: tags.first.id.to_s) expect(record(:update)).to have_attributes(version_no: 2, linked_tags: 'z_link')
end end
end end
+4 -12
ファイルの表示
@@ -158,15 +158,7 @@ private ID の正誤に応じて許可可否を変えないため、条件の一
非公開列を含む列順変更は API の column_values_editable でも不可と通知する。 非公開列を含む列順変更は API の column_values_editable でも不可と通知する。
公開列の値を使うソートは引き続き利用できる。 公開列の値を使うソートは引き続き利用できる。
明細には元の認可を適用し、抽出条件自体や取得済みの全言語値を表示設定で改変しない。 明細には元の認可を適用し、抽出条件自体や取得済みの全言語値を表示設定で改変しない。
外部タグ履歴の linked_tag_ids は、連携する内部タグの ID を半角スペース区切りで 外部タグ履歴の linked_tags は名前しか記録していないため、非公開タグの記録名を含む
記録する。除票履歴のある内部タグの ID を含むスナップショットを非管理者へ公開しない。 スナップショットを保守的に非公開とする。名前全体を空白区切りの境界で照合する。
名前や UI ロカールを参照 ID の判定に使わない。 区切りが曖昧な旧データでは過剰な秘匿の可能性はあるが、参照 ID がない旧形式から
外部タグ名は履歴に保存せず、external_tags の platform と name を参照する。 名前の公開可否を楽観的に推定しない。
V1 Counts の公開フィールド名は linked_tags を維持し、DB の linked_tag_ids へ対応付ける。
明細・フィルタ・集計軸・変更項目・ソート・CSV / JSON 出力で同じ対応付けを使う。
既定列と既存の表示ラベルも linked_tags のままとし、値は ID のスペース区切りとする。
linked_tag_ids は物理カラム名であり、Counts の公開フィールドとしては受け付けない。
旧 URL や保存済み設定のフィールド指定は維持できるが、値にタグ名を指定した検索条件や
ドリル条件は ID 値への見直しが必要となる。旧履歴の名称は移行後の ID 列から復元できず、
現在の名称からの推定変換も行わない。