このコミットが含まれているのは:
2026-11-01 00:20:32 +09:00
コミット 74c5242828
22個のファイルの変更、202行の追加、232行の削除
+3
ファイルの表示
@@ -27,6 +27,9 @@ class ApplicationController < ActionController::API
private private
def enforce_query_contract! 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) 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 pairs.length > 1000 || pairs.map(&:first).uniq.length != pairs.length
return head :bad_request if request.query_parameters.values.any? { !_1.is_a?(String) } 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) target = Locale.find_by(code:, deprecated_at: nil)
return head :not_found unless target return head :not_found unless target
literals = params[:tags].to_s.split tokens = params[:tags].to_s.split
references = tag_filter_references(literals) literals = PostTagSearch.literals(source, tokens.join(' '))
names = literals.each_with_index.map do |literal, index| source_ids = PostTagSearch.references_for(source, literals)
reference = references&.[](index) ids = source_ids.values.flat_map(&:first).uniq
(reference && reference['negative'] == false) ? literal : literal.sub(/\Anot:/i, '') names = TagName.where(language_code: target.language_code,
end primary_flg: true, tag_id: ids).pluck(:tag_id, :name).to_h
internal = references ? {} : TagName.where( filters = literals.map do |literal|
language_code: source.language_code, name: names).pluck(:name, :tag_id).to_h internal, = source_ids.fetch(literal[:name])
canonical = references ? {} : TagName.where( name = internal.empty? ? literal[:name] : names[internal.first]
language_code: source.language_code, primary_flg: true, return head :unprocessable_entity unless name && internal.length <= 1
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
filters = literals.each_with_index.map do |literal, index| literal[:negative] ? "not:#{ name }" : name
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)
end 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 end
private 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 def filtered_posts locale
tag_names = params[:tags].to_s.split tag_names = params[:tags].to_s.split
match_type = params[:match] match_type = params[:match]
@@ -466,19 +454,17 @@ class PostsController < ApplicationController
end end
def filter_posts_by_tags locale, tag_names, match_type def filter_posts_by_tags locale, tag_names, match_type
references = tag_filter_references(tag_names) literals = PostTagSearch.literals(locale, tag_names.join(' '))
literals = PostTagSearch.literals(locale, tag_names.join(' '), references:)
return Post.all if literals.empty? return Post.all if literals.empty?
if match_type == 'any' if match_type == 'any'
literals.reduce(Post.none) do |posts, literal| literals.reduce(Post.none) do |posts, literal|
posts.or(tag_literal_relation(locale, literal[:name], negative: literal[:negative], posts.or(tag_literal_relation(locale, literal[:name], negative: literal[:negative]))
reference: literal[:reference]))
end end
else else
literals.reduce(Post.all) do |posts, literal| 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] if literal[:negative]
posts.where.not(id: ids) posts.where.not(id: ids)
else else
@@ -488,8 +474,8 @@ class PostsController < ApplicationController
end end
end end
def tag_literal_relation locale, name, negative:, reference: nil def tag_literal_relation locale, name, negative:
ids = tagged_post_ids_for(locale, name, reference:) ids = tagged_post_ids_for(locale, name)
if negative if negative
Post.where.not(id: ids) Post.where.not(id: ids)
else else
@@ -497,8 +483,8 @@ class PostsController < ApplicationController
end end
end end
def tagged_post_ids_for locale, name, reference: nil def tagged_post_ids_for locale, name
internal, external = PostTagSearch.references(locale, { name:, reference: }) internal, external = PostTagSearch.references(locale, { name: })
Post.where(id: PostTag.where(tag_id: Array(internal)).select(:post_id)) Post.where(id: PostTag.where(tag_id: Array(internal)).select(:post_id))
.or(Post.where(id: PostExternalTag.where(external_tag_id: Array(external)) .or(Post.where(id: PostExternalTag.where(external_tag_id: Array(external))
.select(:post_id))).select(:id) .select(:post_id))).select(:id)
-19
ファイルの表示
@@ -161,25 +161,6 @@ class CountsCondition
scope.where(predicate, *binds) scope.where(predicate, *binds)
end 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 private
def set_condition(scope, entry, field) def set_condition(scope, entry, field)
+9 -13
ファイルの表示
@@ -210,23 +210,19 @@ class CountsPopulation
raw = params['tags'] raw = params['tags']
tokens = raw.split.reject { _1.downcase == 'type:or' } 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')) locale = Locale.find_by(code: params.fetch('locale', 'ja'))
raise ArgumentError unless locale 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| predicates = literals.map do |literal|
reference = literal[:reference] # Resolve current names/aliases exactly as normal post search does. Only
expression = if reference # membership comes from history; snapshot names never identify a tag.
parts = { 'internal' => reference['tag_id'], internal, external = identities.fetch(literal[:name])
'external' => reference['external_tag_id'] }.filter_map do |kind, id| parts = { 'internal' => internal, 'external' => external }.flat_map do |kind, ids|
snapshot_membership("#{ kind }:#{ id }") if id Array(ids).map { |id| snapshot_membership("#{ kind }:#{ id }") }
end end
parts.empty? ? 'FALSE' : "(#{ parts.join(' OR ') })" expression = parts.empty? ? 'FALSE' : "(#{ parts.join(' OR ') })"
else
@condition ||= CountsCondition.new(self)
@condition.tag_name_match(literal[:name])
end
literal[:negative] ? "NOT #{ expression }" : expression literal[:negative] ? "NOT #{ expression }" : expression
end end
return if predicates.empty? return if predicates.empty?
+2 -2
ファイルの表示
@@ -4,7 +4,7 @@ class CountsTableQuery
class TooManyColumns < ArgumentError; end class TooManyColumns < ArgumentError; end
KEYS = ['resource', 'definition', 'mode', 'at', 'execution_at', 'from', 'through', 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', 'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale',
'measures', 'column_order', 'condition_values', 'name_scope'].freeze 'measures', 'column_order', 'condition_values', 'name_scope'].freeze
attr_reader :population, :axes, :page, :limit, :mode attr_reader :population, :axes, :page, :limit, :mode
@@ -224,7 +224,7 @@ class CountsTableQuery
fields = QueryState.records(@params, 'conditions') + QueryState.records(@params, 'drill') fields = QueryState.records(@params, 'conditions') + QueryState.records(@params, 'drill')
private_fields = fields.any? { !CountsAxes::PUBLIC_FIELDS.include?(_1['field']) } 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| private_parameters = keys.any? do |key|
@params.any? { |name, value| (name == key || name.start_with?("#{ key }.")) && value != '' } @params.any? { |name, value| (name == key || name.start_with?("#{ key }.")) && value != '' }
end end
+21 -43
ファイルの表示
@@ -1,52 +1,30 @@
# Shared literal parsing and reference resolution for current and snapshot post search. # Shared literal parsing and reference resolution for current and snapshot post search.
class PostTagSearch class PostTagSearch
def self.parse_references(raw, literals, partial: false) def self.literals(locale, raw)
return nil unless raw.key?('tag_refs') tokens = raw.split.reject { _1.downcase == 'type:or' }
names = tokens.map { _1.sub(/\Anot:/i, '') }
references = QueryState.records(raw, 'tag_refs') registered = TagName.where(language_code: locale.language_code, name: names)
valid = references.is_a?(Array) && references.all? do |reference| .pluck(:name, :tag_id).to_h
next false unless reference.is_a?(Hash) && reference['name'].is_a?(String) primary = TagName.where(language_code: locale.language_code,
tag_id: registered.values, primary_flg: true).pluck(:tag_id, :name).to_h
ids = [reference['tag_id'], reference['external_tag_id']].compact tokens.zip(names).map do |token, name|
((ids.any? || reference['missing'] == true) && { name: primary[registered[name]] || name,
ids.all? { _1.is_a?(Integer) && _1.positive? } && negative: token.downcase.start_with?('not:') }
(!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: }
end end
end end
def self.references(locale, literal) def self.references(locale, literal)
reference = literal[:reference] references_for(locale, [literal]).fetch(literal[:name])
return [reference['tag_id'], reference['external_tag_id']] if reference end
[TagName.where(language_code: locale.language_code, name: literal[:name]) def self.references_for(locale, literals)
.distinct.pluck(:tag_id), names = literals.map { _1[:name] }.uniq
ExternalTag.where("CONCAT(platform, ':', name) = ?", literal[:name]).pluck(:id)] 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
end end
+2 -3
ファイルの表示
@@ -4,7 +4,6 @@ class QueryState
'conditions' => ['field', 'operator', 'value', 'end', 'blank', 'input', 'values', 'grain', 'value_type'], 'conditions' => ['field', 'operator', 'value', 'end', 'blank', 'input', 'values', 'grain', 'value_type'],
'condition_values' => ['condition', 'value', 'value_type'], 'condition_values' => ['condition', 'value', 'value_type'],
'drill' => ['field', 'grain', 'value', 'value_type'], 'drill' => ['field', 'grain', 'value', 'value_type'],
'tag_refs' => ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
'column_order' => ['value', 'value_type'], 'column_order' => ['value', 'value_type'],
'measures' => ['key', 'axis', 'value', 'value_type'] }.freeze 'measures' => ['key', 'axis', 'value', 'value_type'] }.freeze
@@ -41,11 +40,11 @@ class QueryState
_root, index, field = name.split('.') _root, index, field = name.split('.')
record = records[index.to_i] ||= {} record = records[index.to_i] ||= {}
if ['blank', 'negative', 'missing', 'values'].include?(field) if ['blank', 'values'].include?(field)
raise ArgumentError unless ['0', '1'].include?(value) raise ArgumentError unless ['0', '1'].include?(value)
value = value == '1' 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/) raise ArgumentError unless value.match?(/\A[0-9]{1,18}\z/)
value = value.to_i 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]) expect(versions(query(empty.except('conditions.0.values')))).to eq([1, 2, 3])
end end
it 'canonicalises unreferenced tags while preserving partial stable references' do it 'resolves current tag names for AND, OR and negative searches' do
reference = { tag_refs: '', 'tag_refs.0.name' => 'alpha',
'tag_refs.0.tag_id' => tag.id.to_s }
expect(versions(query(tags: 'alpha'))).to eq([1, 2]) expect(versions(query(tags: 'alpha'))).to eq([1, 2])
expect(versions(query(reference.merge(tags: 'alpha nico:beta')))).to eq([1]) expect(versions(query(tags: 'alpha nico:beta'))).to eq([1])
expect(versions(query(reference.merge(tags: 'nico:beta alpha')))).to eq([1]) expect(versions(query(tags: 'nico:beta alpha'))).to eq([1])
expect(versions(query(reference.merge(tags: 'alpha not:nico:beta')))).to eq([2]) expect(versions(query(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', match: 'any'))).to eq([1, 2])
expect(versions(query(tags: 'not:alpha'))).to eq([3]) expect(versions(query(tags: 'not:alpha'))).to eq([3])
get '/counts/query', params: base.merge(reference).merge(tags: 'different') end
expect(response).to have_http_status(:bad_request)
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 end
it 'rejects malformed structured conditions and axes without accepting arbitrary SQL' do 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 end
it 'rejects duplicate URL keys and JSON query payloads at the HTTP boundary' do 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| 'resource=posts&limit%5B%5D=20'].each do |query_string|
get "/counts/query?#{ query_string }" get "/counts/query?#{ query_string }"
expect(response).to have_http_status(:bad_request) 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) } let!(:other_post) { create(:post) }
before do 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) primary_tag_name_for(tag, 'ain').update!(name: 'シロシ', auto_generated: false)
create(:tag_name, :alias, tag:, name: 'union_alias', language_code: 'ja') create(:tag_name, :alias, tag:, name: 'union_alias', language_code: 'ja')
PostTag.create!(post: internal_post, tag:) PostTag.create!(post: internal_post, tag:)
PostExternalTag.create!(post: external_post, external_tag: external) PostExternalTag.create!(post: external_post, external_tag: external)
end 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 it "preserves the complete match set for #{ literal }" do
get '/posts', params: { locale: 'ja', tags: literal } get '/posts', params: { locale: 'ja', tags: literal }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
original_ids = json.fetch('posts').map { _1.fetch('id') } original_ids = json.fetch('posts').map { _1.fetch('id') }
expected = literal.start_with?('not:') ? [other_post.id] : expected = if literal.start_with?('not:')
[internal_post.id, external_post.id] [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) expect(original_ids).to match_array(expected)
get '/posts/tag_filters', params: { get '/posts/tag_filters', params: {
locale: 'ja', target_locale: 'ain-Kana', tags: literal } locale: 'ja', target_locale: 'ain-Kana', tags: literal }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
references = json.fetch('references') translated = json.fetch('tags')
expect(references.first).to include('tag_id' => tag.id, 'external_tag_id' => external.id) expect(translated).to eq(literal == 'nico:shared' ? literal :
(literal.start_with?('not:') ? 'not:シロシ' : 'シロシ'))
reference_params = { tag_refs: '' } get '/posts', params: { locale: 'ain-Kana', tags: translated }
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)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.fetch('posts').map { _1.fetch('id') }).to match_array(original_ids) expect(json.fetch('posts').map { _1.fetch('id') }).to match_array(original_ids)
end end
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 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 it 'rejects private predicates and supplied measure coordinates before SQL labels escape' do
probes = [{ 'resource_id' => PostVersion.first.post_id.to_s }, probes = [{ 'resource_id' => PostVersion.first.post_id.to_s },
{ 'tags' => 'A' }, { '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' => '', 'conditions.0.field' => 'title', 'conditions.0.operator' => 'eq',
'conditions.0.value' => 'hidden-title-0' }, 'conditions.0.value' => 'hidden-title-0' },
{ 'drill' => '', 'drill.0.field' => 'tags', 'drill.0.value_type' => 'string', { 'drill' => '', 'drill.0.field' => 'tags', 'drill.0.value_type' => 'string',
+4 -2
ファイルの表示
@@ -200,9 +200,11 @@ export const pageRoutes = (
handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja' handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja'
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } }, ? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
{ path: 'counts', element: <CountsResourcePage resource="posts"/>, { path: 'counts', element: <CountsResourcePage resource="posts"/>,
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } }, handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex',
switchLocale: switchPostFilterLocale } },
{ path: 'counts/posts', element: <CountsResourcePage resource="posts"/>, { path: 'counts/posts', element: <CountsResourcePage resource="posts"/>,
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } }, handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex',
switchLocale: switchPostFilterLocale } },
{ path: 'counts/tags', element: <CountsResourcePage resource="tags"/>, { path: 'counts/tags', element: <CountsResourcePage resource="tags"/>,
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } }, handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
{ path: 'counts/nico-tags', element: <CountsResourcePage resource="nico_tags"/>, { path: 'counts/nico-tags', element: <CountsResourcePage resource="nico_tags"/>,
+7 -1
ファイルの表示
@@ -43,6 +43,12 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const tagsQuery = query.get ('tags') ?? '' const tagsQuery = query.get ('tags') ?? ''
const anyFlg = query.get ('match') === 'any' const anyFlg = query.get ('match') === 'any'
const countsQuery = new URLSearchParams ({ tags: tagsQuery })
for (const [key, value] of query)
{
if (key === 'match')
countsQuery.append (key, value)
}
useEffect (() => { useEffect (() => {
const tagsTmp: TagByCategory = { } const tagsTmp: TagByCategory = { }
@@ -120,7 +126,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
</a> </a>
</li> </li>
<li> <li>
<PrefetchLink to={`/counts/posts?tags=${ encodeURIComponent (tagsQuery) }`}> <PrefetchLink to={`/counts/posts?${ countsQuery }`}>
{label ('actions.count')} {label ('actions.count')}
</PrefetchLink> </PrefetchLink>
</li> </li>
+4 -9
ファイルの表示
@@ -68,22 +68,17 @@ describe ('Counts filter draft', () => {
expect (screen.queryByText ('pattern.input')).not.toBeInTheDocument () expect (screen.queryByText ('pattern.input')).not.toBeInTheDocument ()
}) })
it ('retains stable references only for surviving search tokens', () => { it ('submits only the edited tag names', () => {
const params = appliedParams ({ tags: 'alpha beta' }) const { execute } = renderControls (appliedParams ({ tags: 'alpha beta' }))
setCountState (params, 'tag_refs', encodeCountState ('tag_refs', [
{ name: 'alpha', tag_id: 7 }, { name: 'beta', external_tag_id: 8 }]))
const { execute } = renderControls (params)
const tags = screen.getByDisplayValue ('alpha beta') const tags = screen.getByDisplayValue ('alpha beta')
fireEvent.change (tags, { target: { value: 'not:beta gamma alpha' } }) fireEvent.change (tags, { target: { value: 'not:beta gamma alpha' } })
submit () submit ()
expect (readCountState (execute.mock.lastCall![0], 'tag_refs', [])).toEqual ([ expect (execute.mock.lastCall![0].get ('tags')).toBe ('not:beta gamma alpha')
{ name: 'alpha', tag_id: 7 }, { name: 'not:beta', external_tag_id: 8, negative: true }])
fireEvent.change (tags, { target: { value: 'gamma' } }) fireEvent.change (tags, { target: { value: 'gamma' } })
submit () submit ()
expect (readCountState (execute.mock.lastCall![0], 'tag_refs', [])).toEqual ([])
expect (execute.mock.lastCall![0].get ('tags')).toBe ('gamma') expect (execute.mock.lastCall![0].get ('tags')).toBe ('gamma')
expect (screen.queryByText (/internal:7|external:8/)).not.toBeInTheDocument ()
}) })
}) })
+2 -24
ファイルの表示
@@ -17,7 +17,7 @@ import { useClientMotion } from '@/lib/useClientMotion'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
import { readCountState, setCountState, encodeCountState } from '@/lib/queryState' import { readCountState, setCountState } from '@/lib/queryState'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
@@ -97,27 +97,6 @@ const CountsControls = (
const update = (key: string, value: string) => setDraft (previous => { const update = (key: string, value: string) => setDraft (previous => {
const next = new URLSearchParams (previous) const next = new URLSearchParams (previous)
setCountState (next, key, value) setCountState (next, key, value)
if (key === 'tags')
{
const tokens = value.split (/\s+/).filter (token => token
&& token.toLowerCase () !== 'type:or')
const remaining = [...tokens]
const references = readCountState<Record<string, string | number | boolean>[]> (
previous, 'tag_refs', []).flatMap (reference => {
const name = String (reference.name)
const exact = remaining.indexOf (name)
const index = exact >= 0 ? exact : remaining.findIndex (token =>
token.replace (/^not:/i, '') === name.replace (/^not:/i, ''))
if (index < 0) return []
const [token] = remaining.splice (index, 1)
return [token === name ? reference : { ...reference, name: token,
negative: token.toLowerCase ().startsWith ('not:') }]
})
for (const name of [...next.keys ()])
if (name === 'tag_refs' || name.startsWith ('tag_refs.')) next.delete (name)
if (references.length > 0)
setCountState (next, 'tag_refs', encodeCountState ('tag_refs', references))
}
if (key === 'limit') next.delete ('page') if (key === 'limit') next.delete ('page')
if (next.toString () === previous.toString ()) return previous if (next.toString () === previous.toString ()) return previous
if (['rows', 'cols'].includes (key) && next.get ('sort') !== '') next.delete ('sort') if (['rows', 'cols'].includes (key) && next.get ('sort') !== '') next.delete ('sort')
@@ -278,8 +257,7 @@ const CountsControls = (
aria-label={message ('remove').text} onClick={() => setDraft (previous => { aria-label={message ('remove').text} onClick={() => setDraft (previous => {
const next = new URLSearchParams (previous) const next = new URLSearchParams (previous)
for (const key of [...next.keys ()]) for (const key of [...next.keys ()])
if (['tags', 'tag', 'kind', 'match', 'tag_refs', 'page'].includes (key) if (['tags', 'tag', 'kind', 'match', 'page'].includes (key)) next.delete (key)
|| key.startsWith ('tag_refs.')) next.delete (key)
return next return next
})}>×</Button> })}>×</Button>
</>} </>}
+17 -1
ファイルの表示
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchPostChanges, fetchPosts, toggleViewedFlg, updatePost } from '@/lib/posts' import { fetchPostChanges, fetchPosts, switchPostFilterLocale, toggleViewedFlg, updatePost }
from '@/lib/posts'
import type { FetchPostsParams } from '@/types' import type { FetchPostsParams } from '@/types'
@@ -34,6 +35,21 @@ describe ('posts API functions', () => {
vi.clearAllMocks () vi.clearAllMocks ()
}) })
it ('switches current tag names without adding identity state to the URL', async () => {
api.apiGet.mockResolvedValueOnce ({ tags: 'シロシ not:nico:shared' })
const path = await switchPostFilterLocale ({
location: { pathname: '/ja/counts/posts',
search: '?tags=source+not%3Anico%3Ashared&match=any', hash: '#table' },
params: {}, locale: 'ja', targetLocale: 'ain-Kana' })
expect (api.apiGet).toHaveBeenCalledWith ('/posts/tag_filters', {
locale: 'ja', params: { tags: 'source not:nico:shared', target_locale: 'ain-Kana' } })
const url = new URL (path, 'https://example.test')
expect (url.pathname).toBe ('/ain-Kana/counts/posts')
expect (Object.fromEntries (url.searchParams)).toEqual ({
tags: 'シロシ not:nico:shared', match: 'any' })
expect (url.hash).toBe ('#table')
})
it ('maps post search parameters to backend snake_case names', async () => { it ('maps post search parameters to backend snake_case names', async () => {
api.apiGet.mockResolvedValueOnce ({ posts: [], count: 0 }) api.apiGet.mockResolvedValueOnce ({ posts: [], count: 0 })
+4 -18
ファイルの表示
@@ -1,5 +1,4 @@
import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api' import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api'
import { queryRecordParams, setQueryRecords } from '@/lib/queryState'
import { switchLocalePath } from '@/lib/locales' import { switchLocalePath } from '@/lib/locales'
import type { LocaleCode } from '@/lib/locales' import type { LocaleCode } from '@/lib/locales'
@@ -8,7 +7,7 @@ import type { FetchPostsParams, Post, PostVersion } from '@/types'
export const fetchPosts = async ( export const fetchPosts = async (
{ url, title, tags, tagReferences, match, createdFrom, createdTo, updatedFrom, updatedTo, { url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams, originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams,
locale: LocaleCode = 'ja', locale: LocaleCode = 'ja',
): Promise<{ ): Promise<{
@@ -18,7 +17,6 @@ export const fetchPosts = async (
...(url && { url }), ...(url && { url }),
...(title && { title }), ...(title && { title }),
...(tags && { tags }), ...(tags && { tags }),
...(tagReferences && queryRecordParams (new URLSearchParams (tagReferences), 'tag_refs')),
...(match && { match }), ...(match && { match }),
...(createdFrom && { created_from: createdFrom }), ...(createdFrom && { created_from: createdFrom }),
...(createdTo && { created_to: createdTo }), ...(createdTo && { created_to: createdTo }),
@@ -39,21 +37,9 @@ export const switchPostFilterLocale = async (
if (tags == null || tags.trim () === '') if (tags == null || tags.trim () === '')
return switchLocalePath (location, targetLocale) return switchLocalePath (location, targetLocale)
const { references } = await apiGet<{ references: { const result = await apiGet<{ tags: string }> ('/posts/tag_filters', {
name: string locale, params: { tags, target_locale: targetLocale } })
tagId?: number query.set ('tags', result.tags)
externalTagId?: number
negative?: boolean
missing?: boolean }[] }> ('/posts/tag_filters', {
locale, params: { tags, target_locale: targetLocale,
...queryRecordParams (query, 'tag_refs') } })
query.set ('tags', references.map (reference => reference.name).join (' '))
setQueryRecords (query, 'tag_refs', references.map (reference => ({
name: reference.name,
negative: reference.negative,
...(reference.tagId != null && { tag_id: reference.tagId }),
...(reference.externalTagId != null && { external_tag_id: reference.externalTagId }),
...(reference.missing && { missing: true }) })))
return switchLocalePath ({ ...location, search: `?${query}` }, targetLocale) return switchLocalePath ({ ...location, search: `?${query}` }, targetLocale)
} }
-3
ファイルの表示
@@ -9,7 +9,6 @@ import { getHelpArticle,
import { localeFromPath, localeLanguage, unlocalisedPath } from '@/lib/locales' import { localeFromPath, localeLanguage, unlocalisedPath } from '@/lib/locales'
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { queryRecordParams } from '@/lib/queryState'
import { isCountsPath } from '@/lib/routeLocales' import { isCountsPath } from '@/lib/routeLocales'
import { fetchNicoTags, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags' import { fetchNicoTags, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
import { fetchWikiAutolinkCandidates, import { fetchWikiAutolinkCandidates,
@@ -178,8 +177,6 @@ const prefetchPostsIndex: Prefetcher = async (qc, url, locale) => {
const keys = { const keys = {
tags, match: m, page, limit, url: qURL, title, tags, match: m, page, limit, url: qURL, title,
...(url.searchParams.has ('tag_refs') && {
tagReferences: new URLSearchParams (queryRecordParams (url.searchParams, 'tag_refs')).toString () }),
originalCreatedFrom, originalCreatedTo, createdFrom, createdTo, originalCreatedFrom, originalCreatedTo, createdFrom, createdTo,
updatedFrom, updatedTo, order } updatedFrom, updatedTo, order }
+3 -4
ファイルの表示
@@ -4,7 +4,6 @@ const schemas: Record<string, string[]> = {
conditions: ['field', 'operator', 'value', 'end', 'blank', 'input', 'values', 'grain', 'value_type'], conditions: ['field', 'operator', 'value', 'end', 'blank', 'input', 'values', 'grain', 'value_type'],
condition_values: ['condition', 'value', 'value_type'], condition_values: ['condition', 'value', 'value_type'],
drill: ['field', 'grain', 'value', 'value_type'], drill: ['field', 'grain', 'value', 'value_type'],
tag_refs: ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
column_order: ['value', 'value_type'], column_order: ['value', 'value_type'],
measures: ['key', 'axis', 'value', 'value_type'] } measures: ['key', 'axis', 'value', 'value_type'] }
@@ -24,8 +23,8 @@ export const queryRecords = (params: URLSearchParams, key: string): QueryRecord[
const index = Number (parts[0]) const index = Number (parts[0])
const record = indexed.get (index) ?? { } const record = indexed.get (index) ?? { }
const field = parts[1] const field = parts[1]
record[field] = ['blank', 'negative', 'missing', 'values'].includes (field) record[field] = ['blank', 'values'].includes (field)
? value === '1' : (['tag_id', 'external_tag_id', 'axis', 'condition'].includes (field) ? value === '1' : (['axis', 'condition'].includes (field)
? Number (value) : value) ? Number (value) : value)
indexed.set (index, record) indexed.set (index, record)
} }
@@ -59,7 +58,7 @@ export const setQueryRecords = (
params.set (`${key}.${index}.value_type`, value == null ? 'null' : typeof value) params.set (`${key}.${index}.value_type`, value == null ? 'null' : typeof value)
if (value == null) continue if (value == null) continue
params.set (name, typeof value === 'boolean' params.set (name, typeof value === 'boolean'
? (['blank', 'negative', 'missing', 'values'].includes (field) ? (['blank', 'values'].includes (field)
? (value ? '1' : '0') : String (value)) ? (value ? '1' : '0') : String (value))
: String (value)) : String (value))
} }
+8 -5
ファイルの表示
@@ -1,4 +1,4 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { hydrateRoot } from 'react-dom/client' import { hydrateRoot } from 'react-dom/client'
import { renderToString } from 'react-dom/server' import { renderToString } from 'react-dom/server'
@@ -36,6 +36,9 @@ const execute = () => fireEvent.submit (
screen.getByText ('集計実行').closest ('form')!) screen.getByText ('集計実行').closest ('form')!)
const openConditions = () => fireEvent.click ( const openConditions = () => fireEvent.click (
screen.getByText ('集計条件').closest ('button')!) screen.getByText ('集計条件').closest ('button')!)
const resourceInput = () => within (
screen.getByText ('抽出条件', { selector: 'legend' }).closest ('fieldset')!,
).getByRole ('textbox', { name: 'Id.' })
const renderPage = (extra = '') => renderWithProviders (<> const renderPage = (extra = '') => renderWithProviders (<>
<CountsResourcePage resource="posts"/><Location/> <CountsResourcePage resource="posts"/><Location/>
</>, { route: `/ja/counts/posts?mode=details&timezone=UTC${extra}` }) </>, { route: `/ja/counts/posts?mode=details&timezone=UTC${extra}` })
@@ -149,7 +152,7 @@ describe ('Counts execution and URL state', () => {
await waitForResult (queryClient) await waitForResult (queryClient)
await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt)) await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt))
openConditions () openConditions ()
const input = screen.getByRole ('textbox', { name: 'Id.' }) const input = resourceInput ()
fireEvent.change (input, { target: { value: '7' } }) fireEvent.change (input, { target: { value: '7' } })
fireEvent.change (screen.getByLabelText ('表示件数', { selector: 'select' }), { fireEvent.change (screen.getByLabelText ('表示件数', { selector: 'select' }), {
target: { value: '20' } }) target: { value: '20' } })
@@ -167,7 +170,7 @@ describe ('Counts execution and URL state', () => {
await waitFor (() => expect (fetchCounts).toHaveBeenCalledTimes (2)) await waitFor (() => expect (fetchCounts).toHaveBeenCalledTimes (2))
expect (fetchCounts.mock.calls[1][1].get ('resource_id')).toBe ('7') expect (fetchCounts.mock.calls[1][1].get ('resource_id')).toBe ('7')
expect (currentParams ().toString ()).toBe (oldURL) expect (currentParams ().toString ()).toBe (oldURL)
expect (screen.getByRole ('textbox', { name: 'Id.' })).toBe (input) expect (resourceInput ()).toBe (input)
await act (async () => complete ({ ...detailResult, await act (async () => complete ({ ...detailResult,
executionAt: '2024-03-02T00:00:00.000000Z', limit: 20 })) executionAt: '2024-03-02T00:00:00.000000Z', limit: 20 }))
await waitFor (() => expect (currentParams ().get ('resource_id')).toBe ('7')) await waitFor (() => expect (currentParams ().get ('resource_id')).toBe ('7'))
@@ -182,14 +185,14 @@ describe ('Counts execution and URL state', () => {
await waitForResult (queryClient) await waitForResult (queryClient)
await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt)) await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt))
openConditions () openConditions ()
fireEvent.change (screen.getByRole ('textbox', { name: 'Id.' }), { fireEvent.change (resourceInput (), {
target: { value: '99' } }) target: { value: '99' } })
fetchCounts.mockRejectedValueOnce (new Error ('offline')) fetchCounts.mockRejectedValueOnce (new Error ('offline'))
execute () execute ()
await screen.findByRole ('alert') await screen.findByRole ('alert')
expect (fetchCounts).toHaveBeenCalledTimes (2) expect (fetchCounts).toHaveBeenCalledTimes (2)
expect (fetchCounts.mock.calls[1][1].get ('resource_id')).toBe ('99') expect (fetchCounts.mock.calls[1][1].get ('resource_id')).toBe ('99')
expect (screen.getByRole ('textbox', { name: 'Id.' })).toHaveValue ('99') expect (resourceInput ()).toHaveValue ('99')
expect (currentParams ().has ('resource_id')).toBe (false) expect (currentParams ().has ('resource_id')).toBe (false)
}) })
-2
ファイルの表示
@@ -1,4 +1,3 @@
import { queryRecordParams } from '@/lib/queryState'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
@@ -62,7 +61,6 @@ const PostListPage: FC = () => {
const keys = { const keys = {
tags: tagsKey, match, page, limit, tags: tagsKey, match, page, limit,
...(query.has ('tag_refs') && { tagReferences: new URLSearchParams (queryRecordParams (query, 'tag_refs')).toString () }),
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '', url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '', createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
order } as const order } as const
-2
ファイルの表示
@@ -1,4 +1,3 @@
import { queryRecordParams } from '@/lib/queryState'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
@@ -127,7 +126,6 @@ const PostSearchPage: FC = () => {
const keys: FetchPostsParams = { const keys: FetchPostsParams = {
tags: qTags, match: qMatch, page, limit, tags: qTags, match: qMatch, page, limit,
...(query.has ('tag_refs') && { tagReferences: new URLSearchParams (queryRecordParams (query, 'tag_refs')).toString () }),
url: qURL, url: qURL,
title: qTitle, title: qTitle,
originalCreatedFrom: qOriginalCreatedFrom, originalCreatedFrom: qOriginalCreatedFrom,
-1
ファイルの表示
@@ -20,7 +20,6 @@ export type FetchPostsParams = {
url: string url: string
title: string title: string
tags: string tags: string
tagReferences?: string
match: 'all' | 'any' match: 'all' | 'any'
originalCreatedFrom: string originalCreatedFrom: string
originalCreatedTo: string originalCreatedTo: string