コミットを比較

..
2 コミット
作成者 SHA1 メッセージ 日付
Codex Night Worker e3e9efe684 AI: address review on PR #458 2026-11-13 12:40:14 +09:00
Codex Night Worker 73d290c450 AI: address issue #272 2026-11-12 01:53:16 +09:00
11個のファイルの変更、155行の追加、78行の削除
+6 -6
ファイルの表示
@@ -37,7 +37,7 @@ class PostsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
history_join_required = order[0] == 'updated_at' || updated_between.any?(&:present?) needs_history_join = order[0] == 'updated_at' || updated_between.any?(&:present?)
q = q =
filtered_posts(locale) filtered_posts(locale)
@@ -57,8 +57,8 @@ class PostsController < ApplicationController
q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0] q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0]
q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1] q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1]
count_scope = q count_scope = q
if history_join_required if needs_history_join
history_times = PostVersion.all history_times = HistoryAccess.visible(PostVersion.all)
.select('post_id, MAX(created_at) AS latest_at').group(:post_id) .select('post_id, MAX(created_at) AS latest_at').group(:post_id)
q = q.joins("LEFT JOIN (#{ history_times.to_sql }) history_times " + q = q.joins("LEFT JOIN (#{ history_times.to_sql }) history_times " +
'ON history_times.post_id = posts.id') 'ON history_times.post_id = posts.id')
@@ -72,7 +72,7 @@ class PostsController < ApplicationController
q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1]) q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
end end
count_scope = q if updated_between.any?(&:present?) count_scope = q if updated_between.any?(&:present?)
if history_join_required if needs_history_join
q = q.select('posts.*', "#{ updated_at_all_sql } AS updated_at") q = q.select('posts.*', "#{ updated_at_all_sql } AS updated_at")
end end
@@ -92,10 +92,10 @@ class PostsController < ApplicationController
.offset(offset) .offset(offset)
.to_a .to_a
latest_version_times = latest_version_times =
if history_join_required if needs_history_join
{ } { }
else else
PostVersion.where(post_id: posts.map(&:id)) HistoryAccess.visible(PostVersion.where(post_id: posts.map(&:id)))
.group(:post_id).maximum(:created_at) .group(:post_id).maximum(:created_at)
end end
+1
ファイルの表示
@@ -49,6 +49,7 @@ class TagDescriptionsController < ApplicationController
private private
def representation description def representation description
HistoryAccess.require_current!(description, TagDescriptionVersion)
description.as_json(only: [ description.as_json(only: [
:tag_id, :tag_id,
:language_code, :language_code,
+1 -1
ファイルの表示
@@ -1,7 +1,7 @@
class TagGroupsController < ApplicationController class TagGroupsController < ApplicationController
def index def index
locale = resolve_locale! locale = resolve_locale!
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id) groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).includes(:created_by_user, :updated_by_user).order(:id)
render json: TagGroupRepr.many(locale, groups.to_a) render json: TagGroupRepr.many(locale, groups.to_a)
end end
+60 -54
ファイルの表示
@@ -218,63 +218,16 @@ class TagsController < ApplicationController
locale = resolve_locale! locale = resolve_locale!
q = params[:q].to_s.strip.sub(/\Anot:/i, '') q = params[:q].to_s.strip.sub(/\Anot:/i, '')
prefix = "#{ ActiveRecord::Base.sanitize_sql_like(q) }%" literal = ActiveRecord::Base.sanitize_sql_like(q)
nico = bool?(:nico, default: true)
present = bool?(:present, default: true)
with_nico = bool?(:nico, default: true) rows = autocomplete_rows(locale:, pattern: "#{ literal }%", nico:, present:)
present_only = bool?(:present, default: true) if rows.length < 20 && q.present?
suffix_rows = autocomplete_rows(locale:, pattern: "%#{ literal }", nico:, present:)
alias_rows = rows = (rows + suffix_rows).uniq { [_1['category'], _1['id']] }.first(20)
TagName
.where(language_code: locale.language_code, primary_flg: false)
.where('name LIKE ?', prefix)
.pluck(:tag_id, :name)
matched_alias_by_tag_id = { }
tag_ids = []
alias_rows.each do |tag_id, alias_name|
tag_ids << tag_id
matched_alias_by_tag_id[tag_id] ||= alias_name
end end
base =
Tag
.joins(:tag_names)
.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
deprecated_at: nil)
base = base.where('tags.post_count > 0') if present_only
canonical_hit = base.where('tag_names.name LIKE ?', prefix)
internal_tags = canonical_hit.or(base.where(id: tag_ids.uniq))
internal_rows =
internal_tags
.order(Arel.sql('tags.post_count DESC, tag_names.name'))
.limit(20)
.map { |tag|
TagRepr.base(tag, language_code: locale.language_code)
.merge(matched_alias: matched_alias_by_tag_id[tag.id])
}
return render json: internal_rows unless with_nico
external_base = ExternalTag.all
external_base = external_base.where('post_count > 0') if present_only
external_rows =
external_base
.where("CONCAT(platform, ':', name) LIKE ? OR name LIKE ?", prefix, prefix)
.order(post_count: :desc, name: :asc)
.limit(20)
.map { external_tag_json(_1) }
rows =
(internal_rows + external_rows)
.sort_by { |row| [-row['post_count'], row['name'] || row[:name]] }
.first(20)
render json: rows render json: rows
end end
@@ -545,6 +498,59 @@ class TagsController < ApplicationController
private private
def autocomplete_rows locale:, pattern:, nico:, present:
alias_rows =
TagName
.where(language_code: locale.language_code, primary_flg: false)
.where('name LIKE ?', pattern)
.pluck(:tag_id, :name)
matched_alias_by_tag_id = { }
tag_ids = []
alias_rows.each do |tag_id, alias_name|
tag_ids << tag_id
matched_alias_by_tag_id[tag_id] ||= alias_name
end
base =
Tag
.joins(:tag_names)
.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
deprecated_at: nil)
base = base.where('tags.post_count > 0') if present
canonical_hit = base.where('tag_names.name LIKE ?', pattern)
internal_tags = canonical_hit.or(base.where(id: tag_ids.uniq))
internal_rows =
internal_tags
.order(Arel.sql('tags.post_count DESC, tag_names.name'))
.limit(20)
.map { |tag|
TagRepr.base(tag, language_code: locale.language_code)
.merge(matched_alias: matched_alias_by_tag_id[tag.id])
}
return internal_rows unless nico
external_base = ExternalTag.all
external_base = external_base.where('post_count > 0') if present
external_rows =
external_base
.where("CONCAT(platform, ':', name) LIKE ? OR name LIKE ?", pattern, pattern)
.order(post_count: :desc, name: :asc)
.limit(20)
.map { external_tag_json(_1) }
(internal_rows + external_rows)
.sort_by { |row| [-row['post_count'], row['name'] || row[:name]] }
.first(20)
end
def material_filter_param default: def material_filter_param default:
value = params[:material_filter].to_s.presence value = params[:material_filter].to_s.presence
return default unless ['present', 'missing', 'any'].include?(value) return default unless ['present', 'missing', 'any'].include?(value)
+6 -5
ファイルの表示
@@ -8,11 +8,12 @@ class WikiPagesController < ApplicationController
title = params[:title].to_s.strip title = params[:title].to_s.strip
descriptions = descriptions =
TagDescription.where(language_code: locale.language_code) HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
.where(language_code: locale.language_code)
.includes(tag: :tag_names) .includes(tag: :tag_names)
descriptions = descriptions.order(updated_at: :desc, id: :asc) descriptions = descriptions.order(updated_at: :desc, id: :asc)
tag_groups = TagGroup.order(updated_at: :desc, id: :asc) tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).order(updated_at: :desc, id: :asc)
if title.present? if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%" pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions = descriptions =
@@ -40,13 +41,13 @@ class WikiPagesController < ApplicationController
return head :not_found unless language.is_a?(String) return head :not_found unless language.is_a?(String)
return head :not_found unless Language.exists?(code: language) return head :not_found unless Language.exists?(code: language)
names = TagDescription.joins(tag: :tag_names) names = HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names)
.where(language_code: locale.language_code, tag_names: { language_code: language }) .where(language_code: locale.language_code, tag_names: { language_code: language })
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name') .pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
candidates = names.map do |tag_id, description_id, name| candidates = names.map do |tag_id, description_id, name|
WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:) WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:)
end end
candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name| candidates.concat(HistoryAccess.current(TagGroup.all, TagGroupVersion).order(:id).pluck(:id, :name).map do |id, name|
WikiPageRepr.autolink_group(locale, id:, name:, language:) WikiPageRepr.autolink_group(locale, id:, name:, language:)
end) end)
render json: candidates render json: candidates
@@ -410,7 +411,7 @@ class WikiPagesController < ApplicationController
end end
def tag_description_by_title locale, title def tag_description_by_title locale, title
TagDescription.joins(tag: :tag_names) HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names)
.find_by(language_code: locale.language_code, .find_by(language_code: locale.language_code,
tag_names: { language_code: locale.language_code, name: title }) tag_names: { language_code: locale.language_code, name: title })
end end
+23
ファイルの表示
@@ -147,6 +147,29 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
scope.where(Arel.sql(sql(scope.klass, row))) scope.where(Arel.sql(sql(scope.klass, row)))
end end
def self.current(scope, version_model)
return scope if admin?
table = scope.klass.table_name
keys = KEYS.fetch(version_model.table_name)
matches = keys.map do |key|
column = scope.klass.column_names.include?(key) ? key : 'id'
"current_snapshot.#{ key } = #{ table }.#{ column }"
end.join(' AND ')
boundary = "#{ matches } AND current_snapshot.version_no >= #{ table }.version_no " +
"AND current_snapshot.event_type = 'discard'"
scope = scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " +
"current_snapshot WHERE #{ boundary })"))
condition = "#{ matches } AND current_snapshot.version_no = #{ table }.version_no"
scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " +
"current_snapshot WHERE #{ condition } AND NOT (#{ sql(version_model, 'current_snapshot') }))"))
end
def self.require_current!(record, version_model)
return if admin?
raise Forbidden unless current(record.class.where(id: record.id), version_model).exists?
end
def self.blob_visible?(blob, seen = []) def self.blob_visible?(blob, seen = [])
return true if admin? return true if admin?
return false if seen.include?(blob.id) return false if seen.include?(blob.id)
+1
ファイルの表示
@@ -44,6 +44,7 @@ module TagGroupRepr
end end
def one locale, group def one locale, group
HistoryAccess.require_current!(group, TagGroupVersion)
many(locale, [group]).first many(locale, [group]).first
end end
+1
ファイルの表示
@@ -5,6 +5,7 @@ module WikiPageRepr
module_function module_function
def tag_description locale, description def tag_description locale, description
HistoryAccess.require_current!(description, TagDescriptionVersion)
tag = description.tag tag = description.tag
{ 'id' => description.id, { 'id' => description.id,
+3 -3
ファイルの表示
@@ -14,7 +14,7 @@ RSpec.describe 'History boundaries shared with Counts', type: :request do
end end
end end
it 'keeps discarded history private while exposing the current description' do it 'denies the same discarded description through Counts, the facade and the ordinary API' do
get '/counts/query', params: { resource: 'tag_descriptions', definition: 'history', get '/counts/query', params: { resource: 'tag_descriptions', definition: 'history',
mode: 'details', locale: 'ja', execution_at: '2025-01-01T00:00:00Z' } mode: 'details', locale: 'ja', execution_at: '2025-01-01T00:00:00Z' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -22,8 +22,8 @@ RSpec.describe 'History boundaries shared with Counts', type: :request do
'excluded_count' => 2, 'versions' => []) 'excluded_count' => 2, 'versions' => [])
["/wiki/#{ description.id }", "/tags/#{ tag.id }/description"].each do |path| ["/wiki/#{ description.id }", "/tags/#{ tag.id }/description"].each do |path|
get path, params: { locale: 'ja' } get path, params: { locale: 'ja' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:not_found)
expect(response.parsed_body.fetch('body')).to eq(description.body) expect(response.body).not_to include('private body')
end end
get '/sitemap_entries/descriptions', params: { locale: 'ja' } get '/sitemap_entries/descriptions', params: { locale: 'ja' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
+10 -8
ファイルの表示
@@ -69,38 +69,40 @@ RSpec.describe 'Post history privacy', type: :request do
sign_in_as(create(:user, role.to_sym)) unless role == 'anonymous' sign_in_as(create(:user, role.to_sym)) unless role == 'anonymous'
end end
it 'uses the latest version timestamp for display regardless of role' do it 'uses only authorised history timestamps for display' do
get '/posts' get '/posts'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
row = response.parsed_body.fetch('posts').find { _1.fetch('id') == post_record.id } row = response.parsed_body.fetch('posts').find { _1.fetch('id') == post_record.id }
expected = origin + 4.days expected = origin + (role == 'admin' ? 4 : 3).days
expect(Time.zone.parse(row.fetch('updated_at'))).to eq(expected) expect(Time.zone.parse(row.fetch('updated_at'))).to eq(expected)
end end
it 'uses the latest version timestamp for date bounds and count regardless of role' do it 'uses the same visibility for date bounds and count' do
get '/posts', params: { updated_from: (origin + 4.days).iso8601, get '/posts', params: { updated_from: (origin + 4.days).iso8601,
updated_to: (origin + 4.days).iso8601 } updated_to: (origin + 4.days).iso8601 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
ids = response.parsed_body.fetch('posts').pluck('id') ids = response.parsed_body.fetch('posts').pluck('id')
expect(ids).to eq([post_record.id]) expect(ids).to eq(role == 'admin' ? [post_record.id] : [])
expect(response.parsed_body.fetch('count')).to eq(ids.length) expect(response.parsed_body.fetch('count')).to eq(ids.length)
end end
it 'sorts using the latest version timestamps in both directions regardless of role' do it 'sorts using authorised timestamps in both directions' do
['asc', 'desc'].each do |direction| ['asc', 'desc'].each do |direction|
get '/posts', params: { order: "updated_at:#{ direction }" } get '/posts', params: { order: "updated_at:#{ direction }" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expected = [other_post.id, post_record.id] expected = role == 'admin' ? [other_post.id, post_record.id] :
[post_record.id, other_post.id]
expected.reverse! if direction == 'desc' expected.reverse! if direction == 'desc'
expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected) expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected)
end end
end end
it 'applies latest version timestamp ordering before pagination regardless of role' do it 'applies authorised timestamp ordering before pagination' do
get '/posts', params: { order: 'updated_at:desc', limit: 1 } get '/posts', params: { order: 'updated_at:desc', limit: 1 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.parsed_body.fetch('posts').pluck('id')).to eq([post_record.id]) expected = role == 'admin' ? post_record.id : other_post.id
expect(response.parsed_body.fetch('posts').pluck('id')).to eq([expected])
expect(response.parsed_body.fetch('count')).to eq(2) expect(response.parsed_body.fetch('count')).to eq(2)
end end
+43 -1
ファイルの表示
@@ -482,6 +482,7 @@ RSpec.describe 'Tags API', type: :request do
create(:tag, category: :general, primary_name: name, post_count: 1) create(:tag, category: :general, primary_name: name, post_count: 1)
create(:external_tag, name:, post_count: 1) create(:external_tag, name:, post_count: 1)
end end
create(:external_tag, name: 'suffix_combined', post_count: 100)
get '/tags/autocomplete', params: { q: 'combined' } get '/tags/autocomplete', params: { q: 'combined' }
@@ -490,6 +491,39 @@ RSpec.describe 'Tags API', type: :request do
expect(json.map { |row| row.fetch('name') }).to eq(expected.first(20)) expect(json.map { |row| row.fetch('name') }).to eq(expected.first(20))
end end
it 'appends primary, alias, and external suffix matches without duplicate resources' do
prefix = create(:tag, primary_name: 'tail', post_count: 1)
create(:tag_name, :alias, name: 'tail_alias', tag: prefix)
create(:tag_name, :alias, name: 'alias_tail', tag: prefix)
create(:tag, primary_name: 'primary_tail', post_count: 4)
alias_target = create(:tag, primary_name: 'alias_target', post_count: 3)
create(:tag_name, :alias, name: 'suffix_tail', tag: alias_target)
external = create(:external_tag, id: prefix.id, name: 'external_tail', post_count: 5)
create(:tag, primary_name: 'middle_tail_only', post_count: 100)
create(:external_tag, name: 'middle_tail_only', post_count: 100)
get '/tags/autocomplete', params: { q: 'tail' }
expect(response).to have_http_status(:ok)
expect(json.map { |row| row.fetch('name') }).to eq(
['tail', 'nico:external_tail', 'primary_tail', 'alias_target'])
expect(json.first.fetch('matched_alias')).to eq('tail_alias')
expect(json.last.fetch('matched_alias')).to eq('suffix_tail')
expect(json[1]).to include('id' => external.id, 'category' => 'nico')
end
it 'fills only the remaining places with suffix matches' do
names = 19.times.map { |number| "capacity_#{ number.to_s.rjust(2, '0') }" }
names.each { create(:tag, primary_name: _1, post_count: 1) }
create(:tag, primary_name: 'first_capacity', post_count: 3)
create(:external_tag, name: 'second_capacity', post_count: 2)
get '/tags/autocomplete', params: { q: 'capacity' }
expect(response).to have_http_status(:ok)
expect(json.map { |row| row.fetch('name') }).to eq(names + ['first_capacity'])
end
it 'excludes external tags but preserves internal alias matches when nico is false' do it 'excludes external tags but preserves internal alias matches when nico is false' do
internal = create(:tag, category: :general, primary_name: 'switch_internal', post_count: 1) internal = create(:tag, category: :general, primary_name: 'switch_internal', post_count: 1)
alias_target = create(:tag, category: :general, primary_name: 'alias_target', post_count: 1) alias_target = create(:tag, category: :general, primary_name: 'alias_target', post_count: 1)
@@ -518,12 +552,20 @@ RSpec.describe 'Tags API', type: :request do
create(:external_tag, name: literal, post_count: 1) create(:external_tag, name: literal, post_count: 1)
create(:tag, category: :general, primary_name: 'literalXmatch', post_count: 2) create(:tag, category: :general, primary_name: 'literalXmatch', post_count: 2)
create(:external_tag, name: 'literalXmatch', post_count: 2) create(:external_tag, name: 'literalXmatch', post_count: 2)
suffix = "suffix_literal#{ wildcard }"
create(:tag, primary_name: suffix, post_count: 1)
suffix_alias = create(:tag, primary_name: 'suffix_alias_target', post_count: 1)
create(:tag_name, :alias, name: "alias_#{ suffix }", tag: suffix_alias)
create(:external_tag, name: suffix, post_count: 1)
create(:tag, primary_name: 'suffix_literalX', post_count: 2)
create(:external_tag, name: 'suffix_literalX', post_count: 2)
get '/tags/autocomplete', params: { q: "literal#{ wildcard }" } get '/tags/autocomplete', params: { q: "literal#{ wildcard }" }
expect(json.map { |row| row.fetch('name') }) expect(json.map { |row| row.fetch('name') })
.to contain_exactly( .to contain_exactly(
literal, primary_tag_name_for(alias_target, 'ja').name, "nico:#{ literal }") literal, primary_tag_name_for(alias_target, 'ja').name, "nico:#{ literal }",
suffix, 'suffix_alias_target', "nico:#{ suffix }")
end end
end end