このコミットが含まれているのは:
2026-11-01 00:20:32 +09:00
コミット 74c5242828
22個のファイルの変更、202行の追加、232行の削除
+3
ファイルの表示
@@ -27,6 +27,9 @@ class ApplicationController < ActionController::API
private
def enforce_query_contract!
if request.query_parameters.keys.any? { _1 == 'tag_refs' || _1.start_with?('tag_refs.') }
return head :bad_request
end
pairs = URI.decode_www_form(request.query_string)
return head :bad_request if pairs.length > 1000 || pairs.map(&:first).uniq.length != pairs.length
return head :bad_request if request.query_parameters.values.any? { !_1.is_a?(String) }
+35 -49
ファイルの表示
@@ -409,52 +409,40 @@ class PostsController < ApplicationController
target = Locale.find_by(code:, deprecated_at: nil)
return head :not_found unless target
literals = params[:tags].to_s.split
references = tag_filter_references(literals)
names = literals.each_with_index.map do |literal, index|
reference = references&.[](index)
(reference && reference['negative'] == false) ? literal : literal.sub(/\Anot:/i, '')
end
internal = references ? {} : TagName.where(
language_code: source.language_code, name: names).pluck(:name, :tag_id).to_h
canonical = references ? {} : TagName.where(
language_code: source.language_code, primary_flg: true,
tag_id: internal.values).pluck(:tag_id, :name).to_h
external_names = names.map { canonical[internal[_1]] || _1 }
external = references ? {} : ExternalTag.where(
"CONCAT(platform, ':', name) IN (?)", external_names).pluck(
Arel.sql("CONCAT(platform, ':', name)"), :id).to_h
ids = references ? references.filter_map { _1['tag_id'] } : internal.values
translated = TagName.where(language_code: target.language_code,
primary_flg: true, tag_id: ids).pluck(:tag_id, :name).to_h
tokens = params[:tags].to_s.split
literals = PostTagSearch.literals(source, tokens.join(' '))
source_ids = PostTagSearch.references_for(source, literals)
ids = source_ids.values.flat_map(&:first).uniq
names = TagName.where(language_code: target.language_code,
primary_flg: true, tag_id: ids).pluck(:tag_id, :name).to_h
filters = literals.map do |literal|
internal, = source_ids.fetch(literal[:name])
name = internal.empty? ? literal[:name] : names[internal.first]
return head :unprocessable_entity unless name && internal.length <= 1
filters = literals.each_with_index.map do |literal, index|
name = names[index]
reference = if references
references[index].except('name')
else
matches = {}
matches['tag_id'] = internal[name] if internal[name]
external_id = external[external_names[index]]
matches['external_tag_id'] = external_id if external_id
matches.empty? ? { 'missing' => true } : matches
end
name = translated[reference['tag_id']] || name
negative = reference.fetch('negative', literal.downcase.start_with?('not:'))
prefix = negative ? literal[/\Anot:/i] || 'not:' : ''
reference.merge('name' => "#{ prefix }#{ name }", 'negative' => negative)
literal[:negative] ? "not:#{ name }" : name
end
render json: { references: filters }
translated = PostTagSearch.literals(target, filters.join(' '))
target_ids = PostTagSearch.references_for(target, translated)
literals.zip(translated).each do |literal, translation|
unless (source_ids.fetch(literal[:name]).map(&:sort) ==
target_ids.fetch(translation[:name]).map(&:sort))
return head :unprocessable_entity
end
end
index = 0
filters = tokens.map do |token|
next token if token.downcase == 'type:or'
value = filters[index]
index += 1
value
end
render json: { tags: filters.join(' ') }
end
private
def tag_filter_references literals
PostTagSearch.parse_references(params.to_unsafe_h, literals)
rescue ArgumentError, JSON::ParserError, TypeError
raise ActionController::BadRequest
end
def filtered_posts locale
tag_names = params[:tags].to_s.split
match_type = params[:match]
@@ -466,19 +454,17 @@ class PostsController < ApplicationController
end
def filter_posts_by_tags locale, tag_names, match_type
references = tag_filter_references(tag_names)
literals = PostTagSearch.literals(locale, tag_names.join(' '), references:)
literals = PostTagSearch.literals(locale, tag_names.join(' '))
return Post.all if literals.empty?
if match_type == 'any'
literals.reduce(Post.none) do |posts, literal|
posts.or(tag_literal_relation(locale, literal[:name], negative: literal[:negative],
reference: literal[:reference]))
posts.or(tag_literal_relation(locale, literal[:name], negative: literal[:negative]))
end
else
literals.reduce(Post.all) do |posts, literal|
ids = tagged_post_ids_for(locale, literal[:name], reference: literal[:reference])
ids = tagged_post_ids_for(locale, literal[:name])
if literal[:negative]
posts.where.not(id: ids)
else
@@ -488,8 +474,8 @@ class PostsController < ApplicationController
end
end
def tag_literal_relation locale, name, negative:, reference: nil
ids = tagged_post_ids_for(locale, name, reference:)
def tag_literal_relation locale, name, negative:
ids = tagged_post_ids_for(locale, name)
if negative
Post.where.not(id: ids)
else
@@ -497,8 +483,8 @@ class PostsController < ApplicationController
end
end
def tagged_post_ids_for locale, name, reference: nil
internal, external = PostTagSearch.references(locale, { name:, reference: })
def tagged_post_ids_for locale, name
internal, external = PostTagSearch.references(locale, { name: })
Post.where(id: PostTag.where(tag_id: Array(internal)).select(:post_id))
.or(Post.where(id: PostExternalTag.where(external_tag_id: Array(external))
.select(:post_id))).select(:id)
-19
ファイルの表示
@@ -161,25 +161,6 @@ class CountsCondition
scope.where(predicate, *binds)
end
def tag_name_match(value)
source = post_tag_source
bound = @population.history ? 'v.created_at' : @connection.quote(point)
source += ' LEFT JOIN nico_tag_versions external_snapshot ON external_snapshot.tag_id = ' +
scalar('tag_snapshot.item', 'external_tag_id', :integer) +
" AND external_snapshot.created_at <= #{ bound } AND " +
@population.access_sql(NicoTagVersion, 'external_snapshot') +
' AND NOT EXISTS (SELECT 1 FROM nico_tag_versions newer_external ' +
'WHERE newer_external.tag_id = external_snapshot.tag_id ' +
"AND newer_external.created_at <= #{ bound } " +
'AND newer_external.version_no > external_snapshot.version_no)'
document = "JSON_MERGE_PRESERVE(JSON_ARRAY(#{ scalar('tag_snapshot.item', 'name') }, " +
"referenced_tag.name, CONCAT('nico:', external_snapshot.name)), " +
"COALESCE(JSON_EXTRACT(referenced_tag.names, '$**.name'), JSON_ARRAY()))"
source += ' JOIN ' + json_table(document, '$[*]', 'search_name')
@definition.model.sanitize_sql_array([
"EXISTS (SELECT 1 FROM #{ source } WHERE JSON_UNQUOTE(search_name.item) = ?)", value])
end
private
def set_condition(scope, entry, field)
+9 -13
ファイルの表示
@@ -210,23 +210,19 @@ class CountsPopulation
raw = params['tags']
tokens = raw.split.reject { _1.downcase == 'type:or' }
refs = PostTagSearch.parse_references(params, tokens, partial: true)
locale = Locale.find_by(code: params.fetch('locale', 'ja'))
raise ArgumentError unless locale
literals = PostTagSearch.literals(locale, tokens.join(' '), references: refs)
literals = PostTagSearch.literals(locale, tokens.join(' '))
identities = PostTagSearch.references_for(locale, literals)
predicates = literals.map do |literal|
reference = literal[:reference]
expression = if reference
parts = { 'internal' => reference['tag_id'],
'external' => reference['external_tag_id'] }.filter_map do |kind, id|
snapshot_membership("#{ kind }:#{ id }") if id
end
parts.empty? ? 'FALSE' : "(#{ parts.join(' OR ') })"
else
@condition ||= CountsCondition.new(self)
@condition.tag_name_match(literal[:name])
end
# Resolve current names/aliases exactly as normal post search does. Only
# membership comes from history; snapshot names never identify a tag.
internal, external = identities.fetch(literal[:name])
parts = { 'internal' => internal, 'external' => external }.flat_map do |kind, ids|
Array(ids).map { |id| snapshot_membership("#{ kind }:#{ id }") }
end
expression = parts.empty? ? 'FALSE' : "(#{ parts.join(' OR ') })"
literal[:negative] ? "NOT #{ expression }" : expression
end
return if predicates.empty?
+2 -2
ファイルの表示
@@ -4,7 +4,7 @@ class CountsTableQuery
class TooManyColumns < ArgumentError; end
KEYS = ['resource', 'definition', 'mode', 'at', 'execution_at', 'from', 'through',
'timezone', 'rows', 'cols', 'conditions', 'drill', 'tags', 'tag_refs', 'match',
'timezone', 'rows', 'cols', 'conditions', 'drill', 'tags', 'match',
'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale',
'measures', 'column_order', 'condition_values', 'name_scope'].freeze
attr_reader :population, :axes, :page, :limit, :mode
@@ -224,7 +224,7 @@ class CountsTableQuery
fields = QueryState.records(@params, 'conditions') + QueryState.records(@params, 'drill')
private_fields = fields.any? { !CountsAxes::PUBLIC_FIELDS.include?(_1['field']) }
keys = ['tags', 'tag_refs', 'tag', 'kind', 'resource_id', 'user_id']
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
+21 -43
ファイルの表示
@@ -1,52 +1,30 @@
# Shared literal parsing and reference resolution for current and snapshot post search.
class PostTagSearch
def self.parse_references(raw, literals, partial: false)
return nil unless raw.key?('tag_refs')
references = QueryState.records(raw, 'tag_refs')
valid = references.is_a?(Array) && references.all? do |reference|
next false unless reference.is_a?(Hash) && reference['name'].is_a?(String)
ids = [reference['tag_id'], reference['external_tag_id']].compact
((ids.any? || reference['missing'] == true) &&
ids.all? { _1.is_a?(Integer) && _1.positive? } &&
(!reference.key?('missing') || (reference['missing'] == true && ids.empty?)) &&
(!reference.key?('negative') || [true, false].include?(reference['negative'])))
end
raise ArgumentError unless valid
if partial
remaining = references.dup
result = literals.map do |literal|
index = remaining.index { _1['name'] == literal }
index ? remaining.delete_at(index) : nil
end
raise ArgumentError if remaining.any?
return result
end
references.map { _1['name'] } == literals ? references : nil
end
def self.literals(locale, raw, references: nil)
raw.split.each_with_index.filter_map do |token, index|
next if token.downcase == 'type:or'
reference = references&.[](index)
name = token.sub(/\Anot:/i, '')
name = TagName.canonicalise(locale, name).first unless reference || locale.nil?
negative = reference&.key?('negative') ? reference['negative'] :
token.downcase.start_with?('not:')
{ name:, negative:, reference: }
def self.literals(locale, raw)
tokens = raw.split.reject { _1.downcase == 'type:or' }
names = tokens.map { _1.sub(/\Anot:/i, '') }
registered = TagName.where(language_code: locale.language_code, name: names)
.pluck(:name, :tag_id).to_h
primary = TagName.where(language_code: locale.language_code,
tag_id: registered.values, primary_flg: true).pluck(:tag_id, :name).to_h
tokens.zip(names).map do |token, name|
{ name: primary[registered[name]] || name,
negative: token.downcase.start_with?('not:') }
end
end
def self.references(locale, literal)
reference = literal[:reference]
return [reference['tag_id'], reference['external_tag_id']] if reference
references_for(locale, [literal]).fetch(literal[:name])
end
[TagName.where(language_code: locale.language_code, name: literal[:name])
.distinct.pluck(:tag_id),
ExternalTag.where("CONCAT(platform, ':', name) = ?", literal[:name]).pluck(:id)]
def self.references_for(locale, literals)
names = literals.map { _1[:name] }.uniq
internal = TagName.where(language_code: locale.language_code, name: names)
.pluck(:name, :tag_id).group_by(&:first)
external = names.empty? ? {} : ExternalTag.where(
"CONCAT(platform, ':', name) IN (?)", names)
.pluck(Arel.sql("CONCAT(platform, ':', name)"), :id).group_by(&:first)
names.to_h { |name| [name, [internal.fetch(name, []).map(&:last).uniq,
external.fetch(name, []).map(&:last).uniq]] }
end
end
+2 -3
ファイルの表示
@@ -4,7 +4,6 @@ class QueryState
'conditions' => ['field', 'operator', 'value', 'end', 'blank', 'input', 'values', 'grain', 'value_type'],
'condition_values' => ['condition', 'value', 'value_type'],
'drill' => ['field', 'grain', 'value', 'value_type'],
'tag_refs' => ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
'column_order' => ['value', 'value_type'],
'measures' => ['key', 'axis', 'value', 'value_type'] }.freeze
@@ -41,11 +40,11 @@ class QueryState
_root, index, field = name.split('.')
record = records[index.to_i] ||= {}
if ['blank', 'negative', 'missing', 'values'].include?(field)
if ['blank', 'values'].include?(field)
raise ArgumentError unless ['0', '1'].include?(value)
value = value == '1'
elsif ['tag_id', 'external_tag_id', 'axis', 'condition'].include?(field)
elsif ['axis', 'condition'].include?(field)
raise ArgumentError unless value.match?(/\A[0-9]{1,18}\z/)
value = value.to_i
+55 -10
ファイルの表示
@@ -120,17 +120,61 @@ RSpec.describe 'Counts table API', type: :request do
expect(versions(query(empty.except('conditions.0.values')))).to eq([1, 2, 3])
end
it 'canonicalises unreferenced tags while preserving partial stable references' do
reference = { tag_refs: '', 'tag_refs.0.name' => 'alpha',
'tag_refs.0.tag_id' => tag.id.to_s }
it 'resolves current tag names for AND, OR and negative searches' do
expect(versions(query(tags: 'alpha'))).to eq([1, 2])
expect(versions(query(reference.merge(tags: 'alpha nico:beta')))).to eq([1])
expect(versions(query(reference.merge(tags: 'nico:beta alpha')))).to eq([1])
expect(versions(query(reference.merge(tags: 'alpha not:nico:beta')))).to eq([2])
expect(versions(query(reference.merge(tags: 'alpha nico:beta', match: 'any')))).to eq([1, 2])
expect(versions(query(tags: 'alpha nico:beta'))).to eq([1])
expect(versions(query(tags: 'nico:beta alpha'))).to eq([1])
expect(versions(query(tags: 'alpha not:nico:beta'))).to eq([2])
expect(versions(query(tags: 'alpha nico:beta', match: 'any'))).to eq([1, 2])
expect(versions(query(tags: 'not:alpha'))).to eq([3])
get '/counts/query', params: base.merge(reference).merge(tags: 'different')
expect(response).to have_http_status(:bad_request)
end
context 'when current tag names differ from recorded snapshot names' do
before do
tag.tag_names.find_by!(language_code: 'ja', primary_flg: true)
.update!(name: 'renamed-alpha')
create(:tag_name, :alias, tag:, name: 'alpha-alias')
TagVersion.create!(tag:, version_no: 2, event_type: 'update', name: 'renamed-alpha',
category: 'general', aliases: 'alpha-alias', parent_tag_ids: '',
names: { ja: { name: 'renamed-alpha', script_code: 'Jpan', auto_generated: false,
aliases: [{ name: 'alpha-alias', script_code: 'Jpan' }] } },
created_at: Time.utc(2024, 2, 1))
PostVersion.create!(post: record, version_no: 4, event_type: 'update',
url: record.url, title: 'after rename', tags: '', parent_post_ids: '',
tags_json: [{ tag_id: tag.id, version_no: 2, name: 'renamed-alpha',
category: 'general', sections: [] }],
created_by_user: actor, created_at: Time.utc(2024, 2, 2))
end
it 'resolves current names and aliases to stable IDs in both definitions' do
{ 'history' => [1, 2, 4], 'latest' => [4] }.each do |definition, expected|
['renamed-alpha', 'alpha-alias'].each do |name|
expect(versions(query(tags: name, definition:))).to eq(expected)
end
end
result = query(tags: 'renamed-alpha')
expect(result['versions'].first['tags'].first['name']).to eq('alpha')
expect(versions(query(tags: 'renamed-alpha', definition: 'latest',
at: '2024-01-31T16:00:00Z', current: '0'))).to eq([2])
expect(versions(query(tags: 'not:alpha-alias'))).to eq([3])
expect(versions(query(tags: 'renamed-alpha nico:beta'))).to eq([1])
expect(versions(query(tags: 'renamed-alpha nico:beta', match: 'any'))).to eq([1, 2, 4])
end
it 'does not match a historical name reassigned to another tag ID' do
create(:tag, primary_name: 'alpha')
expect(versions(query(tags: 'alpha'))).to eq([])
expect(versions(query(tags: 'unregistered-name'))).to eq([])
expect(versions(query(tags: 'not:unregistered-name'))).to eq([1, 2, 3, 4])
expect(versions(query(tags: 'renamed-alpha'))).to eq([1, 2, 4])
end
it 'resolves external names from current registrations too' do
external.update!(name: 'renamed-beta')
expect(versions(query(tags: 'nico:renamed-beta'))).to eq([1])
expect(versions(query(tags: 'nico:beta'))).to eq([])
expect(versions(query(tags: 'not:nico:renamed-beta'))).to eq([2, 3, 4])
end
end
it 'rejects malformed structured conditions and axes without accepting arbitrary SQL' do
@@ -149,7 +193,8 @@ RSpec.describe 'Counts table API', type: :request do
end
it 'rejects duplicate URL keys and JSON query payloads at the HTTP boundary' do
['resource=posts&limit=20&limit=50', 'resource=posts&headers=%5B%22Id.%22%5D',
['resource=posts&tag_refs=', 'resource=posts&tag_refs.0.tag_id=1',
'resource=posts&limit=20&limit=50', 'resource=posts&headers=%5B%22Id.%22%5D',
'resource=posts&limit%5B%5D=20'].each do |query_string|
get "/counts/query?#{ query_string }"
expect(response).to have_http_status(:bad_request)
+26 -20
ファイルの表示
@@ -11,43 +11,49 @@ RSpec.describe 'Post filter identity across locales', type: :request do
let!(:other_post) { create(:post) }
before do
# Legacy data can predate the current prohibition on internal nico: names.
primary_tag_name_for(tag, 'ja').update_columns(name: 'nico:shared')
primary_tag_name_for(tag, 'ain').update!(name: 'シロシ', auto_generated: false)
create(:tag_name, :alias, tag:, name: 'union_alias', language_code: 'ja')
PostTag.create!(post: internal_post, tag:)
PostExternalTag.create!(post: external_post, external_tag: external)
end
['nico:shared', 'union_alias', 'not:nico:shared'].each do |literal|
literals = ['legacy_union_source', 'union_alias', 'not:legacy_union_source', 'nico:shared']
literals.each do |literal|
it "preserves the complete match set for #{ literal }" do
get '/posts', params: { locale: 'ja', tags: literal }
expect(response).to have_http_status(:ok)
original_ids = json.fetch('posts').map { _1.fetch('id') }
expected = literal.start_with?('not:') ? [other_post.id] :
[internal_post.id, external_post.id]
expected = if literal.start_with?('not:')
[external_post.id, other_post.id]
elsif literal == 'nico:shared'
[external_post.id]
else
[internal_post.id]
end
expect(original_ids).to match_array(expected)
get '/posts/tag_filters', params: {
locale: 'ja', target_locale: 'ain-Kana', tags: literal }
expect(response).to have_http_status(:ok)
references = json.fetch('references')
expect(references.first).to include('tag_id' => tag.id, 'external_tag_id' => external.id)
reference_params = { tag_refs: '' }
references.each_with_index do |reference, index|
['name', 'tag_id', 'external_tag_id', 'negative', 'missing'].each do |field|
value = reference[field]
next if value.nil? || (field == 'missing' && value == false)
reference_params["tag_refs.#{ index }.#{ field }"] =
[true, false].include?(value) ? (value ? '1' : '0') : value.to_s
end
end
get '/posts', params: { locale: 'ain-Kana',
tags: references.map { _1.fetch('name') }.join(' ') }.merge(reference_params)
translated = json.fetch('tags')
expect(translated).to eq(literal == 'nico:shared' ? literal :
(literal.start_with?('not:') ? 'not:シロシ' : 'シロシ'))
get '/posts', params: { locale: 'ain-Kana', tags: translated }
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').map { _1.fetch('id') }).to match_array(original_ids)
end
end
it 'rejects a locale conversion that cannot preserve a legacy union' do
primary_tag_name_for(tag, 'ja').update_columns(name: 'nico:shared')
get '/posts/tag_filters', params: {
locale: 'ja', target_locale: 'ain-Kana', tags: 'nico:shared' }
expect(response).to have_http_status(:unprocessable_entity)
end
it 'rejects removed reference parameters instead of overriding the name' do
get '/posts', params: { locale: 'ja', tags: 'legacy_union_source',
tag_refs: '', 'tag_refs.0.tag_id' => external.id.to_s }
expect(response).to have_http_status(:bad_request)
end
end
-1
ファイルの表示
@@ -133,7 +133,6 @@ RSpec.describe 'Counts masked pivot contracts' do
it 'rejects private predicates and supplied measure coordinates before SQL labels escape' do
probes = [{ 'resource_id' => PostVersion.first.post_id.to_s },
{ 'tags' => 'A' },
{ 'tag_refs' => '', 'tag_refs.0.name' => 'A', 'tag_refs.0.tag_id' => tags.first.id.to_s },
{ 'conditions' => '', 'conditions.0.field' => 'title', 'conditions.0.operator' => 'eq',
'conditions.0.value' => 'hidden-title-0' },
{ 'drill' => '', 'drill.0.field' => 'tags', 'drill.0.value_type' => 'string',