コミットを比較

..
14 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 398f4f6b82 #424 2026-10-12 23:43:49 +09:00
みてるぞ 5246ac5c61 #424 2026-10-12 23:23:31 +09:00
みてるぞ 7cf2978f5f #424 2026-10-12 21:53:02 +09:00
みてるぞ 11e6f73287 #424 2026-10-12 21:34:02 +09:00
みてるぞ 2e98ed02e3 #424 2026-10-12 20:10:04 +09:00
みてるぞ 7c25dedac2 #424 2026-10-12 15:43:32 +09:00
みてるぞ 1a4cbe36fb #424 2026-10-12 15:16:58 +09:00
みてるぞ 4813a3a09e #424 2026-10-12 14:46:19 +09:00
みてるぞ e117e20346 #424 2026-10-12 12:45:00 +09:00
みてるぞ 4164c3c3f7 #424 2026-10-12 03:45:11 +09:00
みてるぞ 4c891604b5 #424 2026-10-12 02:49:24 +09:00
みてるぞ 88e691165a #424 2026-10-12 02:37:56 +09:00
みてるぞ 18415d7f55 Merge remote-tracking branch 'origin/main' into feature/424 2026-10-11 03:58:13 +09:00
みてるぞ a219cc350b #424 一時コミット 2026-10-08 02:24:39 +09:00
394個のファイルの変更、6472行の追加、21038行の削除
+4 -17
ファイルの表示
@@ -573,10 +573,10 @@ and layout reuse, follow `frontend/AGENTS.md`.
- When new user-facing wording appears necessary, ask the user for the exact
wording and placement before implementing it.
- Do not invent replacement copy when removing unrequested wording.
- 明示的な個別指示がない限り、テストの追加・修正・削除・実行を一切行わない。
fixture、mock、snapshot、テスト補助コードも対象とする。
既存のテスト差分を勝手に修正・差戻ししない。
この規約はディレクトリ固有の自動的なテスト実行推奨より優先する。
- Do not create, modify, or run tests unless the user explicitly asks for
test work. When the user asks for tests, keep working within the permitted
test-file scope and rerun them until they pass or the remaining failure is
clearly blocked.
- Test-only work includes adding, updating, deleting, reorganising, or fixing
SyntaxError in tests. During test-only work, do not modify production code.
- During test-only work, do not change production constants, behaviour, API
@@ -631,19 +631,6 @@ and layout reuse, follow `frontend/AGENTS.md`.
## Frontend rules
- 原則として、ユーザ向け UI 文言を TSX / TypeScript に直接記述しない。
新しい文言は既存の i18n システムで管理し、適切な既存キーを先に確認する。
不必要な重複定義を避け、新規機能固有の文言は適切な namespace にまとめる。
- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と
`_untranslated` 規約を使い、language metadata を維持する。
翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。
- UI 文言の追加・変更時はビルド、静的検査、実画面の確認等で、辞書と
SSR / hydration の整合及び翻訳キーの非露出を検証する。
i18n の検証を理由にテスト関連作業を開始しない。
- API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に
翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、
アクセシビリティ用ラベルも UI 文言として扱う。
- Use `frontend/src/lib/api.ts` for API calls so headers and camelCase conversion stay consistent.
- Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`;
avoid ad hoc query key arrays.
+2 -48
ファイルの表示
@@ -1,52 +1,22 @@
class ApplicationController < ActionController::API
include ActionController::Cookies
include ActionController::RequestForgeryProtection
class UnsupportedLocale < StandardError
;
end
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
rescue_from(UnsupportedLocale) { head :not_found }
before_action :reject_banned_ip_address!
before_action :authenticate_user
before_action :reject_banned_user!
before_action :validate_explicit_locale!
protect_from_forgery with: :exception
self.forgery_protection_origin_check = true
rescue_from ActionController::InvalidAuthenticityToken, with: :reject_invalid_csrf
after_action :prevent_authenticated_caching
def current_user = @current_user
private
def reject_invalid_csrf
response.headers['X-CSRF-Invalid'] = '1'
response.headers['Cache-Control'] = 'private, no-store'
head :forbidden
end
def authenticate_user
code = session[:inheritance_code]
code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE']
return if code.blank?
@current_user = User.find_by(inheritance_code: code)
end
def prevent_authenticated_caching
response.headers['Cache-Control'] = 'private, no-store' if current_user
end
def establish_user_session user
reset_session
session[:inheritance_code] = user.inheritance_code
@current_user = user
response.headers['Cache-Control'] = 'private, no-store'
end
def bool? key, default: false
return default if params[key].nil?
@@ -123,21 +93,5 @@ class ApplicationController < ActionController::API
value
end
def resolve_locale!
return @resolved_locale if defined?(@resolved_locale)
code = params.fetch(:locale, 'ja')
unless code.is_a?(String) && Locale.codes.value?(code)
raise UnsupportedLocale
end
locale = Locale.find_by(code:)
raise UnsupportedLocale unless locale && locale.deprecated_at.nil?
@resolved_locale = locale
end
def validate_explicit_locale!
resolve_locale! if params.key?(:locale)
end
def resolve_locale! = Locale.find_by(code: params[:locale]) || Locale.nipponese
end
-21
ファイルの表示
@@ -1,21 +0,0 @@
module CountsListing
extend ActiveSupport::Concern
def index
locale = resolve_locale!
resource = is_a?(CountsPostsController) ? 'posts' : 'tags'
query = CountsQuery.new(version_scope, resource, request.query_parameters.except('locale')
.merge('locale' => locale.code))
result = { count: query.count, page: query.page, limit: query.limit,
at: request.query_parameters['at'].presence || Time.current.utc.iso8601(6) }
if query.mode == 'summary'
result.merge!(query.summary)
else
result[:versions] = CountsVersionRepr.many(
query.details, resource:, language_code: locale.language_code)
end
render json: result
rescue ArgumentError, TypeError
head :bad_request
end
end
-45
ファイルの表示
@@ -1,45 +0,0 @@
class CountsExportsController < ApplicationController
wrap_parameters false
def prepare
locale = resolve_locale!
payload = request.request_parameters.merge(request.query_parameters)
exporter = CountsExporter.new(payload, language_code: locale.language_code)
session[:counts_export_owner] ||= SecureRandom.hex(32)
token = CountsExportDownload.prepare(exporter, session[:counts_export_owner])
response.headers['Cache-Control'] = 'private, no-store'
render json: { token: }
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
def download
file = CountsExportDownload.find(params[:token], session[:counts_export_owner])
return head :not_found unless file
response.headers['Cache-Control'] = 'private, no-store'
send_file file.fetch('path'),
filename: "counts-#{ file.fetch('resource') }.#{ file.fetch('format') }",
type: file['format'] == 'json' ? 'application/json' : 'text/csv',
disposition: 'attachment'
end
def index
locale = resolve_locale!
payload = request.request_parameters.except('authenticity_token')
.merge(request.query_parameters)
exporter = CountsExporter.new(payload,
language_code: locale.language_code)
response.headers['Content-Type'] = exporter.format == 'json' ?
'application/json; charset=utf-8' : 'text/csv; charset=utf-8'
response.headers['Content-Disposition'] =
"attachment; filename=\"counts-#{ exporter.resource }.#{ exporter.format }\""
response.headers['Cache-Control'] = 'private, no-store'
response.headers['X-Accel-Buffering'] = 'no'
# Rack::ETag otherwise materialises the response to calculate a digest.
response.headers['Last-Modified'] = Time.current.httpdate
self.response_body = exporter.body
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
end
-3
ファイルの表示
@@ -1,3 +0,0 @@
class CountsPostsController < PostVersionsController
include CountsListing
end
-10
ファイルの表示
@@ -1,10 +0,0 @@
class CountsStatesController < ApplicationController
def index
locale = resolve_locale!
result = CountsStateQuery.new(request.query_parameters,
language_code: locale.language_code).call
render json: result
rescue ArgumentError, TypeError
head :bad_request
end
end
-21
ファイルの表示
@@ -1,21 +0,0 @@
class CountsTablesController < ApplicationController
def definition
resolve_locale!
definition = CountsResourceDefinition.new(params[:resource])
render json: { fields: definition.metadata,
axes: { latest: definition.axes(false), history: definition.axes(true) },
grains: CountsAxes::GRAINS }
rescue ArgumentError
head :bad_request
end
def index
resolve_locale!
render json: CountsTableQuery.new(request.query_parameters).call
rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
end
-3
ファイルの表示
@@ -1,3 +0,0 @@
class CountsTagsController < TagVersionsController
include CountsListing
end
-10
ファイルの表示
@@ -1,10 +0,0 @@
class LocalesController < ApplicationController
def index
locales = Locale.where(deprecated_at: nil).order(:created_at).map do |locale|
{ code: locale.code_before_type_cast, name: locale.name }
end
response.headers['Cache-Control'] = 'no-store'
render json: { locales: }
end
end
+1 -2
ファイルの表示
@@ -255,8 +255,7 @@ class MaterialsController < ApplicationController
end
def resolve_material_tag! locale, tag_name_raw
Tag.find_or_create_by_tag_name!(locale, tag_name_raw, category: :material,
created_by_user: current_user)
Tag.find_or_create_by_tag_name!(locale, tag_name_raw, category: :material)
end
def material_index_needs_tag_name? filters
+8 -22
ファイルの表示
@@ -24,7 +24,7 @@ class NicoTagsController < ApplicationController
q =
ExternalTag
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " +
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.external_tag_id = external_tags.id')
.includes(linked_tags: [:tag_names, :tag_descriptions])
if name
@@ -37,7 +37,7 @@ class NicoTagsController < ApplicationController
linked_tag_ids =
Tag
.joins(:tag_names)
.where(tag_names: { language_code: locale.language_code, primary_flg: true })
.where(tag_names: { language_code: 'ja', primary_flg: true })
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
.pluck(:id)
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
@@ -45,7 +45,7 @@ class NicoTagsController < ApplicationController
end
if link_status.in?(['linked', 'unlinked'])
exists_sql =
'EXISTS (SELECT 1 FROM nico_tag_relations ' +
'EXISTS (SELECT 1 FROM nico_tag_relations ' \
'WHERE nico_tag_relations.nico_tag_id = external_tags.id)'
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
end
@@ -98,8 +98,7 @@ class NicoTagsController < ApplicationController
ApplicationRecord.transaction do
linked_tags = Tag.normalise_tags!(locale, linked_tag_names,
with_tagme: false,
with_no_deerjikist: false,
created_by_user: current_user)
with_no_deerjikist: false)
TagVersioning.record_tag_snapshots!(
linked_tags,
@@ -141,23 +140,10 @@ class NicoTagsController < ApplicationController
end
def render_nico_tag_form_record_invalid record
names =
case record
when TagName
[record]
when Tag
record.tag_names.to_a
else
[]
end
messages = names.flat_map do |tag_name|
tag_name.errors.full_messages.map do |message|
"タグ名 “#{ tag_name.name }”: #{ message }"
end
end
if messages.any?
render_validation_error fields: { tags: messages }
if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
"タグ名 “#{ record.name }”: #{ message }"
} }
else
render_validation_error record
end
+27 -46
ファイルの表示
@@ -1,6 +1,5 @@
class PostVersionsController < ApplicationController
def index
locale = resolve_locale!
post_id = params[:post].presence
tag_id = params[:tag].presence&.to_i
external_tag_id = params[:external_tag].presence&.to_i
@@ -14,7 +13,21 @@ class PostVersionsController < ApplicationController
offset = (page - 1) * limit
q = version_scope
q = PostVersion.joins(<<~SQL.squish)
LEFT JOIN
post_versions prev
ON
prev.post_id = post_versions.post_id
AND prev.version_no = post_versions.version_no - 1
SQL
.select('post_versions.*',
'prev.title AS prev_title',
'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base',
'prev.tags_json AS prev_tags_json',
'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
@@ -32,24 +45,12 @@ class PostVersionsController < ApplicationController
.limit(limit)
.offset(offset)
render json: { versions: serialise_versions(versions, locale.language_code), count: }
render json: { versions: serialise_versions(versions), count: }
end
private
def version_scope
PostVersion.with_previous_version
.select('post_versions.*',
'prev.title AS prev_title',
'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base',
'prev.tags_json AS prev_tags_json',
'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before')
end
def serialise_versions rows, language_code
def serialise_versions rows
rows = rows.to_a
user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
@@ -57,16 +58,12 @@ class PostVersionsController < ApplicationController
[normalise_json(row.tags_json),
normalise_json(row.attributes['prev_tags_json']) || []]
}
tag_ids = snapshots.flatten.filter_map { _1['tag_id'] }.uniq
tag_names = TagName.where(tag_id: tag_ids, language_code:, primary_flg: true)
.pluck(:tag_id, :name).to_h
external_tag_names = external_tag_names_for(snapshots)
rows.map do |row|
cur_tags = snapshot_tag_literals(
normalise_json(row.tags_json), tag_names, external_tag_names)
cur_tags = snapshot_tag_literals(normalise_json(row.tags_json), external_tag_names)
prev_tags = snapshot_tag_literals(
normalise_json(row.attributes['prev_tags_json']) || [], tag_names, external_tag_names)
normalise_json(row.attributes['prev_tags_json']) || [], external_tag_names)
{ post_id: row.post_id,
version_no: row.version_no,
@@ -78,7 +75,6 @@ class PostVersionsController < ApplicationController
prev: row.attributes['prev_thumbnail_base'] },
video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] },
tags: build_version_tags(cur_tags, prev_tags),
parent_post_ids: row.parent_post_ids,
original_created_from: {
current: row.original_created_from&.iso8601,
prev: row.attributes['prev_original_created_from']&.iso8601 },
@@ -110,27 +106,17 @@ class PostVersionsController < ApplicationController
names
end
def snapshot_tag_literals snapshots, tag_names, external_tag_names
def snapshot_tag_literals snapshots, external_tag_names
snapshots
.filter_map { |snapshot|
if snapshot.key?('tag_id')
sections = snapshot.fetch('sections', []).map { |section|
[section.fetch('begin_ms'), section['end_ms']]
}.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] }
name = tag_names[snapshot['tag_id']] || snapshot.fetch('name')
[tag_category_order(snapshot['category']),
{ identity: [:tag, snapshot['tag_id'], sections],
name: Post.tag_snapshot_literal(snapshot.merge('name' => name)),
restore_name: tag_names[snapshot['tag_id']] &&
Post.tag_snapshot_literal(snapshot.merge('name' => name)),
external: false }]
Post.tag_snapshot_literal(snapshot)]
elsif external_tag_names[snapshot['external_tag_id']]
[6, { identity: [:external_tag, snapshot['external_tag_id']],
name: external_tag_names[snapshot['external_tag_id']],
restore_name: nil, external: true }]
[6, external_tag_names[snapshot['external_tag_id']]]
end
}
.sort_by { |order, tag| [order, tag[:name].downcase] }
.sort_by { |order, name| [order, name.downcase] }
.map(&:second)
end
@@ -139,22 +125,17 @@ class PostVersionsController < ApplicationController
end
def build_version_tags cur_tags, prev_tags
current = cur_tags.index_by { _1[:identity] }
previous = prev_tags.index_by { _1[:identity] }
(current.keys | previous.keys).map do |identity|
(cur_tags | prev_tags).map do |name|
type =
if current.key?(identity) && previous.key?(identity)
if cur_tags.include?(name) && prev_tags.include?(name)
'context'
elsif current.key?(identity)
elsif cur_tags.include?(name)
'added'
else
'removed'
end
entry = current[identity] || previous[identity]
{ name: entry[:name], restore_name: entry[:restore_name],
external: entry[:external], type: }
{ name:, type: }
end
end
end
+23 -68
ファイルの表示
@@ -400,60 +400,8 @@ class PostsController < ApplicationController
render_post_form_record_invalid e.record
end
def tag_filters
source = resolve_locale!
code = params[:target_locale]
return head :not_found unless code.is_a?(String) && Locale.codes.value?(code)
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
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)
end
render json: { references: filters }
end
private
def tag_filter_references literals
PostTagSearch.parse_references(params[:tag_refs], 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]
@@ -465,19 +413,20 @@ 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 = tag_names.map do |raw_name|
{ name: TagName.canonicalise(locale, raw_name.sub(/\Anot:/i, '')).first,
negative: raw_name.downcase.start_with?('not:') }
end
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
@@ -487,8 +436,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
@@ -496,11 +445,19 @@ class PostsController < ApplicationController
end
end
def tagged_post_ids_for locale, name, reference: nil
internal, external = PostTagSearch.references(locale, { name:, reference: })
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)
def tagged_post_ids_for locale, name
posts_by_internal_tags =
Post
.joins(tags: :tag_names)
.where(tag_names: { language_code: locale.language_code, name: })
.select(:id)
posts_by_external_tags =
Post
.joins(:external_tags)
.where("CONCAT(external_tags.platform, ':', external_tags.name) = ?", name)
(posts_by_internal_tags + posts_by_external_tags).uniq(&:id)
end
def sync_post_tags! post, desired_tags, sections
@@ -771,8 +728,7 @@ class PostsController < ApplicationController
with_tagme: false,
with_no_deerjikist: false,
deny_deprecated: true,
with_sections: true,
created_by_user: current_user) => { tags:, sections: }
with_sections: true) => { tags:, sections: }
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
@@ -918,8 +874,7 @@ class PostsController < ApplicationController
Tag.normalise_tags!(locale, snapshot[:tag_names],
with_tagme: false,
deny_deprecated: true,
with_sections: true,
created_by_user: current_user) => { tags:, sections: }
with_sections: true) => { tags:, sections: }
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user,
language_code: locale.language_code)
-57
ファイルの表示
@@ -1,57 +0,0 @@
class SitemapEntriesController < ApplicationController
PAGE_SIZE = 1_000
RESOURCES = [
'posts', 'tags', 'deerjikists', 'materials',
'descriptions', 'tag_groups', 'theatres'].freeze
def index
locale = resolve_locale!
resources = RESOURCES.map { |resource| { resource:, count: entries(resource, locale).count } }
render json: { resources:, page_size: PAGE_SIZE }
end
def show
locale = resolve_locale!
resource = params[:resource]
return head :not_found unless RESOURCES.include?(resource)
page = Integer(params.fetch(:page, '1'), exception: false)
return head :bad_request unless page && page.positive?
scope = entries(resource, locale)
return head :not_found if page > [(scope.count.to_f / PAGE_SIZE).ceil, 1].max
ids = scope.order(:id).offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE).pluck(:id)
render json: { ids: }
end
private
def entries resource, locale
named_tags = TagName.where(language_code: locale.language_code, primary_flg: true)
.select(:tag_id)
case resource
when 'posts'
Post.all
when 'tags'
Tag.where(id: named_tags)
when 'deerjikists'
Tag.where(id: named_tags).where(id: Deerjikist.select(:tag_id))
when 'materials'
Material.all
when 'descriptions'
TagDescription.where(language_code: locale.language_code, tag_id: named_tags)
.where.not(body: [nil, ''])
.where(TagDescriptionVersion
.where('tag_description_versions.tag_id = tag_descriptions.tag_id')
.where('tag_description_versions.language_code = tag_descriptions.language_code')
.where('tag_description_versions.version_no = tag_descriptions.version_no')
.arel.exists)
when 'tag_groups'
TagGroup.joins(:versions)
.where('tag_group_versions.version_no = tag_groups.version_no')
when 'theatres'
Theatre.kept
end
end
end
+7 -12
ファイルの表示
@@ -1,18 +1,14 @@
class TagDescriptionsController < ApplicationController
def show
locale = resolve_locale!
description = TagDescription.find_by(tag_id: params[:id], language_code: locale.language_code)
description = TagDescription.find_by(tag_id: params[:id], language_code: 'ja')
return head :not_found unless description
render json: representation(description)
end
def show_by_title
locale = resolve_locale!
tag_name = TagName.find_by(
language_code: locale.language_code, name: params[:name].to_s.strip)
description = tag_name&.tag && TagDescription.find_by(
tag: tag_name.tag, language_code: locale.language_code)
tag_name = TagName.find_by(language_code: 'ja', name: params[:name].to_s.strip)
description = tag_name&.tag && TagDescription.find_by(tag: tag_name.tag, language_code: 'ja')
return head :not_found unless description
render json: representation(description)
@@ -25,19 +21,18 @@ class TagDescriptionsController < ApplicationController
body = params[:body].to_s
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
locale = resolve_locale!
tag = Tag.find_by(id: params[:id])
return head :not_found unless tag
description = TagDescription.find_by(tag:, language_code: locale.language_code)
description = TagDescription.find_by(tag:, language_code: 'ja')
if description
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user)
TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user)
description.reload
render json: representation(description)
else
TagDescriptionOperation.create!(locale, tag:, body:, created_by_user: current_user)
description = TagDescription.find_by!(tag:, language_code: locale.language_code)
TagDescriptionOperation.create!(tag:, body:, created_by_user: current_user)
description = TagDescription.find_by!(tag:, language_code: 'ja')
render json: representation(description), status: :created
end
rescue ActiveRecord::RecordInvalid => e
+6 -10
ファイルの表示
@@ -1,37 +1,33 @@
class TagGroupsController < ApplicationController
def index
locale = resolve_locale!
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id)
render json: TagGroupRepr.many(locale, groups.to_a)
render json: TagGroupRepr.many(groups.to_a)
end
def show
locale = resolve_locale!
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id])
return head :not_found unless group
render json: TagGroupRepr.one(locale, group)
render json: TagGroupRepr.one(group)
end
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
group = TagGroupOperation.create!(locale, **write_attributes, actor: current_user)
render json: TagGroupRepr.one(locale, group), status: :created
group = TagGroupOperation.create!(**write_attributes, actor: current_user)
render json: TagGroupRepr.one(group), status: :created
end
def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
group = TagGroup.find_by(id: params[:id])
return head :not_found unless group
TagGroupOperation.update!(locale, group:, **write_attributes, actor: current_user)
render json: TagGroupRepr.one(locale, group.reload)
TagGroupOperation.update!(group:, **write_attributes, actor: current_user)
render json: TagGroupRepr.one(group.reload)
end
private
+21 -67
ファイルの表示
@@ -10,7 +10,17 @@ class TagVersionsController < ApplicationController
offset = (page - 1) * limit
q = version_scope
q = TagVersion.joins(<<~SQL.squish)
LEFT JOIN
tag_versions prev
ON
prev.tag_id = tag_versions.tag_id
AND prev.version_no = tag_versions.version_no - 1
SQL
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
'prev.deprecated_at AS prev_deprecated_at',
'prev.aliases AS prev_aliases', 'prev.names AS prev_names',
'prev.parent_tag_ids AS prev_parent_tag_ids')
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
count = q.except(:select, :order, :limit, :offset).count
@@ -24,38 +34,9 @@ class TagVersionsController < ApplicationController
private
def version_scope
TagVersion.joins(<<~SQL.squish)
LEFT JOIN
tag_versions prev
ON
prev.tag_id = tag_versions.tag_id
AND prev.version_no = tag_versions.version_no - 1
SQL
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
'prev.deprecated_at AS prev_deprecated_at',
'prev.aliases AS prev_aliases', 'prev.names AS prev_names',
'prev.parent_tag_ids AS prev_parent_tag_ids')
end
def serialise_versions rows, language_code
rows = rows.to_a
tag_ids = rows.map(&:tag_id).uniq
current_names_by_tag_id = TagName.where(tag_id: tag_ids, language_code:, primary_flg: true)
.pluck(:tag_id, :name, :auto_generated).to_h do |id, name, automatic|
[id, { name:, auto_generated: automatic }]
end
user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
parent_ids = rows.flat_map do |row|
split_parent_tag_ids(row.parent_tag_ids) +
split_parent_tag_ids(row.attributes['prev_parent_tag_ids'])
end.uniq
parents_by_id = Tag.includes(
:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.where(id: parent_ids).index_by(&:id)
parent_json = parents_by_id.transform_values { TagRepr.base(_1, language_code:) }
rows.map do |row|
current_names = names_for_locale(row.names, language_code)
@@ -66,10 +47,18 @@ class TagVersionsController < ApplicationController
cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids)
prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids'])
all_parent_tag_ids = (cur_parent_tag_ids | prev_parent_tag_ids)
tags_by_id =
Tag
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
.where(id: all_parent_tag_ids)
.index_by(&:id)
parent_tags =
build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id)
.map do |h|
{ tag: parent_json.fetch(h[:tag_id]),
{ tag: TagRepr.base(tags_by_id[h[:tag_id]], language_code:),
type: h[:type] }
end
@@ -77,8 +66,6 @@ class TagVersionsController < ApplicationController
version_no: row.version_no,
event_type: row.event_type,
names: row.names,
name_changes: name_changes(row.names, row.attributes['prev_names']),
current_name_reference: current_names_by_tag_id[row.tag_id],
name: { current: current_names['name'], prev: previous_names['name'] },
category: { current: row.category, prev: row.attributes['prev_category'] },
deprecated_at: { current: row.deprecated_at&.iso8601,
@@ -110,44 +97,11 @@ class TagVersionsController < ApplicationController
def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?)
def names_for_locale names, language_code
values = snapshot_names(names)[language_code]
values.is_a?(Hash) ? values : {}
end
def snapshot_names names
values = names.is_a?(String) ? JSON.parse(names) : names
values.is_a?(Hash) ? values : {}
values&.fetch(language_code, {}) || {}
rescue JSON::ParserError
{}
end
def name_changes current_names, previous_names
current = snapshot_names(current_names)
previous = snapshot_names(previous_names)
(current.keys | previous.keys).sort.filter_map do |language|
current_name = language_snapshot(current[language])
previous_name = language_snapshot(previous[language])
next if current_name == previous_name
{ language_code: language, current: current_name, prev: previous_name }
end
end
def language_snapshot entry
return nil unless entry.is_a?(Hash)
aliases = Array(entry['aliases']).filter_map do |value|
value = { 'name' => value } if value.is_a?(String)
next unless value.is_a?(Hash) && value['name'].is_a?(String)
{ name: value['name'], script_code: value['script_code'] }
end
aliases = aliases.uniq.sort_by { [_1[:name], _1[:script_code].to_s] }
{ name: entry['name'],
script_code: entry['script_code'],
auto_generated: entry['auto_generated'],
aliases: }
end
def split_parent_tag_ids(values) = split_values(values).map(&:to_i)
end
+45 -37
ファイルの表示
@@ -175,8 +175,7 @@ class TagsController < ApplicationController
tags_by_id =
Tag
.joins(:tag_names)
.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
id: tag_ids)
.index_by(&:id)
@@ -239,8 +238,7 @@ class TagsController < ApplicationController
base =
Tag
.joins(:tag_names)
.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.includes(:tag_names, :materials, :tag_descriptions, parents: :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
@@ -284,8 +282,7 @@ class TagsController < ApplicationController
tag =
Tag
.joins(:tag_names)
.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
.find_by(id: params[:id],
tag_names: { language_code: locale.language_code, primary_flg: true })
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
@@ -305,9 +302,8 @@ class TagsController < ApplicationController
tag =
Tag
.joins(:tag_names)
.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.find_by(tag_names: { name:, language_code: locale.language_code })
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
platform, external_name = name.split(':', 2)
@@ -324,8 +320,7 @@ class TagsController < ApplicationController
tag =
Tag.joins(:tag_names)
.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.includes(:tag_names, :tag_descriptions, parents: :tag_descriptions)
.find_by(id: params[:id],
tag_names: { language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag
@@ -345,8 +340,7 @@ class TagsController < ApplicationController
tag =
Tag
.joins(:tag_names)
.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.includes(:tag_names, :tag_descriptions, parents: :tag_descriptions)
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag
@@ -365,8 +359,7 @@ class TagsController < ApplicationController
tag =
Tag
.joins(:tag_names)
.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.includes(:tag_names, :tag_descriptions, parents: :tag_descriptions)
.find_by(id: params[:id],
tag_names: { language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag
@@ -375,7 +368,6 @@ class TagsController < ApplicationController
return if performed?
ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock!
requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
@@ -432,8 +424,7 @@ class TagsController < ApplicationController
tag =
Tag
.joins(:tag_names)
.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag
@@ -779,15 +770,31 @@ class TagsController < ApplicationController
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
end
current_tag_name&.update!(primary_flg: false, auto_generated: false)
promoted_tag_name.update!(tag:, primary_flg: true, auto_generated: false)
tag.association(:tag_names).reset
current_tag_name.wiki_page&.update!(tag_name_id: promoted_tag_name.id)
current_tag_name.association(:wiki_page).reset
current_tag_name.update!(canonical_id: promoted_tag_name.id,
primary_flg: false, auto_generated: false)
promoted_tag_name.update!(tag:, canonical_id: nil, primary_flg: true, auto_generated: false)
TagName.where(tag:,
language_code: current_tag_name.language_code,
primary_flg: false)
.where.not(id: promoted_tag_name.id)
.find_each do |alias_tag_name|
alias_tag_name.update!(canonical_id: promoted_tag_name.id, primary_flg: false)
end
tag.update!(tag_name_id: promoted_tag_name.id)
current_tag_name.association(:wiki_page).reset
current_tag_name.association(:tag).reset
current_tag_name.reload.update!(canonical_id: promoted_tag_name.id, primary_flg: false)
return unless old_owner_tag && old_owner_tag != tag
record_tag_version!(old_owner_tag.reload, event_type: :update,
created_by_user: current_user,
language_code: promoted_tag_name.language_code)
language_code: current_tag_name.language_code)
end
def update_aliases! locale, tag, alias_names
@@ -795,15 +802,20 @@ class TagsController < ApplicationController
affected_tags = [tag]
primary_tag_name = TagName.find_by!(tag:,
language_code: locale.language_code,
primary_flg: true)
current_aliases = tag.tag_names.where(language_code: locale.language_code,
primary_flg: false).to_a
requested_aliases = TagName.where(
language_code: locale.language_code, name: alias_names)
.includes(:tag).index_by(&:name)
current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name)
affected_tags << alias_tag_name.tag
end
alias_names.each do |alias_name|
alias_tag_name = requested_aliases[alias_name]
alias_tag_name = TagName.find_by(language_code: locale.language_code, name: alias_name)
if alias_tag_name&.primary? && alias_tag_name.tag_id && alias_tag_name.tag_id != tag.id
alias_tag_name.errors.add(:name, 'その名前は既に使はれてゐます.')
raise ActiveRecord::RecordInvalid, alias_tag_name
@@ -811,8 +823,7 @@ class TagsController < ApplicationController
affected_tags << alias_tag_name&.tag
end
affected_tags = affected_tags.compact.uniq(&:id)
affected_tags.each do |affected_tag|
affected_tags.compact.uniq.each do |affected_tag|
TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user,
language_code: locale.language_code)
end
@@ -820,21 +831,19 @@ class TagsController < ApplicationController
current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name)
alias_tag_name.destroy!
alias_tag_name.update!(canonical_id: nil, tag_id: nil)
end
alias_names.each do |alias_name|
alias_tag_name = requested_aliases[alias_name] || TagName.new(
alias_tag_name = TagName.find_or_initialize_by(
language_code: locale.language_code,
name: alias_name)
alias_tag_name.script_code = locale.script_code
alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false)
alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false,
canonical_id: primary_tag_name.id)
end
affected_tags.each do |affected_tag|
# update_all records this Tag after its parent relationships are updated.
next if affected_tag.id == tag.id
affected_tags.compact.uniq.each do |affected_tag|
record_tag_version!(affected_tag, event_type: :update,
created_by_user: current_user,
language_code: locale.language_code)
@@ -844,8 +853,7 @@ class TagsController < ApplicationController
def update_parent_tags! locale, tag, parent_names
parent_tags = Tag.normalise_tags!(locale, parent_names,
with_tagme: false,
with_no_deerjikist: false,
created_by_user: current_user)
with_no_deerjikist: false)
old_parent_tags = tag.parents.to_a
+3 -33
ファイルの表示
@@ -1,69 +1,39 @@
class UsersController < ApplicationController
def create
if current_user
return render json: { code: current_user.inheritance_code,
user: current_user.slice(:id, :name, :inheritance_code, :role) }
end
user = nil
User.transaction do
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
attach_ip_address!(user)
end
establish_user_session(user)
render json: { code: user.inheritance_code,
user: user.slice(:id, :name, :inheritance_code, :role) },
status: :created
end
def verify
user = if bool?(:restore) && current_user
current_user
else
User.find_by(inheritance_code: params[:code])
end
user = User.find_by(inheritance_code: params[:code])
return render json: { valid: false } unless user
return head :forbidden if user.banned?
attach_ip_address!(user)
establish_user_session(user)
render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
end
def show_session
return head :unauthorized unless current_user
render json: current_user.slice(:id, :name, :role)
end
def csrf
response.headers['Cache-Control'] = 'private, no-store'
render json: { token: form_authenticity_token }
end
def destroy_session
reset_session
response.headers['Cache-Control'] = 'private, no-store'
head :no_content
end
def renew
return head :unauthorized unless current_user
user = current_user
user.inheritance_code = SecureRandom.uuid
user.save!
establish_user_session(user)
render json: { code: user.inheritance_code }
end
def me
user = current_user
return head :unauthorized unless user
user = User.find_by(inheritance_code: params[:code])
return head :not_found unless user
attach_ip_address!(user)
render json: user.slice(:id, :name, :inheritance_code, :role)
end
+115 -120
ファイルの表示
@@ -1,15 +1,12 @@
class WikiPagesController < ApplicationController
# 互換性のための Facade API
rescue_from TagDescriptionOperation::Conflict, with: :render_wiki_conflict
rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict
def index
locale = resolve_locale!
# 互換性のための化石リソース
title = params[:title].to_s.strip
descriptions =
TagDescription
.where(language_code: locale.language_code)
TagDescription.where(language_code: 'ja')
.includes(tag: :tag_names)
descriptions = descriptions.order(updated_at: :desc, id: :asc)
@@ -18,14 +15,13 @@ class WikiPagesController < ApplicationController
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions =
descriptions.joins(tag: :tag_names)
.where(tag_names: { language_code: locale.language_code, primary_flg: true })
.where(tag_names: { language_code: 'ja', primary_flg: true })
.where('tag_names.name LIKE ?', pattern).limit(20)
tag_groups = tag_groups.where(
'CONCAT(?, name) LIKE ? OR CONCAT(?, name) LIKE ?',
TagGroupRepr::PREFIX, pattern, 'tag_group:', pattern).limit(20)
'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20)
end
json = descriptions.map { |description| WikiPageRepr.tag_description(locale, description) }
json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
json.sort_by! do |row|
[-row['updated_at'].to_f, row.fetch('resource_type'), row.fetch('id')]
@@ -34,87 +30,48 @@ class WikiPagesController < ApplicationController
render json:
end
def autolink_candidates
locale = resolve_locale!
language = params.fetch(:language, locale.language_code)
language = 'ja' if language == 'und'
return head :not_found unless language.is_a?(String)
return head :not_found unless Language.exists?(code: language)
names = TagDescription.joins(tag: :tag_names)
.where(language_code: locale.language_code, tag_names: { language_code: language })
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
candidates = names.map do |tag_id, description_id, name|
WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:)
end
candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name|
WikiPageRepr.autolink_group(locale, id:, name:, language:)
end)
render json: candidates
end
def show
locale = resolve_locale!
reference = TagDescription.find_by(id: params[:id])
tag_description = reference && TagDescription.find_by(
tag_id: reference.tag_id, language_code: locale.language_code)
tag_description = TagDescription.find_by(id: params[:id])
return render_wiki_page_by_tag_description locale, tag_description if tag_description
return head :not_found if reference
return render_wiki_page_by_tag_description tag_description if tag_description
tag_group = TagGroup.find_by(id: params[:id])
return render_tag_group locale, tag_group if tag_group
return render_tag_group tag_group if tag_group
head :not_found
render_wiki_page_or_404 nil
end
def show_by_title
locale = resolve_locale!
title = params[:title].to_s.strip
if (name = TagGroupRepr.name_from_title(title))
if title.start_with?(TagGroupRepr::PREFIX)
name = title.delete_prefix(TagGroupRepr::PREFIX)
tag_group = TagGroup.find_by(name:)
return render_tag_group locale, tag_group if tag_group
return render_tag_group tag_group if tag_group
return head :not_found
end
tag_description = tag_description_by_title(locale, title)
tag_description = tag_description_by_title(title)
return render_wiki_page_by_tag_description locale, tag_description if tag_description
return render_wiki_page_by_tag_description tag_description if tag_description
head :not_found
end
def tag_reference
locale = resolve_locale!
description = TagDescription.find_by(id: params[:id])
return head :not_found unless description
tag = Tag.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
.find(description.tag_id)
render json: TagRepr.base(tag, language_code: locale.language_code)
render_wiki_page_or_404 nil
end
def exists
locale = resolve_locale!
if TagDescription.exists?(id: params[:id], language_code: locale.language_code)
return head :no_content
end
return head :no_content if TagDescription.exists?(id: params[:id])
return head :no_content if TagGroup.exists?(id: params[:id])
head :not_found
end
def exists_by_title
locale = resolve_locale!
title = params[:title].to_s.strip
if (name = TagGroupRepr.name_from_title(title))
exists = TagGroup.exists?(name:)
if title.start_with?(TagGroupRepr::PREFIX)
exists = TagGroup.exists?(name: title.delete_prefix(TagGroupRepr::PREFIX))
return head exists ? :no_content : :not_found
end
return head :no_content if tag_description_by_title(locale, title)
return head :no_content if tag_description_by_title(title)
head :not_found
end
@@ -123,17 +80,15 @@ class WikiPagesController < ApplicationController
id = params[:id]
return render_bad_request('id は必須です.') if id.blank?
locale = resolve_locale!
from = params[:from].presence
to = params[:to].presence
description = TagDescription.find_by(id:, language_code: locale.language_code)
description = TagDescription.find_by(id:)
if description
versions = description_versions(description)
from_rev = from && versions.find(from)
to_rev = to ? versions.find(to) : versions.find_by!(version_no: description.version_no)
resource = WikiPageRepr.tag_description(locale, description)
resource = WikiPageRepr.tag_description(description)
else
tag_group = TagGroup.find_by(id:)
if tag_group
@@ -141,8 +96,8 @@ class WikiPagesController < ApplicationController
to_rev = to ? group_version(tag_group, to) : current_group_version(tag_group)
return head :not_found if (from && !from_rev) || !to_rev
from_body = from_rev && render_group_version(locale, from_rev)
to_body = render_group_version(locale, to_rev)
from_body = from_rev && render_group_version(from_rev)
to_body = render_group_version(to_rev)
resource = { 'id' => tag_group.id,
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
'deprecated_at' => nil }
@@ -176,31 +131,30 @@ class WikiPagesController < ApplicationController
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
locale = resolve_locale!
if (name = TagGroupRepr.name_from_title(title))
parsed = TagGroupMarkdownParser.parse(locale, body:, group: nil)
group = TagGroupOperation.create!(
locale, name:, **parsed.except(:compatibility_body), actor: current_user,
require_compatibility: true)
if title.start_with?(TagGroupRepr::PREFIX)
name = title.delete_prefix(TagGroupRepr::PREFIX)
parsed = TagGroupMarkdownParser.parse(body:, group: nil)
group = TagGroupOperation.create!(name:, **parsed, actor: current_user,
require_compatibility: true)
version = current_group_version(group)
return render_group_representation(TagGroupRepr.one(locale, group), version,
return render_group_representation(TagGroupRepr.one(group), version,
body: parsed.fetch(:compatibility_body),
status: :created)
end
description =
page =
if title.include?(':')
return render_unprocessable_entity field: :title
else
tag_name = TagName.find_by(language_code: locale.language_code, name: title)
tag_name = TagName.find_by(language_code: 'ja', name: title)
return render_unprocessable_entity field: :title unless tag_name&.tag
TagDescriptionOperation.create!(
locale,
tag: tag_name.tag, body:, created_by_user: current_user, message:)
end
render json: WikiPageRepr.tag_description(locale, description), status: :created
description = TagDescription.find_by!(tag_id: page.tag_name.tag_id, language_code: 'ja')
render json: WikiPageRepr.tag_description(description), status: :created
rescue ActiveRecord::RecordInvalid => e
render_validation_error e.record
rescue ActiveRecord::RecordNotUnique
@@ -219,35 +173,32 @@ class WikiPagesController < ApplicationController
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
locale = resolve_locale!
group = TagGroup.find_by(id: params[:id])
if (name = TagGroupRepr.name_from_title(title))
if title.start_with?(TagGroupRepr::PREFIX)
return head :not_found unless group
parsed = TagGroupMarkdownParser.parse(locale, body:, group:)
TagGroupOperation.update!(
locale, group:, name:, **parsed.except(:compatibility_body), actor: current_user,
require_compatibility: true)
name = title.delete_prefix(TagGroupRepr::PREFIX)
parsed = TagGroupMarkdownParser.parse(body:, group:)
TagGroupOperation.update!(group:, name:, **parsed, actor: current_user,
require_compatibility: true)
return head :ok
end
return render_unprocessable_entity field: :title if title.include?(':')
description = TagDescription.find_by(id: params[:id], language_code: locale.language_code)
description = TagDescription.find_by(id: params[:id])
unless description
return render_unprocessable_entity field: :title if group
return render_unprocessable_entity field: :title if group || WikiPage.exists?(id: params[:id])
return head :not_found
end
tag = description.tag
tag_name = tag.tag_name(locale.language_code)
return render_unprocessable_entity field: :title unless tag_name
return render_unprocessable_entity field: :title if tag_name.name.include?(':')
tag_name = TagDescriptionOperation.compatibility_tag_name(tag)
if tag_name.name.include?(':') || (group && title != tag_name.name)
return render_unprocessable_entity field: :title
end
base_revision_id = params[:base_revision_id].presence
ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock!
old_title = tag_name.name
if title != old_title
@@ -257,7 +208,7 @@ class WikiPagesController < ApplicationController
tag_name.update!(name: title) if title != old_title
message = params[:message].presence
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user,
TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user,
message:, base_revision_id:)
if title != old_title
@@ -276,12 +227,10 @@ class WikiPagesController < ApplicationController
end
def changes
locale = resolve_locale!
id = params[:id].presence
description = id && TagDescription.find_by(id:, language_code: locale.language_code)
description = id && TagDescription.find_by(id:)
if description
return render json: description_changes(locale, TagDescription.where(id: description.id))
return render json: description_changes(TagDescription.where(id: description.id))
end
tag_group = id && TagGroup.find_by(id:)
@@ -292,8 +241,7 @@ class WikiPagesController < ApplicationController
json = []
unless id
json.concat(tag_group_changes(TagGroupVersion.all))
json.concat(description_changes(locale,
TagDescription.where(language_code: locale.language_code)))
json.concat(description_changes(TagDescription.where(language_code: 'ja')))
json = json.each_with_index.sort_by do |row, index|
[-row.fetch(:timestamp).to_f, index]
end.map(&:first)
@@ -303,7 +251,27 @@ class WikiPagesController < ApplicationController
private
def render_tag_group locale, group
def legacy_group_page? page
return false unless page
return true if page.title.start_with?(TagGroupRepr::PREFIX)
prefix = ApplicationRecord.sanitize_sql_like(TagGroupRepr::PREFIX)
page.wiki_versions.where('title LIKE ?', "#{ prefix }%").exists?
end
def legacy_update_identified? page, title, group
return false unless page
return true if page.title == title
return false if title == group.name
namespace = page.title.split(':', 2)
return true if namespace.size == 2 && title.start_with?("#{ namespace.first }:")
base = params[:base_revision_id].presence
base && page.wiki_revisions.exists?(id: base)
end
def render_tag_group group
version = if params[:version].present?
group_version(group, params[:version])
else
@@ -311,17 +279,16 @@ class WikiPagesController < ApplicationController
end
return head :not_found unless version
representation =
if params[:version].present?
TagGroupRepr.historical(locale, version)
else
TagGroupRepr.one(locale, group)
end
representation = if params[:version].present?
TagGroupRepr.historical(version)
else
TagGroupRepr.one(group)
end
render_group_representation(representation, version)
end
def render_group_version locale, version
representation = TagGroupRepr.historical(locale, version)
def render_group_version version
representation = TagGroupRepr.historical(version)
TagGroupMarkdownRenderer.render(representation)
end
@@ -390,7 +357,28 @@ class WikiPagesController < ApplicationController
end
end
def render_wiki_page_by_tag_description locale, description
def render_wiki_page_or_404 page
return head :not_found unless page
rev = find_revision(page)
return head :not_found unless rev
if rev.redirect?
return (
redirect_to wiki_page_by_title_path(title: rev.redirect_page.title),
status: :moved_permanently)
end
body = rev.body
revision_id = rev.id
pred = page.pred_revision_id(revision_id)
succ = page.succ_revision_id(revision_id)
updated_at = rev.created_at
render json: WikiPageRepr.base(page).merge(body:, revision_id:, pred:, succ:, updated_at:)
end
def render_wiki_page_by_tag_description description
versions = description_versions(description)
historical = params[:version].present?
rev = historical ? versions.find_by(id: params[:version]) :
@@ -401,7 +389,7 @@ class WikiPagesController < ApplicationController
succ = versions.find_by(version_no: rev.version_no + 1)&.id
body = historical ? rev.body : description.body
updated_at = historical ? rev.created_at : description.updated_at
render json: WikiPageRepr.tag_description(locale, description).merge(
render json: WikiPageRepr.tag_description(description).merge(
body:, revision_id: rev.id, pred:, succ:, updated_at:)
end
@@ -410,14 +398,13 @@ class WikiPagesController < ApplicationController
language_code: description.language_code)
end
def tag_description_by_title locale, title
def tag_description_by_title title
TagDescription.joins(tag: :tag_names)
.find_by(language_code: locale.language_code,
tag_names: { language_code: locale.language_code, name: title })
.find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title })
end
def description_changes locale, descriptions
versions = TagDescriptionVersion.where(language_code: locale.language_code,
def description_changes descriptions
versions = TagDescriptionVersion.where(language_code: 'ja',
tag_id: descriptions.select(:tag_id))
.order(id: :desc).limit(200).to_a
return [] if versions.empty?
@@ -426,7 +413,7 @@ class WikiPagesController < ApplicationController
.includes(tag: :tag_names).index_by(&:tag_id)
users = User.where(id: versions.map(&:created_by_user_id)).index_by(&:id)
predecessors = TagDescriptionVersion.where(
language_code: locale.language_code, tag_id: by_tag.keys,
language_code: 'ja', tag_id: by_tag.keys,
version_no: versions.map { |version| version.version_no - 1 })
.pluck(:tag_id, :version_no, :id)
.to_h { |tag_id, version_no, id| [[tag_id, version_no], id] }
@@ -436,7 +423,7 @@ class WikiPagesController < ApplicationController
{ revision_id: version.id,
pred: predecessors[[version.tag_id, version.version_no - 1]],
succ: nil,
wiki_page: { id: description.id, title: description.tag.name(locale.language_code),
wiki_page: { id: description.id, title: description.tag.name('ja'),
deprecated_at: description.tag.deprecated_at },
user: user && { id: user.id, name: user.name },
kind: 'content',
@@ -445,6 +432,14 @@ class WikiPagesController < ApplicationController
end
end
def find_revision page
if params[:version].present?
page.wiki_revisions.find_by(id: params[:version])
else
page.current_revision
end
end
def render_wiki_conflict err
render json: { error: 'conflict', message: err.message }, status: :conflict
end
+1 -1
ファイルの表示
@@ -1,5 +1,5 @@
class Language < ApplicationRecord
has_many :locales, foreign_key: :language_code, primary_key: :code
enum :code, ja: 'ja', ain: 'ain'
enum :code, ja: 'ja'
end
+14 -25
ファイルの表示
@@ -1,38 +1,27 @@
class Locale < ApplicationRecord
attr_accessor :certified_tag_names_path
around_save :save_with_tag_names!, if: :will_generate_tag_names?
after_create_commit :generate_tag_names!
belongs_to :language, foreign_key: :language_code, primary_key: :code
belongs_to :script, foreign_key: :script_code, primary_key: :code
# Enum deserialisation uses the first key for each stored value.
# Keep the legacy key for existing predicates, scopes and query arguments.
enum :code, { ja: 'ja', 'ain-Kana' => 'ain-Kana', ain_kana: 'ain-Kana' }
def code= value
super(self.class.codes.fetch(value.to_s, value))
end
enum :code, ja: 'ja', en: 'en', fr: 'fr'
def self.nipponese = Locale.find('ja')
private
def will_generate_tag_names?
deprecated_at.nil? && (will_save_change_to_code? || will_save_change_to_deprecated_at?)
end
def save_with_tag_names!
completed = self.class.transaction(requires_new: true) do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
saved = yield
raise ActiveRecord::RecordNotSaved, 'Locale save was halted' unless saved
TagNameCompletion.for_locale!(self)
true
end
unless completed
raise ActiveRecord::RecordNotSaved, 'Locale tag completion was rolled back'
def generate_tag_names!
ApplicationRecord.transaction do
tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id)
Tag.where.not(id: tag_ids).find_each do
TagName.create!(tag_id: _1.id,
language_code:,
name: TagName.generate_name(self, _1, _1.tag_name('ja')),
script_code:,
primary_flg: true,
auto_generated: true,
canonical_id: nil)
end
end
end
end
-5
ファイルの表示
@@ -1,11 +1,6 @@
class PostVersion < ApplicationRecord
include VersionRecord
scope :with_previous_version, -> {
joins('LEFT JOIN post_versions prev ON prev.post_id = post_versions.post_id ' +
'AND prev.version_no = post_versions.version_no - 1')
}
belongs_to :post
belongs_to :parent, class_name: 'Post', optional: true
+1 -7
ファイルの表示
@@ -1,11 +1,5 @@
class Script < ApplicationRecord
has_many :locales, foreign_key: :script_code, primary_key: :code
# Canonical keys come first so reads and plucks retain ISO 15924 spelling.
# Legacy keys preserve the existing enum predicates, scopes and queries.
enum :code, { 'Jpan' => 'Jpan', 'Kana' => 'Kana', jpan: 'Jpan', kana: 'Kana' }
def code= value
super(self.class.codes.fetch(value.to_s, value))
end
enum :code, Jpan: 'Jpan'
end
+48 -29
ファイルの表示
@@ -91,6 +91,7 @@ class Tag < ApplicationRecord
end
end
def name(language_code) = tag_name(language_code)&.name
def wiki_page = tag_name('ja')&.wiki_page
def system?
tag_name('ja')&.name.in?(SYSTEM_TAG_NAMES)
@@ -110,12 +111,7 @@ class Tag < ApplicationRecord
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
def snapshot_aliases language_code
if tag_names.loaded?
return tag_names.select { _1.language_code == language_code && !_1.primary? }
.sort_by(&:name).map(&:name)
end
def snapshot_aliases(language_code)
tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name)
end
@@ -142,8 +138,7 @@ class Tag < ApplicationRecord
with_tagme: true,
with_no_deerjikist: true,
deny_deprecated: false,
with_sections: false,
created_by_user: nil
with_sections: false
if tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError
end
@@ -172,8 +167,7 @@ class Tag < ApplicationRecord
name = TagName.canonicalise(locale, name).first
find_or_create_by_tag_name!(locale, name, category: (cat || :general),
created_by_user:).tap do |tag|
find_or_create_by_tag_name!(locale, name, category: (cat || :general)).tap do |tag|
if deny_deprecated && tag.deprecated?
raise DeprecatedTagNormalisationError, [tag.name(locale.language_code)]
end
@@ -261,28 +255,28 @@ class Tag < ApplicationRecord
[left_end_ms, right_end_ms].max
end
def find_or_create_by_tag_name! locale, name, category:, created_by_user: nil
def find_or_create_by_tag_name! locale, name, category:
language_code = locale.language_code
name = name.to_s.strip
ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tn = TagName.lock.find_by(language_code:, name:)
next tn.tag if tn
tn = TagName.find_or_create_by!(language_code:, name:) do
_1.script_code = locale.script_code
_1.primary_flg = true
end
tag = Tag.new(category:)
tag.tag_names.build(language_code:, name:, script_code: locale.script_code,
primary_flg: true, auto_generated: false)
TagNameCompletion.for_tag!(tag, created_by_user:)
tag = tn.tag
next tag if tag
tag = Tag.create!(tag_name_id: tn.id, category:)
tn.update!(tag:, primary_flg: true, canonical_id: nil, auto_generated: false)
generate_tag_names!(locale, tag, tn)
tag.association(:tag_names).reset
tag
end
rescue ActiveRecord::RecordNotUnique
existing = TagName.find_by(language_code:, name:)
raise unless existing
existing.tag
retry
end
def merge_tags! target_tag, source_tags, created_by_user: nil
@@ -309,18 +303,20 @@ class Tag < ApplicationRecord
end
end
if source_tag.tag_descriptions.exists?
source_tag.errors.add(:base, :invalid)
raise ActiveRecord::RecordInvalid, source_tag
source_tag_name = source_tag.tag_name('ja')
if source_tag_name.wiki_page.present?
raise ActiveRecord::RecordInvalid.new(source_tag_name)
end
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
target_languages = target_tag.tag_names.where(primary_flg: true).pluck(:language_code)
source_tag.tag_names.where(language_code: target_languages)
.update_all(primary_flg: false, auto_generated: false, updated_at: Time.current)
source_tag.tag_names.update_all(tag_id: target_tag.id, updated_at: Time.current)
source_tag.tag_names.update_all(tag_id: target_tag.id, primary_flg: false,
updated_at: Time.current)
source_tag.destroy!
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current)
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
end
@@ -362,6 +358,29 @@ class Tag < ApplicationRecord
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
end
private
def generate_tag_names! locale, tag, base_tag_name
ApplicationRecord.transaction do
locales =
Locale
.where(deprecated_at: nil)
.where.not(language_code: locale.language_code)
.order(:language_code, :created_at, :script_code)
.uniq { _1.language_code }
locales.each do |locale|
language_code = locale.language_code
script_code = locale.script_code
TagName.create!(tag_id: tag.id,
language_code:,
name: TagName.generate_name(locale, tag, base_tag_name),
script_code:,
primary_flg: true,
auto_generated: true,
canonical_id: nil)
end
end
end
end
private
+1 -1
ファイルの表示
@@ -1,3 +1,3 @@
class TagDescriptionVersion < ApplicationRecord
before_create { SharedResourceIdAllocator.assign!(self) }
;
end
+1 -10
ファイルの表示
@@ -1,6 +1,5 @@
class TagGroup < ApplicationRecord
before_create { SharedResourceIdAllocator.assign!(self) }
before_destroy :delete_current_structure!
belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true
@@ -12,16 +11,8 @@ class TagGroup < ApplicationRecord
has_many :placements, -> { order(:position) },
class_name: 'TagGroupPlacement', dependent: :delete_all
has_many :versions, -> { order(:version_no) },
class_name: 'TagGroupVersion'
class_name: 'TagGroupVersion', dependent: :delete_all
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
validates :version_no, numericality: { only_integer: true, greater_than: 0 }
private
def delete_current_structure!
placements.reorder(position: :desc).each(&:delete)
tag_group_tags.delete_all
tag_group_groups.delete_all
end
end
-2
ファイルの表示
@@ -1,8 +1,6 @@
class TagGroupVersion < ApplicationRecord
include VersionRecord
before_create { SharedResourceIdAllocator.assign!(self) }
belongs_to :tag_group
scope :ordered, -> { order(:version_no) }
+16 -25
ファイルの表示
@@ -1,12 +1,14 @@
class TagName < ApplicationRecord
belongs_to :language, foreign_key: :language_code, primary_key: :code
belongs_to :tag
belongs_to :tag, optional: true
has_one :wiki_page
validates :name, presence: true,
length: { maximum: 255 },
uniqueness: { scope: :language_code }
validate :alias_name_must_not_have_prefix
validate :alias_must_not_have_wiki_page
validate :name_must_be_sanitised
validate :name_mustnt_start_with_nico
validate :generated_name_must_be_reserved
@@ -19,37 +21,22 @@ class TagName < ApplicationRecord
tns = TagName.where(language_code: locale.language_code, name: names).index_by(&:name)
primary_names = TagName.where(
language_code: locale.language_code, tag_id: tns.values.map(&:tag_id),
primary_flg: true).pluck(:tag_id, :name).to_h
names.map { |name|
tag_name = tns[name]
if !tag_name || tag_name.primary?
if !tag_name || tag_name.primary? || tag_name.tag_id.nil?
name
else
primary_names[tag_name.tag_id] || name
TagName.find_by(language_code: locale.language_code,
tag_id: tag_name.tag_id,
primary_flg: true)&.name || name
end
}.uniq
end
def self.generate_name locale, tag, base_tag_name
fallback = "Tag_##{ tag.id }"
name =
case [locale.language_code, base_tag_name&.language_code]
when ['ain', 'ja']
TagNameTranscription.ainu(base_tag_name.name)
when ['ja', 'ain']
base_tag_name.name
else
return fallback
end
candidate = new(tag:, name:, language_code: locale.language_code,
script_code: locale.script_code,
primary_flg: true, auto_generated: true)
candidate.valid? ? name : fallback
rescue TagNameTranscription::Unconvertible
fallback
# TODO: 言語ごとの自動命名ロジック完成したら書く.
"Tag_##{ tag.id }"
end
private
@@ -66,9 +53,13 @@ class TagName < ApplicationRecord
end
end
def name_must_be_sanitised
return if auto_generated && name == "Tag_##{ tag_id }"
def alias_must_not_have_wiki_page
if !(primary?) && wiki_page
errors.add :primary_flg, 'Wiki 参照がある名前はエーリアスになれません.'
end
end
def name_must_be_sanitised
if name? && name != TagNameSanitisationRule.sanitise(name)
errors.add :name, '名前に使用できない文字が含まれてゐます.'
end
+26 -25
ファイルの表示
@@ -16,36 +16,37 @@ class TagNameSanitisationRule < ApplicationRecord
def apply!
Language.find_each do |language|
TagName.where(language:).find_each do |candidate|
TagName.where(language:).find_each do |tn|
name = sanitise(tn.name)
next if name == tn.name
TagName.transaction do
tn = TagName.find_by(id: candidate.id)
next unless tn
name = sanitise(tn.name)
next if name == tn.name
existing_tn = TagName.find_by(language:, name:)
source_tag = tn.tag
target_tag = existing_tn ? existing_tn.tag : source_tag
[source_tag, target_tag].uniq(&:id).sort_by(&:id).each(&:lock!)
TagVersioning.ensure_snapshot!(source_tag, created_by_user: nil)
if existing_tn && target_tag != source_tag
Tag.merge_tags!(target_tag, source_tag)
tn.reload.destroy!
elsif existing_tn
if tn.primary?
existing_tn.destroy!
tn.update_columns(name:, updated_at: Time.current)
else
tn.destroy!
if existing_tn
unless existing_tn.primary_flg
existing_tn = TagName.find_by!(language:,
tag_id: existing_tn.tag_id,
primary_flg: true)
end
else
# TagName 側の自動サニタイズを回避
tn.update_columns(name:, updated_at: Time.current)
next if existing_tn.id == tn.id
existing_tag = existing_tn.tag
source_tag = tn.tag
if existing_tag
Tag.merge_tags!(existing_tag, source_tag) if tn.tag
elsif source_tag
tn.update_columns(tag_id: nil, updated_at: Time.current)
source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current)
existing_tn.update_columns(tag_id: source_tag.id, updated_at: Time.current)
end
tn.destroy!
next
end
TagVersioning.record!(target_tag, event_type: :update, created_by_user: nil)
# TagName 側の自動サニタイズを回避
tn.update_columns(name:, updated_at: Time.current)
end
end
end
+4 -4
ファイルの表示
@@ -13,10 +13,10 @@ class User < ApplicationRecord
has_many :ip_addresses, through: :user_ips
has_many :user_post_views, dependent: :destroy
has_many :viewed_posts, through: :user_post_views, source: :post
has_many :created_tag_descriptions, class_name: 'TagDescription',
foreign_key: :created_by_user_id, dependent: :nullify
has_many :updated_tag_descriptions, class_name: 'TagDescription',
foreign_key: :updated_by_user_id, dependent: :nullify
has_many :created_wiki_pages,
class_name: 'WikiPage', foreign_key: :created_user_id, dependent: :nullify
has_many :updated_wiki_pages,
class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify
def viewed?(post) = user_post_views.exists?(post_id: post.id)
+15
ファイルの表示
@@ -0,0 +1,15 @@
class WikiLine < ApplicationRecord
has_many :wiki_revision_lines, dependent: :restrict_with_exception
validates :sha256, presence: true, uniqueness: true, length: { is: 64 }
validates :body, presence: true
def self.upsert_by_body! body
sha = Digest::SHA256.hexdigest(body)
now = Time.current
upsert(sha256: sha, body:, created_at: now, updated_at: now)
find_by!(sha256: sha)
end
end
+58
ファイルの表示
@@ -0,0 +1,58 @@
require 'set'
class WikiPage < ApplicationRecord
include MyDiscard
has_many :wiki_revisions, dependent: :destroy
belongs_to :created_user, class_name: 'User'
belongs_to :updated_user, class_name: 'User'
has_many :redirected_from_revisions,
class_name: 'WikiRevision',
foreign_key: :redirect_page_id,
dependent: :nullify
has_many :wiki_versions
attribute :version_no, :integer, default: 1
belongs_to :tag_name
validates :tag_name, presence: true
validates :body, presence: true
def title = tag_name.name
def deprecated_at = tag_name.tag&.deprecated_at
def title= val
(self.tag_name ||= build_tag_name).name = val
end
def current_revision = wiki_revisions.order(id: :desc).first
def resolve_redirect limit: 10
page = self
visited = Set.new
limit.times do
return page if visited.include?(page.id)
visited.add(page.id)
rev = page.current_revision
return page if !(rev&.redirect?) || !(rev.redirect_page)
page = rev.redirect_page
end
page
end
def pred_revision_id revision_id
wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id)
end
def succ_revision_id revision_id
wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id)
end
end
+55
ファイルの表示
@@ -0,0 +1,55 @@
class WikiRevision < ApplicationRecord
belongs_to :wiki_page
belongs_to :base_revision, class_name: 'WikiRevision', optional: true
belongs_to :created_user, class_name: 'User'
belongs_to :redirect_page, class_name: 'WikiPage', optional: true
has_many :wiki_revision_lines, dependent: :delete_all
has_many :wiki_lines, through: :wiki_revision_lines
enum :kind, content: 0, redirect: 1
validates :kind, presence: true
validates :lines_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :tree_sha256, length: { is: 64 }, allow_nil: true
validate :kind_consistency
def body
return unless content?
wiki_revision_lines
.includes(:wiki_line)
.order(:position)
.map { |rev| rev.wiki_line.body }
.join("\n")
end
private
def kind_consistency
if content?
if tree_sha256.blank?
errors.add(:tree_sha256, '種類がページの場合は必須です.')
end
if redirect_page_id.present?
errors.add(:redirect_page_id, '種類がページの場合は空である必要があります.')
end
end
if redirect?
if redirect_page_id.blank?
errors.add(:redirect_page_id, '種類がリダイレクトの場合は必須です.')
end
if tree_sha256.present?
errors.add(:tree_sha256, '種類がリダイレクトの場合は空である必要があります.')
end
if lines_count.to_i > 0
errors.add(:lines_count, '種類がリダイレクトの場合は 0 である必要があります.')
end
end
end
end
+8
ファイルの表示
@@ -0,0 +1,8 @@
class WikiRevisionLine < ApplicationRecord
belongs_to :wiki_revision
belongs_to :wiki_line
validates :position, presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :position, uniqueness: { scope: :wiki_revision_id }
end
+8
ファイルの表示
@@ -0,0 +1,8 @@
class WikiVersion < ApplicationRecord
include VersionRecord
belongs_to :wiki_page
validates :title, presence: true
validates :body, presence: true
end
-103
ファイルの表示
@@ -1,103 +0,0 @@
# Counts uses recorded values only; history/diff endpoints retain their own contract.
class CountsVersionRepr
def self.many(rows, resource:, language_code:, at: nil, inclusive: true)
new(rows.to_a, resource:, language_code:, at:, inclusive:).serialise
end
def initialize(rows, resource:, language_code:, at:, inclusive:)
@rows = rows
@at = at
@inclusive = inclusive
@resource = resource
@language = language_code
@users = User.where(id: rows.map(&:created_by_user_id).compact).pluck(:id, :name).to_h
@languages = master_names(Language)
@scripts = master_names(Script)
@tag_versions = {}
load_tag_versions if resource == 'posts'
end
def serialise
@rows.map do |row|
resource_id = row.public_send(@resource == 'posts' ? :post_id : :tag_id)
common = { id: row.id, resource_id:,
version_no: row.version_no, is_latest: row.attributes['counts_latest'].to_i == 1,
created_at: row.created_at.iso8601,
created_by_user: row.created_by_user_id &&
{ id: row.created_by_user_id, name: @users[row.created_by_user_id] } }
common.merge(@resource == 'posts' ? post(row) : tag(row))
end
end
private
def master_names(model)
model.select('code AS counts_code', :name).to_h do |record|
[record.attributes['counts_code'], record.name]
end
end
def json(value)
value.is_a?(String) ? JSON.parse(value) : value
end
def load_tag_versions
pairs = @rows.flat_map { Array(json(_1.tags_json)) }
.filter_map { |entry| [entry['tag_id'], entry['version_no']] if entry['tag_id'] }
.uniq
pairs.each_slice(500) do |batch|
placeholders = batch.map { '(?, ?)' }.join(', ')
scope = TagVersion.all
if @at
scope = scope.where("created_at #{ @inclusive ? '<=' : '<' } ?", @at)
end
scope.where(["(tag_id, version_no) IN (#{ placeholders })", *batch.flatten])
.select(:tag_id, :version_no, :name, :category, :names).each do |version|
@tag_versions[[version.tag_id, version.version_no]] = version
end
end
end
def names(value)
(json(value) || {}).sort.map do |language, entry|
aliases = entry.fetch('aliases', []).map do |alias_entry|
alias_entry.merge('script_name' => @scripts[alias_entry['script_code']])
end
{ language_code: language, language_name: @languages[language],
script_code: entry['script_code'], script_name: @scripts[entry['script_code']],
name: entry['name'], auto_generated: entry['auto_generated'], aliases: }
end
end
def post(row)
snapshots = Array(json(row.tags_json)).map do |entry|
version = @tag_versions[[entry['tag_id'], entry['version_no']]]
{ id: entry['tag_id'] || entry['external_tag_id'],
kind: entry['tag_id'] ? 'internal' : 'external',
tag_id: entry['tag_id'], external_tag_id: entry['external_tag_id'],
version_no: entry['version_no'], name: entry['name'] || version&.name,
category: entry['category'] || version&.category,
sections: entry.fetch('sections', []), names: version ? names(version.names) : [] }
end
{ title: row.title, url: row.url, thumbnail_base: row.thumbnail_base,
tags: snapshots, parents: row.parent_post_ids.to_s.split.map { { id: _1.to_i } },
video_ms: row.video_ms,
original_created_from: row.original_created_from&.iso8601,
original_created_before: row.original_created_before&.iso8601 }
end
def tag(row)
recorded_names = names(row.names)
selected = recorded_names.find { _1[:language_code] == @language }
aliases = recorded_names.flat_map do |entry|
entry[:aliases].map {
_1.merge('language_code' => entry[:language_code],
'language_name' => entry[:language_name])
}
end
{ name: selected&.fetch(:name) || row.name, names: recorded_names,
category: row.category, aliases:,
parents: row.parent_tag_ids.to_s.split.map { { id: _1.to_i } },
deprecated: row.deprecated_at&.iso8601 }
end
end
+21 -32
ファイルの表示
@@ -3,12 +3,7 @@ module TagGroupRepr
module_function
def name_from_title title
prefix = [PREFIX, 'tag_group:'].find { title.downcase.start_with?(_1) }
title[prefix.length..] if prefix
end
def many locale, groups
def many groups
ids = groups.map(&:id)
return [] if ids.empty?
@@ -19,20 +14,14 @@ module TagGroupRepr
placements = TagGroupPlacement.where(tag_group_id: ids).order(:position)
.group_by(&:tag_group_id)
target_ids =
tag_members.values.flatten.map(&:tag_id) +
placements.values.flatten.filter_map(&:tag_id)
child_ids =
target_ids = tag_members.values.flatten.map(&:tag_id) +
group_members.values.flatten.map(&:child_tag_group_id) +
placements.values.flatten.filter_map(&:tag_id)
child_ids = group_members.values.flatten.map(&:child_tag_group_id) +
placements.values.flatten.filter_map(&:child_tag_group_id)
tags = Tag.select(:id, :category).where(id: target_ids.uniq).index_by(&:id)
names =
TagName
.where(tag_id: target_ids.uniq,
language_code: locale.language_code,
primary_flg: true)
.pluck(:tag_id, :name)
.to_h
names = TagName.where(tag_id: target_ids.uniq, language_code: 'ja', primary_flg: true)
.pluck(:tag_id, :name).to_h
child_groups = TagGroup.where(id: child_ids.uniq).index_by(&:id)
groups.map do |group|
@@ -43,8 +32,8 @@ module TagGroupRepr
end
end
def one locale, group
many(locale, [group]).first
def one group
many([group]).first
end
def tag_group wiki_page
@@ -59,7 +48,7 @@ module TagGroupRepr
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
end
def historical locale, version
def historical version
members = Array(version.members)
placements = Array(version.placements)
tag_references = members.filter_map { |member| member['tag_id'] } +
@@ -79,10 +68,10 @@ module TagGroupRepr
[member.fetch('tag_group_id'), member.fetch('version_no')]
end.to_h
historical_members = members.map do |member|
historical_member(locale, member, tag_versions, group_versions)
historical_member(member, tag_versions, group_versions)
end
historical_placements = placements.map do |node|
historical_placement(locale, node, tag_versions, group_versions,
historical_placement(node, tag_versions, group_versions,
tag_version_numbers, group_version_numbers)
end
{ 'id' => version.tag_group_id,
@@ -100,7 +89,7 @@ module TagGroupRepr
def group_json group, tag_members, group_members, rows, tags, names, child_groups
tree = placement_tree(rows, tags, names, child_groups)
members = tag_members.sort_by(&:tag_id).map do |member|
{ 'tag' => tag_json(tags.fetch(member.tag_id), names[member.tag_id]) }
{ 'tag' => tag_json(tags.fetch(member.tag_id), names.fetch(member.tag_id)) }
end
members.concat(group_members.sort_by(&:child_tag_group_id).map do |member|
{ 'tag_group' => group_json_reference(child_groups.fetch(member.child_tag_group_id)) }
@@ -126,7 +115,7 @@ module TagGroupRepr
build = lambda do |parent|
by_parent.fetch(parent, []).sort_by(&:position).map do |row|
node = if row.tag_id
{ 'tag' => tag_json(tags.fetch(row.tag_id), names[row.tag_id]) }
{ 'tag' => tag_json(tags.fetch(row.tag_id), names.fetch(row.tag_id)) }
elsif row.child_tag_group_id
{ 'tag_group' => group_json_reference(child_groups.fetch(row.child_tag_group_id)) }
else
@@ -164,11 +153,11 @@ module TagGroupRepr
end
end
def historical_member locale, member, tag_versions, group_versions
def historical_member member, tag_versions, group_versions
if member['tag_id']
id = member.fetch('tag_id')
version = tag_versions.fetch([id, member.fetch('version_no')])
{ 'tag' => { 'id' => id, 'name' => version_name(locale, version),
{ 'tag' => { 'id' => id, 'name' => japanese_version_name(version),
'category' => version.category } }
else
id = member.fetch('tag_group_id')
@@ -177,13 +166,13 @@ module TagGroupRepr
end
end
def historical_placement locale, node, tag_versions, group_versions,
tag_version_numbers, group_version_numbers
def historical_placement(node, tag_versions, group_versions,
tag_version_numbers, group_version_numbers)
result = if node['tag_id']
id = node.fetch('tag_id')
version_no = tag_version_numbers.fetch(id)
version = tag_versions.fetch([id, version_no])
{ 'tag' => { 'id' => id, 'name' => version_name(locale, version),
{ 'tag' => { 'id' => id, 'name' => japanese_version_name(version),
'category' => version.category } }
elsif node['tag_group_id']
id = node.fetch('tag_group_id')
@@ -194,12 +183,12 @@ module TagGroupRepr
{ 'section_name' => node.fetch('section_name'), 'description' => node['description'] }
end
result.merge('children' => Array(node['children']).map do |child|
historical_placement(locale, child, tag_versions, group_versions,
historical_placement(child, tag_versions, group_versions,
tag_version_numbers, group_version_numbers)
end)
end
def version_name locale, version
version.names[locale.language_code]&.fetch('name')
def japanese_version_name version
version.names.fetch('ja').fetch('name')
end
end
+11 -24
ファイルの表示
@@ -2,16 +2,19 @@
module WikiPageRepr
BASE = { methods: [:title, :deprecated_at] }.freeze
module_function
def tag_description locale, description
tag = description.tag
def base wiki_page
wiki_page.as_json(BASE)
end
def tag_description description
tag = description.tag
{ 'id' => description.id,
'resource_type' => 'tag_description',
'tag_id' => tag.id,
'language_code' => description.language_code,
'tag_name_id' => tag.tag_name(locale.language_code)&.id,
'tag_name_id' => tag.tag_name('ja')&.id,
'body' => description.body,
'created_user_id' => description.created_by_user_id,
'updated_user_id' => description.updated_by_user_id,
@@ -19,7 +22,7 @@ module WikiPageRepr
'updated_at' => description.updated_at,
'discarded_at' => nil,
'version_no' => description.version_no,
'title' => tag.name(locale.language_code),
'title' => tag.name('ja'),
'deprecated_at' => tag.deprecated_at }
end
@@ -27,23 +30,7 @@ module WikiPageRepr
TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group')
end
def autolink_tag locale, tag_id:, description_id:, name:, language:
{ resource_type: 'tag_description',
resource_id: description_id,
tag_id:,
tag_description_id: description_id,
name:,
language_code: language,
locale_code: locale.code_before_type_cast }
end
def autolink_group locale, id:, name:, language:
{ resource_type: 'tag_group',
resource_id: id,
tag_id: nil,
tag_description_id: nil,
name: "#{ TagGroupRepr::PREFIX }#{ name }",
language_code: language,
locale_code: locale.code_before_type_cast }
def many wiki_pages
wiki_pages.map { |p| base(p) }
end
end
-105
ファイルの表示
@@ -1,105 +0,0 @@
require 'json'
require 'pathname'
class CertifiedTagNameTranslations
FORMAT = 'btrc-hub.certified-tag-name-translations.v1'.freeze
class Error < ArgumentError
;
end
def self.load! path, locale:
new(path, locale:).load!
end
def initialize path, locale:
@path = path
@locale = locale
end
def load!
document = read_document
unless document.is_a?(Hash) && document['format'] == FORMAT
fail!('invalid format or version')
end
source_code = document['source_locale']
target_code = document['target_locale']
unless target_code.is_a?(String) && target_code == Locale.codes.fetch(@locale.code)
fail!("target_locale #{ target_code.inspect } does not match the new Locale")
end
unless source_code.is_a?(String) && Locale.codes.value?(source_code)
fail!("invalid source_locale #{ source_code.inspect }")
end
source_locale = Locale.find_by(code: source_code)
fail!("source Locale #{ source_code.inspect } does not exist") unless source_locale
fail!('source_locale and target_locale must differ') if source_code == target_code
entries = validate_entries(document['entries'], source_code, target_code)
resolve_tags(entries, source_locale.language_code)
end
private
def read_document
unless (@path.is_a?(String) && !@path.include?("\0") &&
Pathname.new(@path).absolute?)
fail!('certified_tag_names_path must be an absolute file path')
end
fail!('JSON file does not exist or is not a regular file') unless File.file?(@path)
text = File.read(@path, encoding: Encoding::UTF_8)
fail!('JSON file is not valid UTF-8') unless text.valid_encoding?
JSON.parse(text)
rescue JSON::ParserError, SystemCallError, IOError, EncodingError => error
fail!("cannot read JSON: #{ error.message }")
end
def validate_entries entries, source_code, target_code
fail!('entries must be an array') unless entries.is_a?(Array)
sources = {}
targets = {}
entries.each_with_index.map do |entry, index|
fail!("entry #{ index + 1 } must be an object") unless entry.is_a?(Hash)
source = entry[source_code]
target = entry[target_code]
unless source.is_a?(String) && source.present? && target.is_a?(String) && target.present?
fail!("entry #{ index + 1 }: missing or empty #{ source_code }/#{ target_code } name")
end
fail!("duplicate source name #{ source.inspect }") if sources.key?(source)
fail!("duplicate target name #{ target.inspect }") if targets.key?(target)
sources[source] = true
targets[target] = true
[source, target]
end
end
def resolve_tags entries, source_language
source_names = entries.map(&:first)
# SQL may use a case/accent-insensitive collation; the Ruby keys are exact.
matches = TagName.where(language_code: source_language, primary_flg: true,
name: source_names).pluck(:name, :tag_id).group_by(&:first)
translations = {}
entries.each do |source, target|
rows = matches.fetch(source, [])
next if rows.empty?
unless rows.length == 1
fail!("source primary name #{ source.inspect } must match exactly one tag")
end
tag_id = rows.first.last
if translations.key?(tag_id)
fail!("multiple source primary names resolve to Tag##{ tag_id }: #{ source.inspect }")
end
translations[tag_id] = target
end
translations
end
def fail! message
raise Error, "Certified tag names #{@path.inspect}: #{ message }"
end
end
-98
ファイルの表示
@@ -1,98 +0,0 @@
class CountsAxes
GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze
def self.time_expression(expression, grain, timezone)
raise ArgumentError unless GRAINS.include?(grain)
connection = ApplicationRecord.connection
# Use the installed TZInfo database, including DST, rather than MySQL timezone tables.
first = Time.utc(1900)
last = Time.utc(2101)
offset = timezone.period_for_utc(first).utc_total_offset
clauses = timezone.transitions_up_to(last, first).filter_map do |transition|
next if transition.offset.utc_total_offset == offset
previous = offset
offset = transition.offset.utc_total_offset
"WHEN #{ expression } < #{ connection.quote(transition.at.to_time) } THEN #{ previous }"
end
seconds = clauses.empty? ? offset.to_s : "CASE #{ clauses.join(' ') } ELSE #{ offset } END"
local = "DATE_ADD(#{ expression }, INTERVAL (#{ seconds }) SECOND)"
case grain
when 'year' then "DATE_FORMAT(#{ local }, '%Y')"
when 'quarter' then "CONCAT(YEAR(#{ local }), '-Q', QUARTER(#{ local }))"
when 'month' then "DATE_FORMAT(#{ local }, '%Y-%m')"
when 'week' then "DATE_FORMAT(#{ local }, '%x-W%v')"
when 'day' then "DATE_FORMAT(#{ local }, '%Y-%m-%d')"
else "DATE_FORMAT(#{ local }, '%Y-%m-%d %H:00')"
end
end
attr_reader :rows, :columns, :all
def initialize(population)
@population = population
@definition = population.definition
@rows = parse(population.params.fetch('rows', '[]'))
@columns = parse(population.params.fetch('cols', '[]'))
@all = rows + columns
raise ArgumentError if all.length > 8 || all.map { _1['field'] }.uniq.length != all.length
end
def expressions
all.map do |axis|
field = axis.fetch('field')
case field
when 'tags'
"CASE WHEN tag_entry.tag_id IS NOT NULL " +
"THEN CONCAT('internal:', tag_entry.tag_id) " +
"ELSE CONCAT('external:', tag_entry.external_tag_id) END"
when 'changed_field' then 'changed.field_key'
else
expression = @definition.expression(field)
@definition.fields[field] == :datetime ? self.class.time_expression(
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
end
end
end
def joins
result = ''
if all.any? { _1['field'] == 'tags' }
result += <<~SQL.squish
JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS (
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
SQL
end
if all.any? { _1['field'] == 'changed_field' }
fields = @definition.changes.map do |field|
"SELECT #{ ApplicationRecord.connection.quote(field) } AS field_key"
end.join(' UNION ALL ')
condition = @definition.changes.map do |field|
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
end.join(' OR ')
result += " JOIN (#{ fields }) changed ON (#{ condition })"
end
result
end
private
def parse(value)
axes = JSON.parse(value)
raise ArgumentError unless axes.is_a?(Array)
axes.each do |axis|
raise ArgumentError unless axis.is_a?(Hash) && (axis.keys - ['field', 'grain']).empty?
raise ArgumentError unless @definition.axes(@population.history).include?(axis['field'])
if @definition.fields[axis['field']] == :datetime
raise ArgumentError unless GRAINS.include?(axis.fetch('grain', 'month'))
elsif axis.key?('grain')
raise ArgumentError
end
end
axes
end
end
-51
ファイルの表示
@@ -1,51 +0,0 @@
require 'tempfile'
require 'fileutils'
# Finish generation before reporting readiness; downloads never hold the full file in RAM.
class CountsExportDownload
EXPIRY = 10.minutes
def self.directory = Rails.root.join('tmp', 'counts_exports')
def self.verifier = Rails.application.message_verifier('counts_export_download')
def self.prepare(exporter, owner)
FileUtils.mkdir_p(directory, mode: 0o700)
Dir.glob(directory.join('counts-*.export')).each do |path|
File.unlink(path) if File.mtime(path) < Time.current - EXPIRY
rescue Errno::ENOENT
# Another request may have removed an expired download.
end
file = Tempfile.create(['counts-', '.part'], directory)
complete = false
path = file.path
begin
file.binmode
exporter.body.each { file.write(_1) }
file.close
finished_path = path.sub(/\.part\z/, '.export')
File.rename(path, finished_path)
path = finished_path
token = verifier.generate({ 'file' => File.basename(path),
'resource' => exporter.resource, 'format' => exporter.format,
'owner' => owner }, expires_in: EXPIRY, purpose: 'counts_export')
complete = true
token
ensure
file.close unless file.closed?
File.unlink(path) if !complete && File.exist?(path)
end
end
def self.find(token, owner)
metadata = verifier.verified(token, purpose: 'counts_export')
return unless metadata.is_a?(Hash) && metadata['owner'] == owner
return unless metadata['file']&.match?(/\Acounts-[a-zA-Z0-9_-]+\.export\z/)
return unless CountsResourceDefinition::DEFINITIONS.key?(metadata['resource'])
return unless ['json', 'csv'].include?(metadata['format'])
path = directory.join(metadata['file'])
return unless path.file?
metadata.merge('path' => path.to_s)
end
end
-229
ファイルの表示
@@ -1,229 +0,0 @@
require 'set'
# Export identifiers are stable; UI translations are supplied separately as column labels.
class CountsExportProjection
ROOT_KEYS = { 'event_type' => 'event_type', 'resource_id' => 'resource_id', 'version_no' => 'version_no',
'is_latest' => 'is_latest', 'thumbnail' => 'thumbnail_url',
'thumbnail_base' => 'thumbnail_base', 'title' => 'title', 'url' => 'url',
'tags' => 'tags', 'parents' => 'parents', 'video_ms' => 'video_ms',
'original_created_at' => 'original_created_at',
'original_created_from' => 'original_created_from',
'original_created_before' => 'original_created_before',
'user' => 'created_by_user', 'created_at' => 'created_at',
'name' => 'name', 'names' => 'names', 'category' => 'category',
'aliases' => 'aliases', 'deprecated' => 'deprecated' }.freeze
CHILD_KEYS = { 'id' => 'id', 'versionNo' => 'version_no', 'kind' => 'kind',
'name' => 'name', 'category' => 'category', 'sections' => 'sections',
'names' => 'names', 'aliases' => 'aliases',
'languageCode' => 'language', 'scriptCode' => 'script',
'autoGenerated' => 'auto_generated' }.freeze
NAME_FIELDS = ['languageCode', 'scriptCode', 'name', 'aliases', 'autoGenerated'].freeze
CHILDREN = {
'tags' => ['id', 'versionNo', 'kind', 'name', 'category', 'sections', 'names'],
'names' => NAME_FIELDS,
'aliases' => ['languageCode', 'name', 'scriptCode'],
'parents' => ['id'] }.freeze
attr_reader :columns, :headers
def initialize(params, resource:, summary:, tags:)
@params = params
@labels = JSON.parse(params.fetch('labels', '{}'))
raise ArgumentError unless @labels.is_a?(Hash) && @labels.length <= 100
raise ArgumentError unless @labels.all? { |key, value| valid_label?(key, value) }
@summary = summary
allowed = if summary
tags ? ['kind', 'tag_id', 'name', 'deprecated', 'count'] : ['group', 'count']
else
CountsQuery::COLUMNS.fetch(resource)
end
@columns = params.fetch('columns', allowed.join(',')).split(',')
raise ArgumentError if (@columns - allowed).any? || @columns.uniq != @columns
@headers = JSON.parse(params.fetch('headers', JSON.generate(@columns)))
unless (@headers.is_a?(Array) && @headers.length == @columns.length &&
@headers.all? { _1.is_a?(String) && _1.length <= 200 })
raise ArgumentError
end
@order = params.fetch('order', '').split(',')
@sorts = params.fetch('child_sort', '').split(',').map do |entry|
parts = entry.split(':', -1)
raise ArgumentError unless parts.length == 2
path, direction = parts
raise ArgumentError unless ['asc', 'desc'].include?(direction)
[path, direction]
end
@selected = params.key?('children') ? params['children'].split(',').to_set : nil
valid = CHILDREN.flat_map do |key, fields|
paths(fields, key)
end
raise ArgumentError if @selected && (@selected.to_a - valid).any?
allowed_sorts = valid.map do |path|
parts = path.split('.')
field = parts.pop
"#{ parts.join('.') }/#{ field }"
end
if ((@sorts.map(&:first) - allowed_sorts).any? ||
@sorts.map(&:first).uniq.length != @sorts.length)
raise ArgumentError
end
end
def metadata
@columns.each_with_index.map do |key, index|
{ key: @summary ? key : ROOT_KEYS.fetch(key), label: @headers[index] }
end
end
def project(row)
@columns.to_h do |key|
if @summary
value = key == 'group' ? { key: row[:key], name: row[:name] } : row[key.to_sym]
[key, value]
else
value = case key
when 'thumbnail' then row[:thumbnail_base]
when 'original_created_at'
{ from: row[:original_created_from], before: row[:original_created_before] }
else row[ROOT_KEYS.fetch(key).to_sym]
end
value = nested(value || [], CHILDREN.fetch(key), key) if CHILDREN.key?(key)
[ROOT_KEYS.fetch(key), value]
end
end
end
def csv_record(record, row)
record.map do |key, value|
key == 'group' ? row[:name] || row[:key] : csv_display(value, key)
end
end
private
def valid_label?(key, value)
key.is_a?(String) && value.is_a?(String) && key.length <= 100 && value.length <= 300
end
def csv_display(value, key)
return value ? @labels.fetch('latest', 'true') : '' if key == 'is_latest'
return @labels.fetch("kind:#{ value }", value) if key == 'kind'
return @labels.fetch("category:#{ value }", value) if key == 'category'
if ['language', 'script'].include?(key) && value.is_a?(Hash)
return @labels.fetch("#{ key }:#{ value[:code] }",
value[:name] || @labels.fetch('notRegistered', ''))
end
return @labels.fetch("boolean:#{ value }", value) if value == true || value == false
return value.map { csv_display(_1, key) } if value.is_a?(Array)
if value.is_a?(Hash)
return value.to_h do |field, item|
[@labels.fetch(field.to_s, field), csv_display(item, field.to_s)]
end
end
value
end
def fields_for(key)
return NAME_FIELDS if key == 'names'
return ['name', 'scriptCode'] if key == 'aliases'
[]
end
def paths(fields, prefix)
fields.flat_map do |key|
path = "#{ prefix }.#{ key }"
[path, *paths(fields_for(key), path)]
end
end
def fetch(row, key) = row.key?(key) ? row[key] : row[key.to_sym]
def named_code(row, kind)
code = fetch(row, "#{ kind }_code")
name = fetch(row, "#{ kind }_name")
{ code:, name: name.present? && name.to_s.downcase != code.to_s.downcase ? name : nil }
end
def stable(value)
case value
when Hash
value.stringify_keys.sort.to_h.transform_values { stable(_1) }
when Array
value.map { stable(_1) }
else
value
end
end
def sort_value(row, key)
if key == 'languageCode'
language = named_code(row, 'language')
return @labels.fetch("language:#{ language[:code] }", language[:name])
end
return named_code(row, 'script')[:name] if key == 'scriptCode'
fetch(row, CHILD_KEYS.fetch(key))
end
def comparison(left, right)
if left.is_a?(Array) && right.is_a?(Array) && left.length != right.length
return left.length <=> right.length
end
return left <=> right if left.is_a?(Numeric) && right.is_a?(Numeric)
if [true, false].include?(left) && [true, false].include?(right)
return (left ? 1 : 0) <=> (right ? 1 : 0)
end
a = left.is_a?(Hash) || left.is_a?(Array) ? JSON.generate(stable(left)) : left.to_s
b = right.is_a?(Hash) || right.is_a?(Array) ? JSON.generate(stable(right)) : right.to_s
a <=> b
end
def sorted(rows, prefix, fields)
sorts = @sorts.filter_map do |path, direction|
parent, key = path.split('/', 2)
next unless parent == prefix
raise ArgumentError unless fields.include?(key)
[key, direction]
end
rows.each_with_index.sort do |(left, a), (right, b)|
result = 0
sorts.each do |key, direction|
first = sort_value(left, key)
last = sort_value(right, key)
result = if first.nil? || last.nil?
first.nil? == last.nil? ? 0 : (first.nil? ? 1 : -1)
else
comparison(first, last) * (direction == 'asc' ? 1 : -1)
end
break unless result.zero?
end
result.zero? ? a <=> b : result
end.map(&:first)
end
def nested(rows, fields, prefix)
keys = fields.select { !@selected || @selected.include?("#{ prefix }.#{ _1 }") }
ordered = (@order + keys.map { "#{ prefix }.#{ _1 }" }).uniq
.filter_map { |path| keys.find { "#{ prefix }.#{ _1 }" == path } }
sorted(rows, prefix, fields).map do |row|
ordered.to_h do |key|
stable = CHILD_KEYS.fetch(key)
value = case key
when 'languageCode' then named_code(row, 'language')
when 'scriptCode' then named_code(row, 'script')
else fetch(row, stable)
end
if fields_for(key).any?
value = nested(value || [], fields_for(key), "#{ prefix }.#{ key }")
end
[stable, value]
end
end
end
end
-146
ファイルの表示
@@ -1,146 +0,0 @@
require 'csv'
# One read view and a fixed point for the entire download; only one page is held in memory.
class CountsExporter
EXTRA_KEYS = ['basis', 'format', 'resource', 'headers', 'children', 'order',
'summary_columns', 'nested_json', 'child_sort', 'labels'].freeze
attr_reader :format, :resource
def initialize(params, language_code:)
if params.key?('definition')
@table_export = CountsTableExport.new(params)
@format = @table_export.format
@resource = @table_export.resource
return
end
allowed = (CountsQuery::KEYS + CountsStateQuery::KEYS + EXTRA_KEYS).uniq
raise ArgumentError if (params.keys - allowed).any?
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@params = params
@language = language_code
@format = params.fetch('format', 'json')
@resource = params.fetch('resource', 'posts')
@mode = params.fetch('mode', 'summary')
@history = params.fetch('basis', 'state') == 'history'
raise ArgumentError unless ['json', 'csv'].include?(format)
raise ArgumentError unless ['posts', 'tags'].include?(resource)
raise ArgumentError unless ['summary', 'details'].include?(@mode)
raise ArgumentError unless ['state', 'history'].include?(params.fetch('basis', 'state'))
@query_params = params.except(*EXTRA_KEYS, 'columns', 'page', 'limit')
.merge('mode' => @mode, 'page' => '1', 'limit' => '100')
point = CountsStateQuery.new(@query_params.slice('at').merge('resource' => resource),
language_code: @language).at
@query_params['at'] = point.iso8601(6)
@query_params['axis'] ||= !@history && resource == 'posts' ? 'tags' : 'month'
@projection = CountsExportProjection.new(params, resource:,
summary: @mode == 'summary',
tags: !@history && @query_params['axis'] == 'tags')
if @history
@query_params.delete('kind')
history_query
else
@query_params.delete('latest')
@state_query = CountsStateQuery.new(@query_params.merge('resource' => resource),
language_code: @language).validate_query!
end
end
def body
return @table_export.body if @table_export
Enumerator.new do |output|
ApplicationRecord.transaction(isolation: :repeatable_read) do
first_row = true
started = false
pages do |page|
unless started
meta = { resource:, mode: @mode, basis: @history ? 'history' : 'state',
at: @query_params['at'], boundary: 'inclusive',
filters: @query_params.except('sort', 'page', 'limit', 'mode'),
sort: export_sort,
child_sort: sort_metadata(@params.fetch('child_sort', '')),
columns: @projection.metadata,
children: @params['children']&.split(','),
field_order: @params.fetch('order', '').split(','), timezone: 'UTC',
count: @mode == 'summary' ? page[:group_count] : page[:count],
totals: page[:totals], coverage: page[:coverage] }
output << (format == 'json' ? "{\"metadata\":#{ meta.to_json },\"data\":[" : "\uFEFF")
if format == 'csv'
output << CSV.generate_line(
@projection.headers.map { csv_value(_1) }, row_sep: "\r\n")
end
started = true
end
(page[:versions] || page[:state_rows] || page[:groups] || []).each do |row|
record = @projection.project(row)
if format == 'json'
output << ',' unless first_row
output << record.to_json
else
values = @projection.csv_record(record, row).map { csv_value(_1) }
output << CSV.generate_line(values, row_sep: "\r\n")
end
first_row = false
end
end
output << ']}' if format == 'json'
end
end
end
private
def export_sort
sort_metadata(@query_params.fetch('sort', default_sort))
end
def sort_metadata(value)
value.split(',').map do |entry|
key, direction = entry.split(':', 2)
{ key:, direction: }
end
end
def default_sort
return 'created_at:desc' if @mode == 'details'
!@history && @query_params['axis'] == 'tags' ? 'count:desc' : 'group:asc'
end
def history_query
scope = resource == 'posts' ? PostVersion.with_previous_version : TagVersion.all
CountsQuery.new(scope, resource, @query_params)
end
def pages
unless @history
@state_query.each_page { yield _1 }
return
end
query = history_query
total = @mode == 'summary' ? nil : query.count
loop do
result = if @mode == 'summary'
query.summary(include_count: @query_params['page'] == '1')
else
{ count: total,
versions: CountsVersionRepr.many(query.details, resource:,
language_code: @language,
at: Time.iso8601(@query_params['at'])) }
end
total ||= result[:group_count]
yield result
break if @query_params['page'].to_i * 100 >= total
@query_params['page'] = (@query_params['page'].to_i + 1).to_s
query = history_query
end
end
def csv_value(value)
text = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
text.match?(/\A\s*[=+@-]/) ? "'#{ text }" : text
end
end
-208
ファイルの表示
@@ -1,208 +0,0 @@
# Population first, then filters. Previous versions are joined before period filtering.
class CountsPopulation
attr_reader :definition, :params, :at, :history, :scope, :timezone
def initialize(params)
@params = params
@definition = CountsResourceDefinition.new(params.fetch('resource', 'posts'))
@history = params.fetch('definition', 'latest') == 'history'
raise ArgumentError unless ['latest', 'history'].include?(params.fetch('definition', 'latest'))
@timezone = TZInfo::Timezone.get(params.fetch('timezone', 'UTC'))
@at = timestamp(params['execution_at']) || Time.current.utc
raise ArgumentError if at > Time.current + 1.second
@scope = definition.model.all
make_population
apply_filters
rescue TZInfo::InvalidTimezoneIdentifier
raise ArgumentError
end
def timestamp(value, end_date: false)
return nil if value.nil? || value == ''
raise ArgumentError unless value.is_a?(String)
if value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
date = Date.iso8601(value)
date += 1 if end_date
timezone.local_to_utc(Time.utc(date.year, date.month, date.day))
else
raise ArgumentError unless value.match?(/(?:Z|[+-]\d{2}:\d{2})\z/)
time = Time.iso8601(value).utc
raise ArgumentError unless time.year.between?(1900, 2100)
time
end
rescue TZInfo::AmbiguousTime, TZInfo::PeriodNotFound, Date::Error
raise ArgumentError
end
def latest_sql(row = 'v', cutoff = nil)
cutoff ||= history ? at : timestamp(params['at']) || at
keys = definition.keys.map { "newer.#{ _1 } = #{ row }.#{ _1 }" }.join(' AND ')
bound = definition.model.connection.quote(cutoff)
<<~SQL.squish
NOT EXISTS (SELECT 1 FROM #{ definition.table } newer
WHERE #{ keys } AND newer.version_no > #{ row }.version_no
AND newer.#{ definition.time_column } <= #{ bound })
SQL
end
def changed_sql(key)
column = definition.column(key)
"(previous.id IS NOT NULL AND NOT (v.#{ column } <=> previous.#{ column }))"
end
private
def make_population
table = definition.table
@scope = @scope.from("#{ table } v").select('v.*')
keys = definition.keys.map { "previous.#{ _1 } = v.#{ _1 }" }.join(' AND ')
@scope = @scope.joins("LEFT JOIN #{ table } previous ON #{ keys } " +
'AND previous.version_no = v.version_no - 1')
@scope = @scope.where("v.#{ definition.time_column } <= ?", at)
unless history
point = timestamp(params['at']) || at
raise ArgumentError if point > at
@scope = @scope.where("v.#{ definition.time_column } <= ?", point)
.where(Arel.sql(latest_sql('v', point))).where.not('v.event_type' => 'discard')
return
end
first = timestamp(params['from'])
last = timestamp(params['through'], end_date: true)
raise ArgumentError if first && last && first > last
@scope = @scope.where('v.created_at >= ?', first) if first
if last
operator = params['through'].length == 10 ? '<' : '<='
@scope = @scope.where("v.created_at #{ operator } ?", last)
end
end
def apply_filters
['resource_id', 'user'].each do |key|
value = params[key == 'user' ? 'user_id' : key]
next if value.nil? || value == ''
raise ArgumentError unless value.match?(/\A[0-9]+\z/)
@scope = @scope.where("#{ definition.expression(key) } = ?", value.to_i)
end
apply_tags if params['tags'].present? || params['tag'].present?
conditions = JSON.parse(params.fetch('conditions', '[]'))
raise ArgumentError unless conditions.is_a?(Array) && conditions.length <= 100
conditions.each { apply_condition(_1) }
drills = JSON.parse(params.fetch('drill', '[]'))
raise ArgumentError unless drills.is_a?(Array) && drills.length <= 100
drills.each do |entry|
key = entry.fetch('field')
if key == 'tags'
@scope = @scope.where(snapshot_membership(entry.fetch('value')))
elsif key == 'changed_field'
raise ArgumentError unless history && definition.changes.include?(entry['value'])
@scope = @scope.where(Arel.sql(changed_sql(entry['value'])))
elsif definition.fields[key] == :datetime && entry['grain']
@scope = @scope.where(definition.expression(key) => nil) if entry['value'].nil?
if entry['value']
expression = CountsAxes.time_expression(definition.expression(key),
entry['grain'], timezone)
@scope = @scope.where("#{ expression } = ?", entry['value'])
end
else
raise ArgumentError unless definition.axes(history).include?(key)
@scope = @scope.where(definition.expression(key) => entry['value'])
end
end
end
def apply_condition(entry)
raise ArgumentError unless entry.is_a?(Hash)
raise ArgumentError if (entry.keys - ['field', 'operator', 'value', 'end', 'blank']).any?
key = entry.fetch('field')
type = definition.fields.fetch(key) { raise ArgumentError }
operator = entry.fetch('operator')
raise ArgumentError unless CountsResourceDefinition::OPERATORS.fetch(type, []).include?(operator)
raise ArgumentError unless [true, false, nil].include?(entry['blank'])
expression = definition.expression(key)
blank = type == :string || type == :enum ?
"(#{ expression } IS NULL OR #{ expression } = '')" : "#{ expression } IS NULL"
if entry['blank']
@scope = @scope.where(Arel.sql(blank))
return
end
values = [entry['value'], entry['end']]
raise ArgumentError unless values.all? { _1.nil? || _1.is_a?(String) }
return if values.all? { _1.nil? || _1 == '' }
parsed = values.map do |value|
next nil if value.nil? || value == ''
case type
when :integer
raise ArgumentError unless value.match?(/\A-?\d+\z/)
value.to_i
when :datetime then timestamp(value)
else value
end
end
if operator == 'range'
@scope = @scope.where("#{ expression } >= ?", parsed[0]) if parsed[0]
@scope = @scope.where("#{ expression } <= ?", parsed[1]) if parsed[1]
return
end
return unless parsed[0]
sql_operator = { 'eq' => '=', 'ne' => '<>', 'gt' => '>', 'lt' => '<',
'ge' => '>=', 'le' => '<=', 'like' => 'LIKE' }.fetch(operator)
sql = "#{ expression } #{ sql_operator } ?"
sql = "(#{ sql } OR #{ blank })" if operator == 'ne'
@scope = @scope.where(sql, parsed[0])
end
def snapshot_membership(value)
kind, identifier = value.to_s.split(':', 2)
raise ArgumentError unless ['internal', 'external'].include?(kind)
raise ArgumentError unless identifier&.match?(/\A[1-9]\d*\z/)
key = kind == 'internal' ? 'tag_id' : 'external_tag_id'
definition.model.sanitize_sql_array([
"JSON_CONTAINS(v.tags_json, JSON_OBJECT('#{ key }', CAST(? AS UNSIGNED)))",
identifier.to_i])
end
def apply_tags
raise ArgumentError unless definition.resource == 'posts'
if params['tag'].present?
@scope = @scope.where(snapshot_membership(
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
end
return unless params['tags'].present?
locale = Locale.find_by!(code: params.fetch('locale', 'ja'))
refs = PostTagSearch.parse_references(params['tag_refs'], params['tags'].split)
raw = params['tags']
literals = PostTagSearch.literals(locale, raw, references: refs)
predicates = literals.map do |literal|
internal, external = PostTagSearch.references(locale, literal)
parts = Array(internal).map { snapshot_membership("internal:#{ _1 }") } +
Array(external).map { snapshot_membership("external:#{ _1 }") }
expression = parts.empty? ? 'FALSE' : "(#{ parts.join(' OR ') })"
literal[:negative] ? "NOT #{ expression }" : expression
end
return if predicates.empty?
any = params['match'] == 'any' || raw.split.any? { _1.downcase == 'type:or' }
@scope = @scope.where(Arel.sql("(#{ predicates.join(any ? ' OR ' : ' AND ') })"))
end
end
-208
ファイルの表示
@@ -1,208 +0,0 @@
# Read-only history queries. SQL identifiers and expressions are server-owned.
class CountsQuery
AXES = ['month', 'user'].freeze
COMMON_COLUMNS = ['resource_id', 'version_no', 'is_latest', 'event_type', 'user', 'created_at'].freeze
COLUMNS = {
'posts' => COMMON_COLUMNS + [
'thumbnail', 'thumbnail_base', 'title', 'url', 'tags', 'parents', 'video_ms',
'original_created_at', 'original_created_from', 'original_created_before'],
'tags' => COMMON_COLUMNS + [
'name', 'names', 'category', 'aliases', 'parents', 'deprecated'] }.freeze
KEYS = [
'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', 'latest',
'user_id', 'resource_id', 'tag', 'columns', 'sort', 'at'].freeze
attr_reader :page, :limit, :mode, :axis
def initialize(scope, resource, params, latest_expression: nil)
@table = scope.klass.table_name
@resource = resource
@id_column = resource == 'posts' ? 'post_id' : 'tag_id'
@params = params
@latest_expression = latest_expression
@scope = scope.except(:select, :order).joins(
"LEFT JOIN users counts_actor ON counts_actor.id = #{ @table }.created_by_user_id")
raise ArgumentError unless (params.keys - KEYS).empty?
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@mode = params.fetch('mode', 'summary')
@axis = params.fetch('axis', 'month')
raise ArgumentError unless ['summary', 'details'].include?(mode) && AXES.include?(axis)
@page = integer('page', 1)
@limit = integer('limit', 20)
raise ArgumentError if limit > 100 || page > 1_000_000
columns = params.fetch('columns', COLUMNS.fetch(resource).join(',')).split(',')
if columns.empty? || columns.uniq != columns || (columns - COLUMNS.fetch(resource)).any?
raise ArgumentError
end
@sort = parse_sort
apply_filters
end
def relation = @scope
def count = @scope.count
def latest_expression
return @latest_expression if @latest_expression
at = timestamp('at')
cutoff = at ? "AND newer.created_at <= #{ @scope.klass.connection.quote(at) }" : ''
<<~SQL.squish
NOT EXISTS (SELECT 1 FROM #{ @table } newer
WHERE newer.#{ @id_column } = #{ @table }.#{ @id_column }
AND newer.version_no > #{ @table }.version_no #{ cutoff })
SQL
end
def details
@scope.select("#{ @table }.*", Arel.sql("(#{ latest_expression }) AS counts_latest"))
.order(Arel.sql(order_sql(detail_expressions, ["#{ @table }.id DESC"])))
.limit(limit).offset((page - 1) * limit)
end
def summary(include_count: true)
expression = axis == 'month' ? "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" :
"#{ @table }.created_by_user_id"
group_order = axis == 'user' ? 'MAX(counts_actor.name)' : expression
sql = @scope.group(Arel.sql(expression)).select(Arel.sql(
"#{ expression } AS group_key, COUNT(*) AS record_count, " +
"#{ group_order } AS group_name")).to_sql
connection = @scope.klass.connection
source = "FROM (#{ sql }) counts_groups"
groups = include_count ? connection.select_value("SELECT COUNT(*) #{ source }") : nil
expressions = { 'group' => ['group_name'], 'count' => ['record_count'] }
order = order_sql(expressions, ['group_key IS NULL ASC', 'group_key ASC'])
rows = connection.select_all(
"SELECT group_key, group_name, record_count #{ source } " +
"ORDER BY #{ order } LIMIT #{ limit } OFFSET #{ (page - 1) * limit }")
{ group_count: groups&.to_i,
groups: rows.map do |row|
{ key: row['group_key'],
name: axis == 'user' ? row['group_name'] : nil,
count: row['record_count'].to_i }
end }
end
private
def parse_sort
value = @params.fetch('sort', mode == 'details' ? 'created_at:desc' : 'group:asc')
return [] if value.empty?
values = value.split(',', -1).map { _1.split(':', -1) }
allowed = mode == 'details' ? COLUMNS.fetch(@resource) - ['thumbnail'] : ['group', 'count']
invalid = values.any? do |key, direction, *extra|
extra.any? || !allowed.include?(key) || !['asc', 'desc'].include?(direction)
end
if (values.empty? || values.length > allowed.length || invalid ||
values.map(&:first).uniq.length != values.length)
raise ArgumentError
end
values
end
# NULL always sorts last, including descending keys. The final key is unique.
def order_sql(expressions, final)
(@sort.flat_map do |key, direction|
expressions.fetch(key).flat_map do |expression|
["(#{ expression }) IS NULL ASC", "#{ expression } #{ direction.upcase }"]
end
end + final).join(', ')
end
def detail_expressions
common = { 'resource_id' => ["#{ @table }.#{ @id_column }"],
'version_no' => ["#{ @table }.version_no"],
'is_latest' => ["(#{ latest_expression })"],
'user' => ['counts_actor.name', "#{ @table }.created_by_user_id"],
'created_at' => ["#{ @table }.created_at"] }
if @resource == 'posts'
fields = [
'title', 'url', 'thumbnail_base', 'video_ms',
'original_created_from', 'original_created_before']
common.merge(fields.to_h { [_1, ["#{ @table }.#{ _1 }"]] }).merge(
'original_created_at' => [
'post_versions.original_created_from', 'post_versions.original_created_before'],
'tags' => json_order('post_versions.tags_json'),
'parents' => list_order('post_versions.parent_post_ids'))
else
name = "JSON_UNQUOTE(JSON_EXTRACT(tag_versions.names, #{ locale_name_path }))"
common.merge(
'name' => ["COALESCE(#{ name }, tag_versions.name)"],
'names' => json_order('tag_versions.names'),
'category' => ['tag_versions.category'],
'aliases' => json_order(
"JSON_EXTRACT(tag_versions.names, '$.*.aliases[*]')"),
'parents' => list_order('tag_versions.parent_tag_ids'),
'deprecated' => ['tag_versions.deprecated_at'])
end
end
def locale_name_path
language = Locale.find_by(code: @params.fetch('locale', 'ja'))&.language_code || 'ja'
path = "$.#{ JSON.generate(language) }.name"
@scope.klass.connection.quote(path)
end
# Multi-valued parent cells sort by cardinality, then binary snapshot text.
def json_order(column)
[
"COALESCE(JSON_LENGTH(#{ column }), 0)",
"COALESCE(CAST(#{ column } AS CHAR), '[]') COLLATE utf8mb4_bin"]
end
def list_order(column)
[
"CASE WHEN #{ column } = '' THEN 0 ELSE " +
"1 + LENGTH(#{ column }) - LENGTH(REPLACE(#{ column }, ' ', '')) END",
"#{ column } COLLATE utf8mb4_bin"]
end
def integer(key, default = nil)
value = @params[key].presence
return default if value.nil? && !default.nil?
raise ArgumentError unless value&.match?(/\A[1-9][0-9]{0,9}\z/)
value.to_i
end
def timestamp(key)
value = @params[key].presence
return nil unless value
pattern = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?Z\z/
raise ArgumentError unless value.match?(pattern)
Date.iso8601(value.first(10))
Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value)
end
def apply_filters
at = timestamp('at')
first = timestamp('from')
last = timestamp('before')
raise ArgumentError if first && last && first >= last
raise ArgumentError unless ['0', '1'].include?(@params.fetch('latest', '1'))
@scope = @scope.where("#{ @table }.created_at <= ?", at) if at
@scope = @scope.where(Arel.sql(latest_expression)) unless @params['latest'] == '0'
@scope = @scope.where("#{ @table }.created_at >= ?", first) if first
@scope = @scope.where("#{ @table }.created_at < ?", last) if last
if @params['resource_id'].present?
@scope = @scope.where(@table => { @id_column => integer('resource_id') })
end
if @params['user_id'].present?
user = @params['user_id'] == 'none' ? nil : integer('user_id')
@scope = @scope.where(@table => { created_by_user_id: user })
end
return unless @params['tag'].present?
raise ArgumentError unless @resource == 'posts'
id = integer('tag')
@scope = @scope.where(
"JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('tag_id', CAST(? AS UNSIGNED))) OR " +
"JSON_CONTAINS(prev.tags_json, JSON_OBJECT('tag_id', CAST(? AS UNSIGNED)))", id, id)
end
end
-65
ファイルの表示
@@ -1,65 +0,0 @@
# Server-owned version identities, attributes and field types. No client SQL is accepted.
class CountsResourceDefinition
COMMON = { 'resource_id' => :integer, 'version_no' => :integer,
'event_type' => :enum, 'user' => :integer, 'created_at' => :datetime }.freeze
DEFINITIONS = {
'posts' => [PostVersion, ['post_id'], {
'title' => :string, 'url' => :string, 'thumbnail_base' => :string,
'tags_json' => :json, 'parent_post_ids' => :string, 'video_ms' => :integer,
'original_created_from' => :datetime, 'original_created_before' => :datetime }],
'tags' => [TagVersion, ['tag_id'], {
'name' => :string, 'names' => :json, 'category' => :enum, 'aliases' => :string,
'parent_tag_ids' => :string, 'deprecated_at' => :datetime }],
'nico_tags' => [NicoTagVersion, ['tag_id'], {
'name' => :string, 'linked_tags' => :string }],
'tag_descriptions' => [TagDescriptionVersion, ['tag_id', 'language_code'], {
'language_code' => :enum, 'body' => :string,
'source_language_code' => :enum, 'source_version_no' => :integer }],
'tag_groups' => [TagGroupVersion, ['tag_group_id'], {
'name' => :string, 'description' => :string,
'members' => :json, 'placements' => :json }],
'materials' => [MaterialVersion, ['material_id'], {
'url' => :string, 'parent_id' => :integer, 'tag_id' => :integer,
'tag_name' => :string, 'tag_category' => :enum,
'discarded_at' => :datetime, 'updated_at' => :datetime,
'updated_by_user_id' => :integer, 'export_paths_json' => :json,
'file_blob_id' => :integer, 'file_filename' => :string,
'file_content_type' => :string, 'file_byte_size' => :integer,
'file_checksum' => :string, 'file_sha256' => :string,
'source_kind' => :enum, 'source_uri' => :string, 'source_path' => :string,
'source_file_id' => :string, 'normalized_source_key' => :string }] }.freeze
OPERATORS = {
string: ['eq', 'ne', 'like'], enum: ['eq', 'ne'], boolean: ['eq', 'ne'],
integer: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'],
datetime: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'] }.freeze
attr_reader :model, :keys, :fields, :resource
def initialize(resource)
@resource = resource
@model, @keys, attributes = DEFINITIONS.fetch(resource) { raise ArgumentError }
@fields = COMMON.merge(attributes)
end
def table = model.table_name
def time_column = 'created_at'
def column(key)
raise ArgumentError unless fields.key?(key)
{ 'resource_id' => keys.first, 'user' => 'created_by_user_id' }.fetch(key, key)
end
def expression(key, row = 'v') = "#{ row }.#{ column(key) }"
def changes = fields.keys - COMMON.keys - ['updated_at', 'updated_by_user_id']
def axes(history)
fields.reject { |_key, type| type == :json }.keys +
(resource == 'posts' ? ['tags'] : []) + (history ? ['changed_field'] : [])
end
def metadata
fields.map do |key, type|
{ key:, type:, nullable: model.columns_hash.fetch(column(key)).null,
operators: OPERATORS.fetch(type, []) }
end
end
end
-366
ファイルの表示
@@ -1,366 +0,0 @@
# Restores one recorded version per resource before applying record filters.
class CountsStateQuery
KEYS = ['locale', 'resource', 'mode', 'at', 'from', 'before', 'grain',
'kind', 'tag', 'page', 'limit', 'sort', 'axis', 'user_id', 'resource_id'].freeze
MAX_PERIODS = 366
attr_reader :at
def initialize(params, language_code:)
raise ArgumentError unless (params.keys - KEYS).empty?
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@params = params
@connection = ApplicationRecord.connection
@language = language_code
@resource = params.fetch('resource', 'posts')
@mode = params.fetch('mode', 'summary')
@inclusive = ['summary', 'details'].include?(@mode)
@axis = params.fetch('axis', @resource == 'posts' ? 'tags' : 'month')
@kind = params.fetch('kind', 'internal')
@grain = params.fetch('grain', 'month')
raise ArgumentError unless ['posts', 'tags'].include?(@resource)
raise ArgumentError unless ['summary', 'details', 'state', 'trend', 'snapshot'].include?(@mode)
raise ArgumentError unless ['internal', 'external', 'all'].include?(@kind)
raise ArgumentError unless ['day', 'month', 'year'].include?(@grain)
raise ArgumentError unless ['tags', 'month', 'user'].include?(@axis)
if @mode == 'summary' && @axis == 'tags' && @resource != 'posts'
raise ArgumentError
end
@page = integer('page', 1)
@limit = integer('limit', 20)
@tag = integer('tag') if params['tag'].present?
raise ArgumentError if @limit > 100 || @page > 1_000_000
raise ArgumentError if @tag && @kind == 'all'
raise ArgumentError if @mode == 'trend' && !@tag
@now = Time.current.utc
@at = timestamp('at') || @now
raise ArgumentError if @at > @now
end
def call(metadata: true)
@metadata = metadata
# Keep totals, rows, and detail pages on one repeatable-read database snapshot.
ApplicationRecord.transaction do
unless @history_loaded
@post_start = PostVersion.minimum(:created_at)
@tag_start = TagVersion.minimum(:created_at)
@history_loaded = true
end
result = { page: @page, limit: @limit, at: @at.iso8601(6),
coverage: { basis: 'recorded_snapshots', exact: false,
post_history_from: @post_start&.iso8601(6),
tag_history_from: @tag_start&.iso8601(6) } }
case @mode
when 'trend'
result.merge(trend)
when 'summary'
result.merge(metadata ? { totals: totals } : {}).merge(summary)
when 'details', 'snapshot'
result.merge(metadata ? { totals: totals } : {}).merge(details)
else
result.merge(metadata ? { totals: totals } : {}).merge(listing)
end
end
end
def each_page
return enum_for(:each_page) unless block_given?
@page = 1
first = call
yield first
total = ['summary', 'state', 'trend'].include?(@mode) ? first[:group_count] : first[:count]
while @page * @limit < total
@page += 1
yield call(metadata: false)
end
end
def validate_query!
filtered_scope(@resource)
case @mode
when 'summary'
if @axis == 'tags'
order_sql({ 'name' => 'name', 'count' => 'post_count', 'tag_id' => 'tag_id',
'kind' => 'kind', 'deprecated' => 'deprecated_at' }, 'count:desc', [])
else
record_query('summary')
end
when 'details', 'snapshot'
record_query('details')
end
self
end
private
def quote(value) = @connection.quote(value)
def integer(key, default = nil)
value = @params[key]
return default if value.blank? && default
raise ArgumentError unless value&.match?(/\A[1-9][0-9]{0,9}\z/)
value.to_i
end
def timestamp(key)
value = @params[key].presence
return nil unless value
pattern = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?Z\z/
raise ArgumentError unless value.match?(pattern)
Date.iso8601(value.first(10))
Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value).utc
end
# Version numbers order equal-timestamp writes. Do not filter tags before this selection.
def latest_condition(table, row, boundary)
key = table == 'post_versions' ? 'post_id' : 'tag_id'
<<~SQL.squish
#{ row }.created_at #{ @inclusive ? '<=' : '<' } #{ boundary } AND NOT EXISTS (
SELECT 1 FROM #{ table } newer
WHERE newer.#{ key } = #{ row }.#{ key }
AND newer.created_at #{ @inclusive ? '<=' : '<' } #{ boundary }
AND newer.version_no > #{ row }.version_no)
SQL
end
def state_sql(table, boundary, live: false)
sql = "SELECT v.* FROM #{ table } v WHERE #{ latest_condition(table, 'v', boundary) }"
live ? "#{ sql } AND v.event_type <> 'discard'" : sql
end
def membership(row)
key = @kind == 'external' ? 'external_tag_id' : 'tag_id'
"JSON_CONTAINS(#{ row }.tags_json, JSON_OBJECT('#{ key }', #{ @tag }))"
end
# Deprecation keeps a tag in existence. Only an explicit discard removes it.
def tag_visible(boundary)
return '1 = 1' if @kind == 'external'
<<~SQL.squish
NOT EXISTS (SELECT 1 FROM tag_versions t
WHERE t.tag_id = #{ @tag } AND t.event_type = 'discard'
AND #{ latest_condition('tag_versions', 't', boundary) })
SQL
end
def ctes
boundary = quote(@at)
<<~SQL
WITH post_at AS (#{ filtered_scope('posts').to_sql }),
tag_at AS (#{ state_sql('tag_versions', boundary) }),
nico_at AS (#{ state_sql('nico_tag_versions', boundary) }),
pairs AS (
SELECT DISTINCT p.post_id,
CASE WHEN entry.tag_id IS NULL THEN 'external' ELSE 'internal' END AS kind,
COALESCE(entry.tag_id, entry.external_tag_id) AS tag_id
FROM post_at p
JOIN JSON_TABLE(CAST(p.tags_json AS JSON), '$[*]' COLUMNS (
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY ERROR ON ERROR,
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY ERROR ON ERROR
)) entry
LEFT JOIN tag_at t ON t.tag_id = entry.tag_id
WHERE COALESCE(entry.tag_id, entry.external_tag_id) IS NOT NULL
AND (t.event_type IS NULL OR t.event_type <> 'discard')
)
SQL
end
def totals
row = @connection.select_one(<<~SQL)
#{ ctes }
SELECT (SELECT COUNT(*) FROM post_at) AS posts,
(SELECT COUNT(*) FROM (#{ filtered_scope('tags').to_sql }) counted_tags) AS tags,
(SELECT COUNT(*) FROM tag_at WHERE event_type <> 'discard'
AND deprecated_at IS NOT NULL) AS deprecated_tags,
(SELECT COUNT(*) FROM pairs) AS assignments,
(SELECT COUNT(*) FROM pairs WHERE kind = 'external') AS external_assignments
SQL
row.transform_values!(&:to_i)
row.merge('posts' => available?(@post_start, @at) ? row['posts'] : nil,
'assignments' => available?(@post_start, @at) ? row['assignments'] : nil,
'external_assignments' => available?(@post_start, @at) ?
row['external_assignments'] : nil,
'tags' => available?(@tag_start, @at) ? row['tags'] : nil,
'deprecated_tags' => available?(@tag_start, @at) ? row['deprecated_tags'] : nil)
end
def available?(first, boundary)
first && (@inclusive ? first <= boundary : first < boundary)
end
def listing
path = quote("$.#{ JSON.generate(@language) }.name")
catalog = <<~SQL
, tag_counts AS (
SELECT membership.kind, membership.tag_id, COUNT(*) AS post_count
FROM pairs membership GROUP BY membership.kind, membership.tag_id
), catalog AS (
SELECT 'internal' AS kind, tag_id,
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(names, #{ path })), name) AS name,
deprecated_at FROM tag_at WHERE event_type <> 'discard'
UNION ALL
SELECT tc.kind, tc.tag_id, external_version.name, NULL AS deprecated_at
FROM tag_counts tc
LEFT JOIN tag_at internal_version
ON tc.kind = 'internal' AND internal_version.tag_id = tc.tag_id
LEFT JOIN nico_at external_version
ON tc.kind = 'external' AND external_version.tag_id = tc.tag_id
WHERE tc.kind = 'external' OR internal_version.tag_id IS NULL
)
SQL
filters = []
filters << "c.kind = #{ quote(@kind) }" unless @kind == 'all'
filters << "c.tag_id = #{ @tag }" if @tag
where = filters.any? ? "WHERE #{ filters.join(' AND ') }" : ''
source = 'FROM catalog c LEFT JOIN tag_counts n ON n.kind = c.kind AND n.tag_id = c.tag_id'
prefix = "#{ ctes } #{ catalog }"
total = if @metadata
@connection.select_value("#{ prefix } SELECT COUNT(*) #{ source } #{ where }").to_i
end
order = order_sql({ 'name' => 'c.name', 'count' => 'COALESCE(n.post_count, 0)',
'tag_id' => 'c.tag_id', 'kind' => 'c.kind',
'deprecated' => 'c.deprecated_at' }, 'count:desc',
['c.kind ASC', 'c.tag_id ASC'])
rows = @connection.select_all(<<~SQL)
#{ prefix }
SELECT c.kind, c.tag_id, c.name, c.deprecated_at,
COALESCE(n.post_count, 0) AS post_count #{ source } #{ where }
ORDER BY #{ order } LIMIT #{ @limit } OFFSET #{ (@page - 1) * @limit }
SQL
{ count: total, group_count: total, state_rows: rows.map do |row|
{ kind: row['kind'], tag_id: row['tag_id'], name: row['name'],
deprecated: row['deprecated_at']&.iso8601,
count: available?(@post_start, @at) ? row['post_count'].to_i : nil,
at: @at.iso8601(6) }
end }
end
# All record filters apply after selecting the latest recorded version at the point.
def restored_scope(resource)
model = resource == 'posts' ? PostVersion : TagVersion
table = model.table_name
scope = model.where(Arel.sql(latest_condition(table, table, quote(@at))))
.where.not(event_type: 'discard')
if @tag
scope = if resource == 'posts'
scope.where("#{ membership(table) } AND #{ tag_visible(quote(@at)) }")
elsif @kind == 'internal'
scope.where(tag_id: @tag)
else
scope.none
end
end
scope
end
def filtered_scope(resource)
params = @params.slice('from', 'before', 'user_id', 'resource_id')
.merge('mode' => 'details', 'latest' => '0', 'sort' => '')
CountsQuery.new(restored_scope(resource), resource, params,
latest_expression: '1').relation
end
def record_query(mode)
params = @params.slice('locale', 'sort', 'page', 'limit')
.merge('mode' => mode, 'latest' => '0', 'axis' => @axis == 'tags' ? 'month' : @axis,
'page' => @page.to_s, 'limit' => @limit.to_s)
CountsQuery.new(filtered_scope(@resource), @resource, params, latest_expression: '1')
end
def summary
return listing if @axis == 'tags'
query = record_query('summary')
{ count: @metadata ? query.count : nil }.merge(query.summary(include_count: @metadata))
end
def details
query = record_query('details')
{ count: @metadata ? query.count : nil,
versions: CountsVersionRepr.many(query.details, resource: @resource,
language_code: @language, at: @at,
inclusive: @inclusive) }
end
def next_boundary(time)
case @grain
when 'day' then time.beginning_of_day + 1.day
when 'month' then time.beginning_of_month.next_month
else time.beginning_of_year.next_year
end
end
def periods
@before = timestamp('before') || @now.beginning_of_month
@from = timestamp('from') || @before.prev_year
raise ArgumentError if @from >= @before || @before > @now
rows = []
boundary = next_boundary(@from)
while boundary <= @before
raise ArgumentError if rows.length >= MAX_PERIODS
format = { 'day' => '%Y-%m-%d', 'month' => '%Y-%m', 'year' => '%Y' }.fetch(@grain)
rows << [boundary, (boundary - 1.second).strftime(format)]
boundary = next_boundary(boundary)
end
rows
end
def trend
points = periods
metadata = { count: points.length, group_count: points.length, state_rows: [],
from: @from.iso8601, before: @before.iso8601, grain: @grain }
return metadata if points.empty?
values = points.map do |boundary, period|
"SELECT CAST(#{ quote(boundary) } AS DATETIME(6)) AS boundary, " +
"#{ quote(period) } AS period"
end.join(' UNION ALL ')
order = order_sql({ 'period' => 'boundary', 'count' => 'post_count' },
'period:asc', ['boundary ASC'])
known = @post_start ? "periods.boundary > #{ quote(@post_start) }" : 'FALSE'
rows = @connection.select_all(<<~SQL)
WITH periods AS (#{ values }), series AS (
SELECT periods.boundary, periods.period,
CASE WHEN #{ known } THEN COUNT(DISTINCT p.post_id) ELSE NULL END AS post_count
FROM periods
LEFT JOIN post_versions p ON p.event_type <> 'discard'
AND #{ membership('p') }
AND #{ latest_condition('post_versions', 'p', 'periods.boundary') }
AND #{ tag_visible('periods.boundary') }
GROUP BY periods.boundary, periods.period
)
SELECT * FROM series ORDER BY #{ order }
LIMIT #{ @limit } OFFSET #{ (@page - 1) * @limit }
SQL
metadata.merge(state_rows: rows.map do |row|
{ kind: @kind, tag_id: @tag, name: nil, period: row['period'],
at: row['boundary'].utc.iso8601(6), count: row['post_count']&.to_i }
end)
end
def order_sql(expressions, default, final)
raw = @params.fetch('sort', default)
sorts = raw.empty? ? [] : raw.split(',', -1).map { _1.split(':', -1) }
raise ArgumentError if sorts.map(&:first).uniq.length != sorts.length
raise ArgumentError if sorts.length > expressions.length
order = sorts.flat_map do |key, direction, *extra|
if extra.any? || !expressions.key?(key) || !['asc', 'desc'].include?(direction)
raise ArgumentError
end
expression = expressions.fetch(key)
["(#{ expression }) IS NULL ASC", "#{ expression } #{ direction.upcase }"]
end
(order + final).join(', ')
end
end
-172
ファイルの表示
@@ -1,172 +0,0 @@
require 'csv'
# Export uses the exact same population and grouped cells as the interactive table.
class CountsTableExport
EXTRA = ['format', 'columns', 'headers', 'labels', 'children', 'child_sort',
'order', 'nested_json', 'summary_columns', 'basis', 'axis', 'latest'].freeze
attr_reader :format, :resource
def initialize(params)
raise ArgumentError if (params.keys - CountsTableQuery::KEYS - EXTRA).any?
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@params = params
@labels = JSON.parse(params.fetch('labels', '{}'))
raise ArgumentError unless @labels.is_a?(Hash)
@selected = params.key?('children') ? params['children'].split(',') : nil
@order = params.fetch('order', '').split(',')
@child_sort = params.fetch('child_sort', '').split(',').map { _1.split(':', -1) }
@child_sort.each do |path, direction, *extra|
raise ArgumentError if extra.any? || !['asc', 'desc'].include?(direction)
raise ArgumentError unless path&.match?(/\A[a-zA-Z_]+(?:\.[a-zA-Z]+)*\/[a-zA-Z]+\z/)
end
@format = params.fetch('format', 'json')
@resource = params.fetch('resource', 'posts')
raise ArgumentError unless ['json', 'csv'].include?(format)
@query = CountsTableQuery.new(params.slice(*CountsTableQuery::KEYS)
.merge('page' => '1', 'limit' => '100'))
@summary = @query.mode == 'summary'
@columns = params.fetch('columns', '').split(',')
@headers = JSON.parse(params.fetch('headers', '[]'))
raise ArgumentError unless @headers.is_a?(Array) && @headers.all? { _1.is_a?(String) }
unless @summary
if ['posts', 'tags'].include?(resource)
@projection = CountsExportProjection.new(params, resource:, summary: false, tags: false)
else
allowed = @query.population.definition.fields.keys + ['is_latest']
raise ArgumentError if (@columns - allowed).any?
raise ArgumentError unless @columns.length == @headers.length && @columns.uniq == @columns
end
end
end
def body
Enumerator.new do |output|
ApplicationRecord.transaction(isolation: :repeatable_read) do
@language_names = Language.pluck(:code, :name).to_h
started = false
first = true
@query.each_page do |page|
unless started
metadata = page.except(:versions, :cells, :axis_labels, :column_values).merge(
resource:, filters: @params.slice(*CountsTableQuery::KEYS),
fields: @query.population.definition.metadata,
columns: @columns, column_labels: @headers,
order: @params['order'], children: @params['children'],
sort: @params.fetch('sort', ''), child_sort: @params['child_sort'])
output << (format == 'json' ? "{\"metadata\":#{ metadata.to_json },\"data\":[" : "\uFEFF")
if format == 'csv'
headers = @summary ? summary_headers : @headers
output << CSV.generate_line(headers.map { safe(_1) }, row_sep: "\r\n")
end
started = true
end
(page[:cells] || page[:versions]).each do |row|
record = project(row)
if format == 'json'
output << ',' unless first
output << record.to_json
else
values = if @summary
row[:values].each_with_index.map do |value, index|
page[:axis_labels].fetch("a#{ index }", {}).fetch(value.to_s, value)
end + [row[:count]]
elsif @projection
@projection.csv_record(record, row)
else
record.map { |key, value| csv_logical(key, value) }
end
output << CSV.generate_line(values.map { safe(_1) }, row_sep: "\r\n")
end
first = false
end
end
output << ']}' if format == 'json'
end
end
end
private
def summary_headers
labels = @labels
@query.axes.all.map { labels.fetch(_1['field'], _1['field']) } +
[labels.fetch('count', 'count')]
end
def project(row)
return { row: row[:values].first(@query.axes.rows.length),
column: row[:values].drop(@query.axes.rows.length), count: row[:count] } if @summary
return @projection.project(row) if @projection
@columns.to_h do |column|
key = column == 'user' ? 'created_by_user' : column
value = row[key]
value = project_nested(value, key) if ['members', 'placements', 'export_paths_json'].include?(key)
[key, value]
end
end
def camel(key)
key.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
end
def project_nested(value, path)
rows = path == 'export_paths_json' ? (value || {}).map { |profile, target|
{ 'profile' => profile, 'path' => target }
} : Array(value)
sorts = @child_sort.filter_map do |key, direction|
parent, field = key.split('/', 2)
[field, direction] if parent == path
end
rows = rows.each_with_index.sort do |(first, a), (last, b)|
compared = 0
sorts.each do |field, direction|
left = first.find { |key, _| camel(key) == field }&.last
right = last.find { |key, _| camel(key) == field }&.last
compared = if left.nil? || right.nil?
left.nil? == right.nil? ? 0 : (left.nil? ? 1 : -1)
else
(left.to_s <=> right.to_s) * (direction == 'asc' ? 1 : -1)
end
break unless compared.zero?
end
compared.zero? ? a <=> b : compared
end.map(&:first)
rows.map do |row|
fields = row.keys.select { !@selected || @selected.include?("#{ path }.#{ camel(_1) }") }
ordered = (@order + fields.map { "#{ path }.#{ camel(_1) }" }).uniq
.filter_map { |item| fields.find { "#{ path }.#{ camel(_1) }" == item } }
ordered.to_h do |key|
item = row[key]
item = project_nested(item, "#{ path }.#{ camel(key) }") if item.is_a?(Array)
[key, item]
end
end
end
def csv_logical(key, value)
return value ? @labels.fetch('latest', '') : '' if key == 'is_latest'
if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
return @query.population.timezone.utc_to_local(value.utc).iso8601
end
if key == 'language_code' || key == 'source_language_code'
return @language_names[value]
end
if value.is_a?(Hash)
return value.to_h { |field, item| [@labels.fetch(camel(field.to_s), ''), csv_logical(field, item)] }
end
return value.map { csv_logical(key, _1) } if value.is_a?(Array)
@labels.fetch("#{ key }:#{ value }", value)
end
def safe(value)
return value if value.is_a?(Numeric)
text = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
text.match?(/\A\s*[=+@-]/) ? "'#{ text }" : text
end
end
-252
ファイルの表示
@@ -1,252 +0,0 @@
# Common entrypoint for six version resources; legacy endpoints keep their contracts.
class CountsTableQuery
MAX_COLUMNS = 100
class TooManyColumns < ArgumentError; end
KEYS = ['resource', 'definition', 'mode', 'at', 'execution_at', 'from', 'through',
'timezone', 'rows', 'cols', 'conditions', 'drill', 'tags', 'tag_refs', 'match',
'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale'].freeze
attr_reader :population, :axes, :page, :limit, :mode
def initialize(params)
raise ArgumentError if (params.keys - KEYS).any?
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@params = params
@population = CountsPopulation.new(params)
@definition = population.definition
@connection = @definition.model.connection
@axes = CountsAxes.new(population)
@page = Integer(params.fetch('page', '1'), 10)
@limit = Integer(params.fetch('limit', '20'), 10)
@mode = params.fetch('mode', 'summary')
raise ArgumentError unless page.between?(1, 1_000_000) && limit.between?(1, 100)
raise ArgumentError unless ['summary', 'details'].include?(mode)
@sort = params.fetch('sort', '').split(',').map { _1.split(':', -1) }
allowed = mode == 'summary' ? axes.all.each_index.map { "a#{ _1 }" } + ['count'] :
@definition.fields.keys + ['is_latest'] +
(@definition.resource == 'posts' ? ['tags', 'parents', 'original_created_at'] : []) +
(@definition.resource == 'tags' ? ['parents', 'deprecated'] : [])
raise ArgumentError if @sort.map(&:first).uniq.length != @sort.length
@sort.each do |key, direction, *extra|
measure = mode == 'summary' && key&.match?(/\Ameasure_[0-9a-f]+\z/)
measure_values(key) if measure
if extra.any? || (!allowed.include?(key) && !measure) || !['asc', 'desc'].include?(direction)
raise ArgumentError
end
end
end
def call
ApplicationRecord.transaction do
metadata.merge(mode == 'summary' ? summary : details)
end
end
def metadata
{ page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
definition: population.history ? 'history' : 'latest',
timezone: population.timezone.identifier, rows: axes.rows, cols: axes.columns,
fields: @definition.metadata,
count: population.scope.except(:select).optimizer_hints('MAX_EXECUTION_TIME(10000)').count }
end
def summary
expressions = axes.expressions
selected = expressions.each_with_index.map do |expression, index|
"#{ expression } AS a#{ index }"
end
source = population.scope
source = source.joins(axes.joins) unless axes.joins.empty?
sql = if expressions.empty?
source.except(:select).select('COUNT(DISTINCT v.id) AS count').to_sql
else
source.except(:select).group(*expressions.map { Arel.sql(_1) })
.select(Arel.sql((selected + ['COUNT(DISTINCT v.id) AS count']).join(', ')))
.to_sql
end
prefix = "WITH cells AS (#{ sql })"
column_values = limited_columns(prefix)
row_keys = axes.rows.each_index.map { "a#{ _1 }" }
# Page whole row tuples so a pivot row is never split across display pages.
row_selection = row_keys.empty? ? '1 AS singleton' : row_keys.join(', ')
row_group = row_keys.empty? ? 'GROUP BY singleton' : "GROUP BY #{ row_keys.join(', ') }"
row_sort = @sort.each_with_index.filter_map do |(key, direction), index|
if key == 'count' || key.start_with?('measure_')
condition = if key == 'count'
'TRUE'
else
measure_values(key).each_with_index.map do |value, offset|
"a#{ axes.rows.length + offset } <=> #{ @connection.quote(value) }"
end.join(' AND ').presence || 'TRUE'
end
["MAX(CASE WHEN #{ condition } THEN count END) AS sort#{ index }",
"sort#{ index } IS NULL ASC, sort#{ index } #{ direction.upcase }"]
elsif row_keys.include?(key)
[nil, "#{ key } IS NULL ASC, #{ key } #{ direction.upcase }"]
end
end
extras = row_sort.filter_map(&:first)
order_rows = row_sort.map(&:last) + row_keys.map { "#{ _1 } IS NULL, #{ _1 }" }
order_rows = ['singleton ASC'] if order_rows.empty?
group_sql = "SELECT #{ ([row_selection] + extras).join(', ') } FROM cells #{ row_group }"
total = @connection.select_value(<<~SQL).to_i
#{ prefix }
SELECT /*+ MAX_EXECUTION_TIME(10000) */ COUNT(*) FROM (#{ group_sql }) row_groups
SQL
join = row_keys.map { "cells.#{ _1 } <=> page_rows.#{ _1 }" }.join(' AND ')
join = 'TRUE' if row_keys.empty?
order_cols = @sort.filter_map do |key, direction|
next unless key.match?(/\Aa[0-9]+\z/) && !row_keys.include?(key)
"cells.#{ key } #{ direction.upcase }"
end
order = ['page_rows.position'] + order_cols +
axes.all.each_index.map { "cells.a#{ _1 }" }
records = @connection.select_all(<<~SQL)
#{ prefix }, row_groups AS (#{ group_sql }),
numbered AS (SELECT row_groups.*,
ROW_NUMBER() OVER (ORDER BY #{ order_rows.join(', ') }) AS position FROM row_groups),
page_rows AS (SELECT * FROM numbered
WHERE position > #{ (page - 1) * limit } AND position <= #{ page * limit })
SELECT /*+ MAX_EXECUTION_TIME(10000) */ cells.* FROM cells JOIN page_rows ON #{ join }
ORDER BY #{ order.join(', ') }
SQL
{ group_count: total, column_values:, cells: records.map do |row|
{ values: axes.all.each_index.map { row["a#{ _1 }"] }, count: row['count'].to_i }
end, axis_labels: axis_labels(records, column_values) }
end
def details
query = population.scope.select(Arel.sql(
"(#{ population.latest_sql }) AS counts_latest"))
order = sort_sql { detail_expression(_1) }
query = query.order(Arel.sql((order + ['v.id DESC']).join(', ')))
.limit(limit).offset((page - 1) * limit)
rows = query.to_a
if ['posts', 'tags'].include?(@definition.resource)
point = population.history ? population.at :
population.timestamp(@params['at']) || population.at
values = CountsVersionRepr.many(rows, resource: @definition.resource,
language_code: locale.language_code,
at: point)
values.each_with_index { |value, index| value[:event_type] = rows[index].event_type }
else
users = User.where(id: rows.map(&:created_by_user_id)).pluck(:id, :name).to_h
values = rows.map do |row|
attributes = @definition.fields.keys.to_h do |key|
[key, row.public_send(@definition.column(key))]
end
attributes.merge('id' => row.id, 'resource_id' => row[@definition.keys.first],
'is_latest' => row.attributes['counts_latest'].to_i == 1,
'created_by_user' => row.created_by_user_id &&
{ id: row.created_by_user_id, name: users[row.created_by_user_id] })
end
end
{ versions: values }
end
def each_page
total = nil
loop do
result = call
total ||= mode == 'summary' ? result[:group_count] : result[:count]
yield result
break if page * limit >= total
@page += 1
end
end
private
def locale = @locale ||= Locale.find_by!(code: @params.fetch('locale', 'ja'))
def limited_columns(prefix)
keys = axes.columns.each_index.map { "a#{ axes.rows.length + _1 }" }
return [[]] if keys.empty?
order = keys.map { "#{ _1 } IS NULL, #{ _1 }" }.join(', ')
values = @connection.select_rows(<<~SQL)
#{ prefix }
SELECT /*+ MAX_EXECUTION_TIME(10000) */ DISTINCT #{ keys.join(', ') } FROM cells
ORDER BY #{ order } LIMIT #{ MAX_COLUMNS + 1 }
SQL
raise TooManyColumns if values.length > MAX_COLUMNS
values
end
def measure_values(key)
value = JSON.parse([key.delete_prefix('measure_')].pack('H*'))
unless (value.is_a?(Array) && value.length == axes.columns.length &&
value.all? { _1.nil? || _1.is_a?(String) || _1.is_a?(Numeric) })
raise ArgumentError
end
value
end
def detail_expression(key)
return "(#{ population.latest_sql })" if key == 'is_latest'
aliases = { 'tags' => 'tags_json', 'parents' => @definition.resource == 'posts' ?
'parent_post_ids' : 'parent_tag_ids', 'deprecated' => 'deprecated_at',
'original_created_at' => 'original_created_from' }
field = aliases.fetch(key, key)
expression = @definition.expression(field)
return expression unless @definition.fields[field] == :json
"CAST(#{ expression } AS CHAR) COLLATE utf8mb4_bin"
end
def sort_sql
@sort.flat_map do |key, direction|
expression = yield key
["(#{ expression }) IS NULL ASC", "#{ expression } #{ direction.upcase }"]
end
end
def axis_labels(records, column_values)
result = {}
axes.all.each_with_index do |axis, index|
values = records.map { _1["a#{ index }"] }
if index >= axes.rows.length
values += column_values.map { _1[index - axes.rows.length] }
end
values = values.compact.uniq
labels = case axis['field']
when 'user'
User.where(id: values).pluck(:id, :name).to_h.transform_keys(&:to_s)
when 'language_code', 'source_language_code'
Language.where(code: values).pluck(:code, :name).to_h
when 'tags'
tag_labels(values)
else {}
end
result["a#{ index }"] = labels
end
result
end
def tag_labels(values)
internal = values.grep(/\Ainternal:/).map { _1.split(':').last.to_i }
external = values.grep(/\Aexternal:/).map { _1.split(':').last.to_i }
point = population.timestamp(@params['at']) || population.at
labels = {}
sources = [[TagVersion, internal, 'internal'], [NicoTagVersion, external, 'external']]
sources.each do |model, ids, kind|
next if ids.empty?
table = model.table_name
model.where(tag_id: ids).where('created_at <= ?', point)
.where("NOT EXISTS (SELECT 1 FROM #{ table } newer " +
"WHERE newer.tag_id = #{ table }.tag_id AND newer.created_at <= ? " +
"AND newer.version_no > #{ table }.version_no)", point).each do |row|
names = row.respond_to?(:names) ? row.names : nil
name = names&.dig(locale.language_code, 'name') || row.name
labels["#{ kind }:#{ row.tag_id }"] = name
end
end
labels
end
end
+15 -12
ファイルの表示
@@ -95,10 +95,14 @@ class PostCreatePlan
def build_default_tag_specs locale, direct_tag_specs
default_tag_specs = []
if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME }
default_tag_specs << { name: TAGME_TAG_NAME, category: :meta }
default_tag_specs << {
name: TAGME_TAG_NAME,
category: :meta }
end
if direct_tag_specs.none? { deerjikist_tag_spec?(locale, _1) }
default_tag_specs << { name: NO_DEERJIKIST_TAG_NAME, category: :meta }
default_tag_specs << {
name: NO_DEERJIKIST_TAG_NAME,
category: :meta }
end
default_tag_specs
@@ -113,9 +117,9 @@ class PostCreatePlan
end
def validate_new_tag_spec! locale, spec
tag = Tag.new(category: spec[:category])
tag_name = TagName.new(tag:, name: spec[:name], language_code: locale.language_code,
tag_name = TagName.new(name: spec[:name], language_code: locale.language_code,
script_code: locale.script_code, primary_flg: true)
tag = Tag.new(category: spec[:category])
return if tag_name.valid? && tag.valid?
post = Post.new
@@ -136,10 +140,10 @@ class PostCreatePlan
end
expanded_parent_specs =
Tag.expand_parent_tags(existing_snapshot_tags)
.reject(&:deprecated?)
.map { |tag|
{ name: tag.name(locale.language_code),
category: tag.category.to_sym }
.reject(&:deprecated?)
.map { |tag|
{ name: tag.name(locale.language_code),
category: tag.category.to_sym }
}
merge_tag_specs(snapshot_tag_specs + expanded_parent_specs)
end
@@ -160,10 +164,9 @@ class PostCreatePlan
def existing_tags_by_name locale
@existing_tags_by_name ||= begin
names = tag_names.map { canonical_tag_name_without_sections(locale, _1) }.uniq
TagName
.where(language_code: locale.language_code, primary_flg: true, name: names)
.includes(:tag)
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag }
TagName.where(language_code: locale.language_code, primary_flg: true, name: names)
.includes(:tag)
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag }
end
end
+1 -1
ファイルの表示
@@ -105,7 +105,7 @@ class PostCreator
category = spec[:category] || spec['category']
next if name.blank? || category.blank?
tag = Tag.find_or_create_by_tag_name!(locale, name, category:, created_by_user: @actor)
tag = Tag.find_or_create_by_tag_name!(locale, name, category:)
tag.update!(category:) if tag.category.to_sym != category.to_sym
tags[name] ||= tag
end.values
-42
ファイルの表示
@@ -1,42 +0,0 @@
# Shared literal parsing and reference resolution for current and snapshot post search.
class PostTagSearch
def self.parse_references(raw, literals)
return nil if raw.blank?
references = JSON.parse(raw)
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
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
negative = reference&.key?('negative') ? reference['negative'] :
token.downcase.start_with?('not:')
{ name:, negative:, reference: }
end
end
def self.references(locale, literal)
reference = literal[:reference]
return [reference['tag_id'], reference['external_tag_id']] if reference
[TagName.where(language_code: locale.language_code, name: literal[:name])
.distinct.pluck(:tag_id),
ExternalTag.where("CONCAT(platform, ':', name) = ?", literal[:name]).pluck(:id)]
end
end
+18 -99
ファイルの表示
@@ -4,134 +4,53 @@ require 'digest'
class SharedResourceIdAllocator
SPACES = {
'tags' => ['tags', 'external_tags'],
'wiki' => ['tag_descriptions', 'tag_groups'],
'wiki_history' => ['tag_description_versions', 'tag_group_versions'] }.freeze
HISTORY_IDS = {
'tags' => [
['tag_versions', 'tag_id'],
['nico_tag_versions', 'tag_id'],
['tag_description_versions', 'tag_id']],
'wiki' => [['tag_group_versions', 'tag_group_id']] }.freeze
'wiki' => ['tag_descriptions', 'tag_groups'] }.freeze
LOCK_TIMEOUT = 10
CONNECTION_LOCKS = ObjectSpace::WeakMap.new
class LockCleanup
def initialize &finish
@finish = finish
end
def trigger_transactional_callbacks? = false
def before_committed! = nil
def committed!(**) = @finish.call(self)
def rolledback!(**) = @finish.call(self)
end
private_constant :LockCleanup
def self.assign! record
space, tables = SPACES.find { |_name, names| names.include?(record.class.table_name) }
raise ArgumentError, 'Unknown shared ID resource' unless tables
connection = record.class.connection
lock_space!(record.class, space)
# 明示-tic な Id. 指定は許可する.
return if record.id.present?
next_id = record.class.uncached do
historical_max = HISTORY_IDS.fetch(space, []).map { |table, column|
quoted_table = connection.quote_table_name(table)
quoted_column = connection.quote_column_name(column)
connection.select_value("SELECT MAX(#{ quoted_column }) FROM #{ quoted_table }").to_i
}.max.to_i
[next_ids(connection, tables).max, historical_max + 1].max
end
record.id = next_id
end
def self.lock_space! record_class, space
raise ArgumentError, 'Unknown shared ID resource' unless SPACES.key?(space)
connection = record_class.connection
transaction = connection.current_transaction
unless transaction.open?
raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction'
end
transaction = record.class.current_transaction
raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction' unless transaction.open?
database = connection.pool.db_config.database
name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }"
held_locks = CONNECTION_LOCKS[connection] ||= {}
return if held_locks.key?(name)
quoted_name = connection.quote(name)
acquired = record_class.uncached do
acquired = record.class.uncached do
connection.select_value("SELECT GET_LOCK(#{ quoted_name }, #{ LOCK_TIMEOUT })")
end
unless acquired.to_i == 1
raise ActiveRecord::LockWaitTimeout, "Shared ID lock unavailable: #{ space }"
end
held_locks[name] = true
finish = lambda do |cleanup|
next unless held_locks.key?(name)
# Savepoint callbacks may run before the outer transaction ends.
# Keep one session lock and transfer its cleanup to the remaining parent.
parent = connection.current_transaction
if parent.open?
connection.add_transaction_record(cleanup)
next
end
result = record_class.uncached do
release = lambda do
result = record.class.uncached do
connection.select_value("SELECT RELEASE_LOCK(#{ quoted_name })")
end
unless result.to_i == 1
raise ActiveRecord::ActiveRecordError, "Shared ID lock release failed: #{ space }"
end
held_locks.delete(name)
rescue StandardError
# Never return a session with an untracked advisory lock to the pool.
begin
connection.disconnect!
ensure
held_locks.clear
end
raise
end
begin
# Rails finalises remaining records in ensure if another callback raises.
connection.add_transaction_record(LockCleanup.new(&finish))
transaction.after_commit(&release)
transaction.after_rollback(&release)
rescue StandardError
begin
connection.disconnect!
ensure
held_locks.clear
end
release.call
raise
end
end
def self.next_ids connection, tables
expiry = connection.select_value('SELECT @@SESSION.information_schema_stats_expiry').to_i
begin
connection.execute('SET SESSION information_schema_stats_expiry = 0')
quoted_tables = tables.map { connection.quote(_1) }.join(', ')
rows = connection.select_all(<<~SQL).to_a
SELECT TABLE_NAME, ENGINE, AUTO_INCREMENT
FROM information_schema.tables
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (#{ quoted_tables })
SQL
persistent = rows.all? do |row|
row['ENGINE'] == 'InnoDB' && row['AUTO_INCREMENT'].to_i.positive?
return if record.id.present?
# Locking reads see committed maxima even after earlier repeatable reads.
maxima = record.class.uncached do
tables.map do |table|
quoted_table = connection.quote_table_name(table)
connection.select_value(
"SELECT id FROM #{ quoted_table } ORDER BY id DESC LIMIT 1 FOR UPDATE").to_i
end
unless rows.size == tables.size && persistent
raise ActiveRecord::ActiveRecordError, 'Persistent InnoDB counters are required'
end
rows.map { _1.fetch('AUTO_INCREMENT').to_i }
ensure
connection.execute("SET SESSION information_schema_stats_expiry = #{ expiry }")
end
record.id = maxima.max + 1
end
private_class_method :next_ids
end
+51 -48
ファイルの表示
@@ -1,69 +1,72 @@
class TagDescriptionOperation
class Conflict < StandardError
;
end
LANGUAGE_CODE = 'ja'
def self.create! locale, tag:, body:, created_by_user:, message: nil
def self.create!(tag:, body:, created_by_user:, message: nil)
ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock!
raise ActiveRecord::RecordNotUnique if TagDescription.exists?(
tag_id: tag.id, language_code: locale.language_code)
tag_id: tag.id, language_code: LANGUAGE_CODE)
description = TagDescription.new(tag:, language_code: locale.language_code)
body = normalise_body!(description, body)
record_version!(description:, body:, user: created_by_user)
description
page = Wiki::CompatibilityWriter.create!(
tag_name: compatibility_tag_name(tag), body:, created_by_user:, message:)
revision = page.wiki_revisions.order(id: :desc).first
record_version!(tag:, revision:, body: revision.body, user: created_by_user,
description: nil)
page
end
end
def self.update! locale, tag:, body:, updated_by_user:, message: nil, base_revision_id: nil
def self.update!(tag:, body:, updated_by_user:, message: nil,
base_revision_id: nil)
ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock!
description = TagDescription.lock.find_by!(
tag_id: tag.id, language_code: locale.language_code)
body = normalise_body!(description, body)
latest = TagDescriptionVersion.find_by(
tag_id: tag.id, language_code: locale.language_code,
version_no: description.version_no)
if base_revision_id.present? && latest&.id != base_revision_id.to_i
raise Conflict, '競合が発生してゐます.'
tag_id: tag.id, language_code: LANGUAGE_CODE)
tag_name = compatibility_tag_name(tag)
page = WikiPage.lock.find_by(tag_name_id: tag_name.id)
unless page
versions = TagDescriptionVersion.where(tag_id: tag.id, language_code: LANGUAGE_CODE)
if base_revision_id.present? && versions.maximum(:id) != base_revision_id.to_i
raise Wiki::Commit::Conflict, '競合が発生してゐます.'
end
page = Wiki::CompatibilityWriter.create!(
tag_name:, body:, created_by_user: updated_by_user, message:)
revision = page.current_revision
record_version!(tag:, revision:, body: revision.body,
user: updated_by_user, description:)
next revision
end
next if latest && normalise_body(description.body) == body
revision = Wiki::CompatibilityWriter.update!(
page:, body:, updated_by_user:, message:,
base_revision_id:)
next unless revision
record_version!(description:, body:, user: updated_by_user)
record_version!(tag:, revision:, body: revision.body,
user: updated_by_user, description:)
revision
end
end
def self.record_version! description:, body:, user:
creating = description.new_record?
version_no = creating ? 1 : description.version_no + 1
description.created_by_user_id = user.id if creating
description.update!(body:, version_no:, updated_by_user_id: user.id)
def self.compatibility_tag_name tag
tag.tag_name(LANGUAGE_CODE) || TagName.find_by!(id: tag.tag_name_id,
language_code: LANGUAGE_CODE)
end
def self.record_version! tag:, revision:, body:, user:, description:
version_no = description ? description.version_no + 1 : 1
TagDescriptionVersion.create!(
tag_id: description.tag_id, language_code: description.language_code,
version_no:, event_type: creating ? 'create' : 'update', body:,
source_language_code: description.source_language_code,
source_version_no: description.source_version_no,
created_by_user_id: user.id, created_at: Time.current)
id: revision.id, tag_id: tag.id, language_code: LANGUAGE_CODE,
version_no:, event_type: description ? 'update' : 'create', body:,
created_by_user_id: user.id, created_at: revision.created_at)
if description
description.update!(body:, version_no:, updated_by_user_id: user.id)
else
TagDescription.create!(
tag_id: tag.id,
language_code: LANGUAGE_CODE, version_no:, body:,
created_by_user_id: user.id, updated_by_user_id: user.id)
end
end
private_class_method :record_version!
def self.normalise_body body
body.to_s.gsub(/\r\n?/, "\n")
.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
.sub(/\n+\z/, '')
end
private_class_method :normalise_body
def self.normalise_body! description, body
normalised = normalise_body(body)
return normalised if normalised.present?
description.errors.add(:body, :blank)
raise ActiveRecord::RecordInvalid, description
end
private_class_method :normalise_body!
end
+21 -22
ファイルの表示
@@ -7,8 +7,8 @@ class TagGroupMarkdownParser
;
end
def self.parse(locale, body:, group: nil)
new(body:, group:).parse(locale)
def self.parse(body:, group: nil)
new(body:, group:).parse
end
def initialize(body:, group:)
@@ -16,7 +16,7 @@ class TagGroupMarkdownParser
@group = group
end
def parse locale
def parse
parse_body = @body.gsub(/([^\n])\n([\x23]{1,6}[ \t]+\S)/, "\\1\n\n\\2")
root = Kramdown::Document.new(parse_body, input: 'GFM', auto_ids: false).root
placements = []
@@ -51,7 +51,7 @@ class TagGroupMarkdownParser
when :ul
invalid!('未対応のリスト項目があります.') if node.attr.any?
target = headings.any? ? headings.last.fetch(:node).fetch('children') : placements
target.concat(node.children.map { parse_list_item(locale, _1) })
target.concat(node.children.map { parse_list_item(_1) })
else
invalid!("未対応の Markdown 構文 #{ node.type.inspect } です.")
end
@@ -66,7 +66,7 @@ class TagGroupMarkdownParser
private
def parse_list_item locale, item
def parse_list_item(item)
invalid!('リスト項目ではありません.') unless item.type == :li
invalid!('未対応のリスト項目があります.') if item.attr.any?
@@ -81,30 +81,29 @@ class TagGroupMarkdownParser
label = own.map { inline_text(_1) }.join(' ').strip
invalid!('空のリスト項目は使用できません.') if label.blank?
node =
if (group_name = TagGroupRepr.name_from_title(label))
group_name = group_name.strip
child_group = TagGroup.find_by(name: group_name)
invalid!("Tag Group #{ group_name.inspect } を解決できません.") unless child_group
invalid!('自分自身を含めることはできません.') if child_group.id == @group&.id
{ 'tag_group_id' => child_group.id }
elsif label.end_with?(':', ':')
section_name = label.sub(/[::]\z/, '').strip
invalid!('Section 名は必須です.') if section_name.blank?
{ 'section_name' => section_name, 'description' => nil }
else
{ 'tag_id' => resolve_tag(locale, label) }
end
node = if label.start_with?(PREFIX)
group_name = label.delete_prefix(PREFIX).strip
child_group = TagGroup.find_by(name: group_name)
invalid!("Tag Group #{ group_name.inspect } を解決できません.") unless child_group
invalid!('自分自身を含めることはできません.') if child_group.id == @group&.id
{ 'tag_group_id' => child_group.id }
elsif label.end_with?(':', ':')
section_name = label.sub(/[::]\z/, '').strip
invalid!('Section 名は必須です.') if section_name.blank?
{ 'section_name' => section_name, 'description' => nil }
else
{ 'tag_id' => resolve_tag(label) }
end
node['children'] = nested.flat_map do |list|
invalid!('未対応の入れ子リストです.') unless list.type == :ul
list.children.map { parse_list_item(locale, _1) }
list.children.map { parse_list_item(_1) }
end
node
end
def resolve_tag locale, name
names = TagName.where(language_code: locale.language_code, name:).to_a
def resolve_tag(name)
names = TagName.where(language_code: 'ja', name:).to_a
tag_ids = names.filter_map(&:tag_id).uniq
invalid!("Tag #{ name.inspect } を解決できません.") if tag_ids.empty?
invalid!("Tag 名 #{ name.inspect } が曖昧です.") if tag_ids.many?
+9 -14
ファイルの表示
@@ -19,20 +19,15 @@ class TagGroupMarkdownRenderer
blocks << clean_text(@group['description'])
end
first_section = placements.index { _1.key?('section_name') }
sections = placements.select { _1.key?('section_name') }
if sections.any? && sections.size != placements.size
raise UnsupportedStructure,
'最上位の見出し Section と Placement の混在は Markdown にできません.'
end
if first_section
if placements.drop(first_section).any? { !_1.key?('section_name') }
raise UnsupportedStructure,
'見出し Section の後に最上位 Placement は配置できません.'
end
if first_section.positive?
blocks << render_list(placements.take(first_section), 0)
end
blocks.concat(placements.drop(first_section).map { render_heading(_1, 2) })
elsif placements.any?
if placements.all? { _1.key?('section_name') }
blocks.concat(placements.map { render_heading(_1, 2) })
else
blocks << render_list(placements, 0)
end
@@ -79,7 +74,7 @@ class TagGroupMarkdownRenderer
first_section = section_indexes.first
if children[first_section..].any? { !_1.key?('section_name') }
raise UnsupportedStructure,
'見出し Section と兄弟 Placement の交互配置は Markdown にできません.'
'見出し Section と兄弟 Placement の交互配置は Markdown にできません.'
end
list_nodes = children[0...first_section]
blocks << render_list(list_nodes, 0) if list_nodes.any?
+77 -29
ファイルの表示
@@ -1,51 +1,49 @@
class TagGroupOperation
MAX_PLACEMENT_DEPTH = 5
PREFIX = TagGroupRepr::PREFIX
def self.create!(locale, name:, description:, members:, placements:, actor:,
require_compatibility: false)
ApplicationRecord.transaction do
def self.create!(name:, description:, members:, placements:, actor:,
require_compatibility: false, compatibility_body: nil)
group, sync_issue = ApplicationRecord.transaction do
group = TagGroup.new(version_no: 1, created_by_user: actor,
updated_by_user: actor)
attributes = prepare!(locale:, group:, name:, description:, members:, placements:)
attributes = prepare!(group:, name:, description:, members:, placements:)
group.assign_attributes(name: attributes.fetch(:name),
description: attributes.fetch(:description))
group.save!
persist_structure!(group:, attributes:, actor:)
validate_compatibility!(locale, group) if require_compatibility
persist_version!(group:, attributes:, actor:, event_type: 'create')
group
issue = sync_compatibility!(group:, actor:, old_name: nil,
require_compatibility:, compatibility_body:)
[group, issue]
end
report_sync_issue(group, actor, sync_issue)
group
end
def self.update!(locale, group:, name:, description:, members:, placements:, actor:,
require_compatibility: false)
ApplicationRecord.transaction do
def self.update!(group:, name:, description:, members:, placements:, actor:,
require_compatibility: false, compatibility_body: nil)
group, sync_issue = ApplicationRecord.transaction do
group.lock!
attributes = prepare!(locale:, group:, name:, description:, members:, placements:)
if unchanged?(group, attributes)
validate_compatibility!(locale, group) if require_compatibility
next group
end
old_name = group.name
attributes = prepare!(group:, name:, description:, members:, placements:)
next [group, nil] if unchanged?(group, attributes)
group.update!(name: attributes.fetch(:name),
description: attributes.fetch(:description),
version_no: group.version_no + 1,
updated_by_user: actor)
persist_structure!(group:, attributes:, actor:)
validate_compatibility!(locale, group) if require_compatibility
persist_version!(group:, attributes:, actor:, event_type: 'update')
group
issue = sync_compatibility!(group:, actor:, old_name:,
require_compatibility:, compatibility_body:)
[group, issue]
end
report_sync_issue(group, actor, sync_issue)
group
end
def self.validate_compatibility! locale, group
TagGroupMarkdownRenderer.render(TagGroupRepr.one(locale, group))
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
invalid!(group, :base, error.message)
end
private_class_method :validate_compatibility!
def self.prepare!(locale:, group:, name:, description:, members:, placements:)
def self.prepare!(group:, name:, description:, members:, placements:)
record = group
normalized_name = name.to_s.strip
invalid!(record, :name, 'グループ名は必須です.') if normalized_name.blank?
@@ -63,7 +61,7 @@ class TagGroupOperation
member_rows = normalize_members(record, members)
placement_tree = normalize_placements(record, placements)
validate_references!(locale, record, member_rows, placement_tree)
validate_references!(record, member_rows, placement_tree)
validate_containment!(record, member_rows)
{ name: normalized_name, description:, members: member_rows,
placements: placement_tree }
@@ -187,7 +185,7 @@ class TagGroupOperation
end
private_class_method :normalize_placement_nodes
def self.validate_references!(locale, record, members, placements)
def self.validate_references!(record, members, placements)
membership = members.to_set
placed = placement_references(placements).uniq
missing = placed - membership.to_a
@@ -206,14 +204,13 @@ class TagGroupOperation
tag_versions = TagVersion.where(tag_id: tag_ids).pluck(:tag_id, :version_no).to_set
group_versions = TagGroupVersion.where(tag_group_id: group_ids)
.pluck(:tag_group_id, :version_no).to_set
tag_names = TagName.where(
tag_id: tag_ids, language_code: locale.language_code, primary_flg: true)
tag_names = TagName.where(tag_id: tag_ids, language_code: 'ja', primary_flg: true)
.pluck(:tag_id).uniq
members.each do |member|
if member.key?('tag_id')
id = member.fetch('tag_id')
invalid!(record, :members, :invalid) unless
invalid!(record, :members, "Tag ##{ id } に日本語の正規名がありません.") unless
tag_names.include?(id)
version_no = tags.fetch(id).version_no
unless tag_versions.include?([id, version_no])
@@ -316,6 +313,57 @@ class TagGroupOperation
end
private_class_method :persist_version!
def self.sync_compatibility!(group:, actor:, old_name:,
require_compatibility:, compatibility_body:)
representation = TagGroupRepr.one(group)
body = begin
TagGroupMarkdownRenderer.render(representation)
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
if compatibility_body.present?
compatibility_body
elsif require_compatibility
invalid!(group, :base, error.message)
else
return error.message
end
end
invalid!(group, :base, '本文は必須です.') if body.blank?
title = "#{ PREFIX }#{ group.name }"
old_title = old_name && "#{ PREFIX }#{ old_name }"
page = if old_title
WikiPage.joins(:tag_name).find_by(tag_names: { name: old_title })
else
nil
end
page ||= WikiPage.joins(:tag_name).find_by(tag_names: { name: title })
if page
tag_name = page.tag_name
tag_name.update!(name: title) if tag_name.name != title
Wiki::Commit.legacy_content!(page:, body:, created_user: actor, message: nil,
base_revision_id: nil)
else
tag_name = TagName.find_or_initialize_by(language_code: 'ja', name: title)
tag_name.script_code = 'Jpan'
tag_name.primary_flg = true
tag_name.auto_generated = false
tag_name.save!
Wiki::CompatibilityWriter.create!(tag_name:, body:, created_by_user: actor)
end
nil
end
private_class_method :sync_compatibility!
def self.report_sync_issue(group, actor, reason)
return unless reason
payload = { status: 'unsupported_structure', tag_group_id: group.id,
version_no: group.version_no, user_id: actor&.id, reason: }
Rails.logger.warn("tag_group_wiki_sync_skipped #{ payload.to_json }")
end
private_class_method :report_sync_issue
def self.placement_references(nodes)
nodes.flat_map do |node|
own = if node.key?('tag_id')
-122
ファイルの表示
@@ -1,122 +0,0 @@
class TagNameCompletion
class << self
def for_locale! locale
completed = Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
certified_names =
if locale.certified_tag_names_path.nil?
{ }
else
CertifiedTagNameTranslations.load!(locale.certified_tag_names_path, locale:)
end
Tag.where(id: certified_names.keys).lock.find_each(order: :asc) do |tag|
for_tag!(tag, locales: [locale],
certified_names: { locale.language_code => certified_names.fetch(tag.id) },
created_by_user: nil)
end
named_tags =
TagName.where(language_code: locale.language_code, primary_flg: true)
.select(:tag_id)
Tag.where.not(id: named_tags).lock.find_each(order: :asc) do |tag|
for_tag!(tag, locales: [locale], created_by_user: nil)
end
true
end
unless completed
raise ActiveRecord::RecordNotSaved, 'Locale tag completion was rolled back'
end
end
def for_tag! tag, locales: nil, certified_names: { }, created_by_user: nil
locales ||=
Locale.where(deprecated_at: nil)
.order(:language_code, :created_at, :script_code).lock
Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
# Creation includes every language in its first snapshot.
creating = tag.new_record?
tag.save! if creating
completed = tag.with_lock do
primary_names = tag.tag_names.where(primary_flg: true).lock.index_by(&:language_code)
certified_names.each do |language, name|
primary = primary_names[language]
next unless primary
next if primary.name == name && !primary.auto_generated
raise CertifiedTagNameTranslations::Error,
"Tag##{ tag.id } already has a primary #{ language } name; " +
"cannot register certified name #{ name.inspect }"
end
missing_locales = locales.to_a.uniq(&:language_code).reject { |locale|
primary_names.key?(locale.language_code)
}
next true if missing_locales.empty? && !creating
TagVersioning.ensure_snapshot!(tag, created_by_user:) unless creating
missing_locales.each do |locale|
primary_names[locale.language_code] =
if certified_names.key?(locale.language_code)
create_certified_name!(locale, tag, certified_names.fetch(locale.language_code))
else
source_language = locale.language_code == 'ja' ? 'ain' : 'ja'
name = TagName.generate_name(locale, tag, primary_names[source_language])
create_name!(locale, tag, name)
end
end
tag.association(:tag_names).reset
event_type = creating ? :create : :update
version = TagVersioning.record!(tag, event_type:, created_by_user:)
unless version&.persisted?
raise ActiveRecord::RecordNotSaved, 'Tag version was not persisted'
end
true
end
unless completed
raise ActiveRecord::RecordNotSaved, 'Tag completion was rolled back'
end
end
end
private
def create_certified_name! locale, tag, name
persist_name!(locale, tag, name, auto_generated: false)
rescue ActiveRecord::RecordInvalid => error
raise unless error.record.is_a?(TagName)
raise CertifiedTagNameTranslations::Error,
"Tag##{ tag.id } certified name #{ name.inspect }: " +
error.record.errors.full_messages.join(', ')
rescue ActiveRecord::RecordNotUnique => error
raise CertifiedTagNameTranslations::Error,
"Tag##{ tag.id } certified name #{ name.inspect } conflicts: #{ error.message }"
end
def create_name! locale, tag, name
persist_name!(locale, tag, name, auto_generated: true)
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error
fallback = "Tag_##{ tag.id }"
invalid_name =
if error.is_a?(ActiveRecord::RecordNotUnique)
TagName.where(language_code: locale.language_code, name:).lock.exists?
else
error.record.errors[:name].present? &&
error.record.errors.attribute_names.all? { _1 == :name }
end
raise if name == fallback || !(invalid_name)
persist_name!(locale, tag, fallback, auto_generated: true)
end
def persist_name! locale, tag, name, auto_generated:
tag_name = TagName.transaction(requires_new: true) do
TagName.create!(tag:, name:, language_code: locale.language_code,
script_code: locale.script_code, primary_flg: true, auto_generated:)
end
unless tag_name&.persisted?
raise ActiveRecord::RecordNotSaved, 'Generated tag name was not persisted'
end
tag_name
end
end
end
-56
ファイルの表示
@@ -1,56 +0,0 @@
module TagNameTranscription
class Unconvertible < StandardError
;
end
def self.ainu name
previous_script = nil
parts = []
NipponeseSegments.split(name).each do |script, text|
output = transcribe_segment(script, text)
if (parts.last && output.first && previous_script && script != previous_script &&
NipponeseSegments.nipponese?(previous_script) && NipponeseSegments.nipponese?(script))
parts << '_'
end
parts.concat(output)
previous_script = script
end
replace_unconvertible(parts)
end
def self.transcribe_segment script, text
return [text] if script == :literal
return [nil] if script == :unconvertible
return transcribe_reading(text) unless script == :kanzi
text.each_char.flat_map do |character|
transcribe_reading(JisReadings.read(character))
rescue Unconvertible
[nil]
end
end
def self.transcribe_reading reading
Phonology.adapt(reading).map do |sound|
sound && Katakana.render([sound])
rescue Unconvertible
nil
end
end
def self.replace_unconvertible parts
grouped = parts.chunk { _1.nil? }.flat_map { |unconvertible, group|
unconvertible ? [nil] : group
}
first = grouped.index { _1&.match?(/[^_]/) }
last = grouped.rindex { _1&.match?(/[^_]/) }
grouped.each_with_index.map { |part, index|
part || (first && first < index && index < last ? '_' : '')
}.join
end
private_class_method :transcribe_segment, :transcribe_reading, :replace_unconvertible
end
-13
ファイルの表示
@@ -1,13 +0,0 @@
module TagNameTranscription
module JisReadings
SOURCE = Rails.root.join('config/tag_name_readings/jis_level_one.tsv')
READINGS = File.foreach(SOURCE, chomp: true).reject { _1.start_with?('#') }.to_h { |line|
character, reading, = line.split("\t")
[character, reading]
}.freeze
def self.read text
text.each_char.map { READINGS.fetch(_1) { raise Unconvertible } }.join
end
end
end
-26
ファイルの表示
@@ -1,26 +0,0 @@
module TagNameTranscription
module Katakana
ROWS = {
'' => 'アイウエオ',
'k' => 'カキクケコ',
's' => 'サシスセソ',
'c' => ['チャ', 'チ', 'チュ', 'チェ', 'チョ'],
't' => ['タ', 'ティ', 'トゥ', 'テ', 'ト'],
'n' => 'ナニヌネノ',
'h' => 'ハヒフヘホ',
'p' => 'パピプペポ',
'm' => 'マミムメモ',
'r' => 'ラリルレロ' }.freeze
SYLLABLES = ROWS.flat_map { |onset, letters|
kana = letters.is_a?(String) ? letters.chars : letters
['a', 'i', 'u', 'e', 'o'].zip(kana).map { |vowel, letter| [onset + vowel, letter] }
}.to_h.merge(
'ya' => 'ヤ', 'yu' => 'ユ', 'yo' => 'ヨ', 'wa' => 'ワ',
'k' => 'ㇰ', 's' => 'ㇱ', 't' => 'ッ', 'n' => 'ン', 'm' => 'ㇺ', 'p' => 'ㇷ゚',
'r:a' => 'ㇻ', 'r:i' => 'ㇼ', 'r:u' => 'ㇽ', 'r:e' => 'ㇾ', 'r:o' => 'ㇿ').freeze
def self.render sounds
sounds.map { SYLLABLES.fetch(_1) { raise Unconvertible } }.join
end
end
end
-30
ファイルの表示
@@ -1,30 +0,0 @@
module TagNameTranscription
module NipponeseSegments
NIPPONESE_SCRIPTS = [:kanzi, :xiragana, :katakana].freeze
def self.nipponese?(script) = NIPPONESE_SCRIPTS.include?(script)
def self.split name
segments = []
name.to_s.scan(/\X/).each do |character|
script = classify(character, segments.last&.first)
if segments.last&.first == script
segments.last[1] << character
else
segments << [script, character.dup]
end
end
segments
end
def self.classify character, previous_script
return previous_script if character == 'ー' && nipponese?(previous_script)
return :kanzi if character.match?(/\A\p{Han}\z/)
return :xiragana if character.match?(/\A\p{Hiragana}\p{M}*\z/)
return :katakana if character.match?(/\A\p{Katakana}\p{M}*\z/)
return :literal if character.match?(/\A(?:\p{Latin}\p{M}*|[!-~])\z/)
:unconvertible
end
end
end
-59
ファイルの表示
@@ -1,59 +0,0 @@
module TagNameTranscription
module Phonology
ROWS = {
'アイウエオ' => ['a', 'i', 'u', 'e', 'o'],
'カキクケコ' => ['ka', 'ki', 'ku', 'ke', 'ko'],
'ガギグゲゴ' => ['ka', 'ki', 'ku', 'ke', 'ko'],
'サシスセソ' => ['sa', 'si', 'su', 'se', 'so'],
'ザジズゼゾ' => ['ca', 'ci', 'cu', 'ce', 'co'],
'タチツテト' => ['ta', 'ci', 'cu', 'te', 'to'],
'ダヂヅデド' => ['ta', 'ci', 'cu', 'te', 'to'],
'ナニヌネノ' => ['na', 'ni', 'nu', 'ne', 'no'],
'ハヒフヘホ' => ['ha', 'hi', 'hu', 'he', 'ho'],
'バビブベボ' => ['pa', 'pi', 'pu', 'pe', 'po'],
'パピプペポ' => ['pa', 'pi', 'pu', 'pe', 'po'],
'マミムメモ' => ['ma', 'mi', 'mu', 'me', 'mo'],
'ヤユヨ' => ['ya', 'yu', 'yo'],
'ラリルレロ' => ['ra', 'ri', 'ru', 're', 'ro'],
'ワ' => ['wa'],
'ン' => ['n'],
'ッ' => ['t'],
'ㇰ' => ['k'],
'ㇱㇲ' => ['s', 's'],
'ㇳ' => ['t'],
'ㇴ' => ['n'],
'ㇺ' => ['m'],
'ㇻㇼㇽㇾㇿ' => ['r:a', 'r:i', 'r:u', 'r:e', 'r:o'] }.freeze
SOUNDS = ROWS.flat_map { |characters, sounds| characters.chars.zip(sounds) }.to_h.freeze
DIGRAPHS = {
'シャ' => 'sa', 'シュ' => 'su', 'シェ' => 'se', 'ショ' => 'so',
'チャ' => 'ca', 'チュ' => 'cu', 'チェ' => 'ce', 'チョ' => 'co',
'ジャ' => 'ca', 'ジュ' => 'cu', 'ジェ' => 'ce', 'ジョ' => 'co',
'ヂャ' => 'ca', 'ヂュ' => 'cu', 'ヂェ' => 'ce', 'ヂョ' => 'co' }.freeze
def self.adapt reading
kana = reading.unicode_normalize(:nfc).tr('ぁ-ゖ', 'ァ-ヶ').delete('ー')
characters = kana.scan(/\X/)
sounds = []
index = 0
while index < characters.length
pair = characters[index, 2].join
if DIGRAPHS.key?(pair)
sounds << DIGRAPHS.fetch(pair)
index += 2
next
end
character = characters[index].tr('ャュョ', 'ヤユヨ')
sounds << (character == 'ㇷ゚' ? 'p' : SOUNDS[character])
index += 1
end
sounds
end
end
end
+6 -22
ファイルの表示
@@ -1,9 +1,6 @@
class TagVersionRecorder < VersionRecorder
def self.record! tag:, event_type:, created_by_user:, **_options
Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
new(tag:, event_type:, created_by_user:).record!
end
new(tag:, event_type:, created_by_user:).record!
end
def initialize tag:, event_type:, created_by_user:
@@ -18,39 +15,26 @@ class TagVersionRecorder < VersionRecorder
def snapshot_attributes
names = snapshot_names
compatibility_names = names['ja'] || names['ain'] || names.values.first
unless compatibility_names
@record.errors.add(:tag_names, :invalid)
raise ActiveRecord::RecordInvalid, @record
end
parent_ids =
TagImplication.where(tag_id: @record.id).order(:parent_tag_id)
.lock.pluck(:parent_tag_id)
compatibility_names = names.fetch('ja')
{ name: compatibility_names.fetch('name'),
category: @record.category,
deprecated_at: @record.deprecated_at,
aliases: compatibility_names.fetch('aliases').map { _1.fetch('name') }.join(' '),
names:,
parent_tag_ids: parent_ids.join(' ') }
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
end
def snapshot_names
@record.tag_names.order(:language_code, :name).lock.group_by(&:language_code)
@record.tag_names.order(:language_code, :name).group_by(&:language_code)
.transform_values do |tag_names|
primary = tag_names.find(&:primary?)
unless primary
@record.errors.add(:tag_names, :invalid,
language_code: tag_names.first.language_code)
raise ActiveRecord::RecordInvalid, @record
end
aliases = tag_names.reject(&:primary?).sort_by(&:name).map { |tag_name|
{ 'name' => tag_name.name,
'script_code' => Script.codes.fetch(tag_name.script_code, tag_name.script_code) }
{ 'name' => tag_name.name, 'script_code' => tag_name.script_code }
}
{ 'name' => primary.name,
'script_code' => Script.codes.fetch(primary.script_code, primary.script_code),
'script_code' => primary.script_code,
'auto_generated' => primary.auto_generated,
'aliases' => aliases }
end
+4 -13
ファイルの表示
@@ -4,23 +4,14 @@ class TagVersioning
end
def self.ensure_snapshot! tag, created_by_user:, language_code: 'ja'
Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
locked_tag = Tag.unscoped.lock.find(tag.id)
next if locked_tag.tag_versions.lock.exists?
return if tag.tag_versions.exists?
TagVersionRecorder.record!(
tag: locked_tag, event_type: :create, created_by_user:, language_code:)
end
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:, language_code:)
end
def self.record_tag_snapshot! tag, created_by_user:, language_code: 'ja'
Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
locked_tag = Tag.unscoped.lock.find(tag.id)
event_type = locked_tag.tag_versions.lock.exists? ? :update : :create
record!(locked_tag, event_type:, created_by_user:, language_code:)
end
event_type = tag.tag_versions.exists? ? :update : :create
record!(tag, event_type:, created_by_user:, language_code:)
end
def self.record_tag_snapshots! tags, created_by_user:, language_code: 'ja'
+1 -1
ファイルの表示
@@ -35,7 +35,7 @@ class VersionRecorder
private
def latest_version = versions.order(version_no: :desc).lock.first
def latest_version = versions.order(version_no: :desc).first
def versions = @record.public_send(version_association)
+164
ファイルの表示
@@ -0,0 +1,164 @@
require 'digest'
module Wiki
class Commit
class Conflict < StandardError
;
end
def self.create_content! tag_name:, body:, created_by_user:, message: nil
if tag_name.tag && tag_name.language_code == 'ja' && !tag_name.name.include?(':')
return TagDescriptionOperation.create!(
tag: tag_name.tag, body:, created_by_user:, message:)
end
tag_name.errors.add(:base, :invalid)
raise ActiveRecord::RecordInvalid, tag_name
end
def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil
normalised = normalise_body(body)
page = WikiPage.new(tag_name:,
body: normalised,
created_user: created_by_user,
updated_user: created_by_user)
if normalised.blank?
page.errors.add(:body, :blank)
raise ActiveRecord::RecordInvalid, page
end
ActiveRecord::Base.transaction do
page.save!
new(page:, created_user: created_by_user).content!(
body: normalised,
message:,
base_revision_id: nil)
page
end
end
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
description = TagDescription.find_by(tag_id: page.tag_name.tag_id, language_code: 'ja')
description ||= TagDescription.joins(:tag).find_by(
tags: { tag_name_id: page.tag_name_id }, language_code: 'ja')
if description && !page.title.include?(':')
return TagDescriptionOperation.update!(
tag: description.tag, body:, updated_by_user: created_user,
message:, base_revision_id:)
end
page.errors.add(:base, :invalid)
raise ActiveRecord::RecordInvalid, page
end
def self.legacy_content! page:, body:, created_user:, message: nil,
base_revision_id: nil
new(page:, created_user:).content!(body:, message:, base_revision_id:)
end
def self.redirect! page:, redirect_page:, created_user:, message: nil, base_revision_id: nil
new(page:, created_user:).redirect!(redirect_page:, message:, base_revision_id:)
end
def initialize page:, created_user:
@page = page
@created_user = created_user
end
def content! body:, message:, base_revision_id:
normalised = self.class.normalise_body(body)
if normalised.blank?
@page.errors.add(:body, :blank)
raise ActiveRecord::RecordInvalid, @page
end
lines = split_lines(normalised)
line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) }
tree_sha = Digest::SHA256.hexdigest(line_shas.join(','))
ActiveRecord::Base.transaction do
@page.lock!
if base_revision_id.present?
current_id = @page.wiki_revisions.maximum(:id)
if current_id && current_id != base_revision_id.to_i
raise Conflict,
"競合が発生してゐます" +
"(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
end
end
line_id_by_sha = upsert_lines!(lines, line_shas)
line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) }
@page.update!(body: normalised)
WikiVersionRecorder.record!(
page: @page,
event_type: @page.wiki_versions.exists? ? :update : :create,
reason: message,
created_by_user: @created_user)
rev = WikiRevision.create!(
wiki_page: @page,
base_revision_id:,
created_user: @created_user,
kind: :content,
redirect_page_id: nil,
message:,
lines_count: lines.length,
tree_sha256: tree_sha)
rows = line_ids.each_with_index.map do |line_id, pos|
{ wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos }
end
WikiRevisionLine.insert_all!(rows) if rows.any?
rev
end
end
def redirect!(redirect_page:, message:, base_revision_id:) = raise '廃止しました.'
def self.normalise_body body
s = body.to_s
s.gsub!(/\r\n?/, "\n")
s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
s.gsub(/\n+$/, '')
end
private
def split_lines(body) = body.split("\n")
def upsert_lines! lines, line_shas
now = Time.current
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
missing_by_sha = { }
line_shas.each_with_index do |sha, i|
next if id_by_sha.key?(sha)
next if missing_by_sha.key?(sha)
missing_by_sha[sha] = {
sha256: sha,
body: lines[i],
created_at: now,
updated_at: now }
end
if missing_by_sha.any?
WikiLine.upsert_all(missing_by_sha.values)
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
end
id_by_sha
end
end
end
+43
ファイルの表示
@@ -0,0 +1,43 @@
module Wiki
class CompatibilityWriter
def self.create! tag_name:, body:, created_by_user:, message: nil
ApplicationRecord.transaction do
tag_name.lock!
page = WikiPage.lock.find_by(tag_name_id: tag_name.id)
if page
update!(page:, body:, updated_by_user: created_by_user, message:)
page
else
Commit.create_legacy_content!(tag_name:, body:, created_by_user:, message:)
end
end
end
def self.update! page:, body:, updated_by_user:, message: nil,
base_revision_id: nil
normalised = Commit.normalise_body(body)
if normalised.blank?
return Commit.legacy_content!(
page:, body: normalised, created_user: updated_by_user, message:,
base_revision_id:)
end
if base_revision_id.present?
current_id = page.wiki_revisions.maximum(:id)
if current_id && current_id != base_revision_id.to_i
raise Commit::Conflict,
"競合が発生してゐます" +
"(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
end
end
if Commit.normalise_body(page.body) == normalised && page.wiki_revisions.exists?
return
end
Commit.legacy_content!(
page:, body: normalised, created_user: updated_by_user, message:,
base_revision_id:)
end
end
end
+21
ファイルの表示
@@ -0,0 +1,21 @@
class WikiVersionRecorder < VersionRecorder
def self.record! page:, event_type:, reason: nil, created_by_user:
new(page:, event_type:, reason:, created_by_user:).record!
end
def initialize page:, event_type:, reason: nil, created_by_user:
@reason = reason
super(record: page, event_type:, created_by_user:)
end
private
def version_class = WikiVersion
def version_association = :wiki_versions
def record_key = :wiki_page
def snapshot_attributes = {
title: @record.title,
body: @record.body,
reason: @reason }
end
-9
ファイルの表示
@@ -31,14 +31,5 @@ module Backend
# Middleware like session, flash, cookies can be added back manually.
# Skip views, helpers and assets when generating a new resource.
config.api_only = true
config.middleware.use ActionDispatch::Cookies
config.session_store :cookie_store,
key: Rails.env.production? ? '__Host-btrc-session' : 'btrc-session',
path: '/',
secure: Rails.env.production?,
httponly: true,
same_site: :lax,
expire_after: 20.years
config.middleware.use ActionDispatch::Session::CookieStore, config.session_options
end
end
+2 -2
ファイルの表示
@@ -4,5 +4,5 @@
# Use this to limit dissemination of sensitive information.
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
Rails.application.config.filter_parameters += [
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate,
:otp, :ssn, :cvv, :cvc, :code, :inheritance_code]
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
]
-19
ファイルの表示
@@ -1,17 +1,4 @@
Rails.application.routes.draw do
get 'counts/definitions/:resource', to: 'counts_tables#definition'
get 'counts/query', to: 'counts_tables#index'
post 'counts/export/prepare', to: 'counts_exports#prepare'
get 'counts/export/files/:token', to: 'counts_exports#download'
get 'counts/export', to: 'counts_exports#index'
post 'counts/export', to: 'counts_exports#index'
get 'counts/state', to: 'counts_states#index'
get 'counts/posts', to: 'counts_posts#index'
get 'counts/tags', to: 'counts_tags#index'
resources :locales, only: [:index]
resources :sitemap_entries, only: [:index, :show], param: :resource
resources :tag_groups, only: [:index, :show, :create, :update]
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
@@ -56,7 +43,6 @@ Rails.application.routes.draw do
collection do
get :search, action: :index
get :changes
get :autolink_candidates
scope :title do
get ':title/exists', action: :exists_by_title
@@ -67,14 +53,12 @@ Rails.application.routes.draw do
member do
get :exists
get :diff
get :tag, action: :tag_reference
end
end
resources :posts, only: [:index, :show, :create, :update] do
collection do
get :metadata
get :tag_filters
post :bulk
get :random
get :versions, to: 'post_versions#index'
@@ -113,9 +97,6 @@ Rails.application.routes.draw do
collection do
post :verify
get :me
get :session, action: :show_session
get :csrf
delete :session, action: :destroy_session
post 'code/renew', action: :renew
end
end
ファイル差分が大きすぎるため省略します 差分を読み込み
-9
ファイルの表示
@@ -1,9 +0,0 @@
class DropWikiPages < ActiveRecord::Migration[8.0]
def change
drop_table :wiki_versions
drop_table :wiki_revision_lines
drop_table :wiki_lines
drop_table :wiki_revisions
drop_table :wiki_pages
end
end
-11
ファイルの表示
@@ -1,11 +0,0 @@
class RemoveTagNameIdFromTags < ActiveRecord::Migration[8.0]
def up
remove_foreign_key :tags, column: :tag_name_id
remove_foreign_key :tag_names, column: :canonical_id
remove_column :tags, :tag_name_id
remove_column :tag_names, :canonical_id
end
def down = raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
-15
ファイルの表示
@@ -1,15 +0,0 @@
class DeleteNameForWikiFromTagNames < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE
FROM
tag_names
WHERE
tag_id IS NULL
SQL
change_column_null :tag_names, :tag_id, false
end
def down = raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
生成ファイル
+89 -2
ファイルの表示
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -578,15 +578,17 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
end
create_table "tag_names", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.bigint "tag_id"
t.string "language_code", limit: 16, null: false
t.string "name", null: false
t.string "script_code", limit: 4, null: false
t.boolean "primary_flg", null: false
t.boolean "auto_generated", default: false, null: false
t.bigint "canonical_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.virtual "primary_tag_id", type: :bigint, as: "(case when `primary_flg` then `tag_id` else NULL end)"
t.index ["canonical_id"], name: "index_tag_names_on_canonical_id"
t.index ["language_code", "name"], name: "index_tag_names_on_language_code_and_name", unique: true
t.index ["primary_tag_id", "language_code"], name: "index_tag_names_on_primary_tag_id_and_language_code", unique: true
t.index ["script_code"], name: "fk_rails_dd783b3d1c"
@@ -623,6 +625,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
end
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false
t.string "category", default: "general", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false
@@ -630,6 +633,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
t.integer "post_count", default: 0, null: false
t.integer "version_no", null: false
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
end
@@ -769,6 +773,76 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
t.index ["banned_at"], name: "index_users_on_banned_at"
end
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false
t.text "body", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["sha256"], name: "index_wiki_lines_on_sha256", unique: true
end
create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false
t.text "body", null: false
t.bigint "created_user_id", null: false
t.bigint "updated_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.integer "version_no", null: false
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id"
t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive"
end
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_revision_id", null: false
t.integer "position", null: false
t.bigint "wiki_line_id", null: false
t.index ["wiki_line_id"], name: "index_wiki_revision_lines_on_wiki_line_id"
t.index ["wiki_revision_id", "position"], name: "index_wiki_revision_lines_on_wiki_revision_id_and_position", unique: true
t.index ["wiki_revision_id", "wiki_line_id"], name: "index_wiki_revision_lines_on_wiki_revision_id_and_wiki_line_id"
t.index ["wiki_revision_id"], name: "index_wiki_revision_lines_on_wiki_revision_id"
end
create_table "wiki_revisions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.bigint "base_revision_id"
t.bigint "created_user_id", null: false
t.integer "kind", default: 0, null: false
t.bigint "redirect_page_id"
t.string "message"
t.integer "lines_count", default: 0, null: false
t.string "tree_sha256", limit: 64
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["base_revision_id"], name: "index_wiki_revisions_on_base_revision_id"
t.index ["created_user_id"], name: "index_wiki_revisions_on_created_user_id"
t.index ["kind"], name: "index_wiki_revisions_on_kind"
t.index ["redirect_page_id"], name: "index_wiki_revisions_on_redirect_page_id"
t.index ["tree_sha256"], name: "index_wiki_revisions_on_tree_sha256"
t.index ["wiki_page_id", "id"], name: "index_wiki_revisions_on_wiki_page_id_and_id"
t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id"
end
create_table "wiki_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "title", null: false
t.text "body", null: false
t.text "reason"
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id"
t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true
t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions"
@@ -841,10 +915,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
add_foreign_key "tag_implications", "tags", column: "parent_tag_id"
add_foreign_key "tag_names", "languages", column: "language_code", primary_key: "code"
add_foreign_key "tag_names", "scripts", column: "script_code", primary_key: "code"
add_foreign_key "tag_names", "tag_names", column: "canonical_id"
add_foreign_key "tag_names", "tags"
add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
add_foreign_key "tag_versions", "users", column: "created_by_user_id"
add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres"
add_foreign_key "theatre_comments", "users"
add_foreign_key "theatre_programmes", "posts"
@@ -869,4 +945,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
add_foreign_key "user_post_views", "posts"
add_foreign_key "user_post_views", "users"
add_foreign_key "user_theme_slots", "users"
add_foreign_key "wiki_pages", "tag_names"
add_foreign_key "wiki_pages", "users", column: "created_user_id"
add_foreign_key "wiki_pages", "users", column: "updated_user_id"
add_foreign_key "wiki_revision_lines", "wiki_lines"
add_foreign_key "wiki_revision_lines", "wiki_revisions"
add_foreign_key "wiki_revisions", "users", column: "created_user_id"
add_foreign_key "wiki_revisions", "wiki_pages"
add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id"
add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id"
add_foreign_key "wiki_versions", "users", column: "created_by_user_id"
add_foreign_key "wiki_versions", "wiki_pages"
end
-5
ファイルの表示
@@ -8,11 +8,6 @@
# MovieGenre.find_or_create_by!(name: genre_name)
# end
ApplicationRecord.transaction do
Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' }
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
end
post_url_sanitisation_rules = [
{ priority: 10,
source_pattern: '\Ahttps?://youtu\.be/([^/?#]+)(?:[?#].*)?\z',
+34
ファイルの表示
@@ -0,0 +1,34 @@
require 'rails_helper'
require_relative '../../db/migrate/20261003010000_migrate_tag_descriptions_from_wiki_pages'
RSpec.describe 'Tag description migration identity' do
it 'preserves every revision ID and repeated body in an independent sequence' do
user = create_member_user!
tag_name = create(:tag_name, name: 'description_migration')
page = Wiki::Commit.create_legacy_content!(
tag_name:, body: 'A', created_by_user: user)
revisions = [page.current_revision]
['A', 'B', 'B', 'A'].each do |body|
revisions << Wiki::Commit.legacy_content!(
page:, body:, created_user: user, base_revision_id: revisions.last.id)
end
tag = create(:tag, primary_tag_name: tag_name)
legacy_state = [page.reload.attributes, revisions.map(&:attributes)]
expect(page.wiki_versions.count).to eq(3)
migration = MigrateTagDescriptionsFromWikiPages.new
migration.suppress_messages { migration.up }
versions = TagDescriptionVersion.where(tag_id: tag.id, language_code: 'ja')
.order(:version_no)
expect(versions.pluck(:id)).to eq(revisions.map(&:id))
expect(versions.pluck(:version_no)).to eq([1, 2, 3, 4, 5])
expect(versions.pluck(:body)).to eq(['A', 'A', 'B', 'B', 'A'])
expect(versions.pluck(:created_by_user_id)).to eq([user.id] * 5)
expect(versions.pluck(:created_at)).to eq(revisions.map(&:created_at))
expect(TagDescription.find(page.id)).to have_attributes(
tag_id: tag.id, language_code: 'ja', version_no: 5, body: 'A')
expect([page.reload.attributes, revisions.map { |revision| revision.reload.attributes }])
.to eq(legacy_state)
end
end
+119
ファイルの表示
@@ -0,0 +1,119 @@
require 'rails_helper'
RSpec.describe 'Tag group migration identity and structure' do
include TagGroupRecords
let!(:user) { create_member_user! }
before { I18n.locale = :ja }
it 'preserves page and revision IDs, timestamps and repeated historical structures' do
tag = group_tag('洗澡鹿')
body = "説明文\n\n- 洗澡鹿\n- 洗澡鹿"
page = legacy_group('伊地知ニジカ亜種', body)
revisions = [page.current_revision]
[body, '# 空の区分', body].each do |text|
revisions << Wiki::Commit.legacy_content!(page:, body: text, created_user: user)
end
before_state = group_state.except(*TagGroupRecords::GROUP_TABLES)
migrate_groups
versions = group_rows('tag_group_versions', tag_group_id: page.id)
.sort_by { _1.fetch('version_no') }
expect(versions.map { _1.fetch('id') }).to eq(revisions.map(&:id))
expect(versions.map { _1.fetch('version_no') }).to eq([1, 2, 3, 4])
expect(versions.map { _1.fetch('created_at') }).to eq(revisions.map(&:created_at))
expect(versions.map { _1.fetch('created_by_user_id') }).to eq([user.id] * 4)
expect(versions.map { _1.fetch('event_type') }).to eq(['create', 'update', 'update', 'update'])
expect(group_version(page.id, 1).fetch('placements')).to eq(
group_version(page.id, 2).fetch('placements'))
expect(group_version(page.id, 3).fetch('placements')).to eq([
{ 'section_name' => '空の区分', 'description' => nil, 'children' => [] }])
expect_group_snapshot(
page.id, number: 4, description: '説明文',
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }],
placements: [{ 'tag_id' => tag.id, 'children' => [] }] * 2)
expect(group_rows('tag_groups', id: page.id).sole).to include(
'created_at' => page.created_at, 'updated_at' => page.reload.updated_at)
expect(group_state.except(*TagGroupRecords::GROUP_TABLES)).to eq(before_state)
end
it 'migrates nested list Sections and child groups as different placement kinds' do
series = group_tag('ぼざろクリーチャーシリーズ')
deer = group_tag('洗澡鹿')
nijika = group_tag('伊地知ニジカ')
child = legacy_group('伊地知ニジカ亜種', <<~MARKDOWN)
- ぼざろクリーチャーシリーズ
- 伊地知ニジカ亜種:
- 洗澡鹿
MARKDOWN
parent = legacy_group('ぼざろクリーチャーシリーズのキャラクター', <<~MARKDOWN)
- ぼざろクリーチャーシリーズ
- 伊地知ニジカ
- タググループ:伊地知ニジカ亜種
MARKDOWN
migrate_groups
expect_group_snapshot(
child.id, number: 1,
members: [series, deer].map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } },
placements: [{ 'tag_id' => series.id, 'children' => [
{ 'section_name' => '伊地知ニジカ亜種', 'description' => nil,
'children' => [{ 'tag_id' => deer.id, 'children' => [] }] }] }])
expect_group_snapshot(
parent.id, number: 1,
members: [
{ 'tag_id' => series.id, 'version_no' => series.version_no },
{ 'tag_id' => nijika.id, 'version_no' => nijika.version_no },
{ 'tag_group_id' => child.id, 'version_no' => 1 }],
placements: [{ 'tag_id' => series.id, 'children' => [
{ 'tag_id' => nijika.id, 'children' => [
{ 'tag_group_id' => child.id, 'children' => [] }] }] }])
end
it 'preserves heading order, group paragraphs and Section descriptions' do
names = ['タグ希望', 'キャラクター名希望', '楽曲情報希望', 'bot操作', 'ニジラー情報不詳']
tags = names.map { group_tag(_1) }
markdown = <<~MARKDOWN
全体の説明
# 要求
要求の説明
- タグ希望
- キャラクター名希望
- 楽曲情報希望
# 情報
- bot操作
- ニジラー情報不詳
MARKDOWN
page = legacy_group('メタタグ', markdown)
expect(page.current_revision.body).to eq(markdown.chomp)
migrate_groups
leaves = tags.map { { 'tag_id' => _1.id, 'children' => [] } }
leaves.first['children'] = leaves[1..2]
expect_group_snapshot(
page.id, number: 1, description: '全体の説明',
members: tags.map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } },
placements: [
{ 'section_name' => '要求', 'description' => '要求の説明',
'children' => [leaves.first] },
{ 'section_name' => '情報', 'description' => nil,
'children' => leaves[3..4] }])
end
it 'leaves Help in the legacy domain' do
page = Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:移行対象外'),
body: 'Help 本文', created_by_user: user)
before_state = group_state
migrate_groups
expect(group_state).to eq(before_state)
expect(page.reload.body).to eq('Help 本文')
end
end
+5 -16
ファイルの表示
@@ -1,17 +1,11 @@
require 'rails_helper'
RSpec.describe 'TagName database invariants' do
def insert_name! **attributes
defaults = {
tag_id: create(:tag, primary_tag_name: nil).id,
name: 'constraint_name',
language_code: 'ja',
script_code: 'Jpan',
primary_flg: true,
created_at: Time.current,
updated_at: Time.current }
TagName.insert_all!([defaults.merge(attributes)])
def insert_name!(**attributes)
TagName.insert_all!([{ name: 'constraint_name', language_code: 'ja',
script_code: 'Jpan', primary_flg: true,
created_at: Time.current, updated_at: Time.current,
**attributes }])
end
it 'defaults persisted names to human-confirmed provenance' do
@@ -39,9 +33,4 @@ RSpec.describe 'TagName database invariants' do
.to raise_error(ActiveRecord::RecordNotUnique)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
end
it 'rejects a name without a tag' do
expect { insert_name!(tag_id: nil) }
.to raise_error(ActiveRecord::NotNullViolation)
end
end
-23
ファイルの表示
@@ -1,23 +0,0 @@
require 'rails_helper'
RSpec.describe 'Schema after Wiki removal' do
it 'has no legacy Wiki tables or name indirection columns' do
connection = ApplicationRecord.connection
['wiki_pages', 'wiki_revisions', 'wiki_lines', 'wiki_revision_lines', 'wiki_versions']
.each do |table|
expect(connection.table_exists?(table)).to be(false)
end
expect(connection.column_exists?(:tags, :tag_name_id)).to be(false)
expect(connection.column_exists?(:tag_names, :canonical_id)).to be(false)
end
it 'requires TagName ownership while keeping the native resources and history' do
connection = ApplicationRecord.connection
expect(TagName.columns_hash.fetch('tag_id').null).to be(false)
expect(connection.foreign_key_exists?(:tag_names, :tags)).to be(true)
['tag_descriptions', 'tag_description_versions', 'tag_groups', 'tag_group_versions']
.each do |table|
expect(connection.table_exists?(table)).to be(true)
end
end
end
-2
ファイルの表示
@@ -6,8 +6,6 @@ FactoryBot.define do
auto_generated { false }
sequence(:name) { |number| "tag-#{ SecureRandom.hex(4) }-#{ number }" }
association :tag, primary_tag_name: nil
trait :alias do
primary_flg { false }
end
+18 -7
ファイルの表示
@@ -4,7 +4,7 @@ FactoryBot.define do
primary_name { nil }
primary_tag_name do
attributes = primary_name.nil? ? { } : { name: primary_name }
build(:tag_name, tag: nil, **attributes)
build(:tag_name, **attributes)
end
end
@@ -12,19 +12,30 @@ FactoryBot.define do
post_count { 0 }
after(:build) do |tag, evaluator|
next unless evaluator.primary_tag_name
evaluator.primary_tag_name.primary_flg = true
evaluator.primary_tag_name.tag = tag
tag.tag_names.target << evaluator.primary_tag_name
end
after(:create) do |tag, evaluator|
next unless evaluator.primary_tag_name
before(:create) do |tag, evaluator|
evaluator.primary_tag_name.save!
# Compatibility write only; ownership is established through tag_id below.
tag.tag_name_id = evaluator.primary_tag_name.id
end
after(:create) do |tag, evaluator|
evaluator.primary_tag_name.update!(tag:, primary_flg: true)
TagNameCompletion.for_tag!(tag)
Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code).to_a
.uniq(&:language_code).each do |locale|
next if tag.tag_names.exists?(language_code: locale.language_code,
primary_flg: true)
create(:tag_name,
tag:, language_code: locale.language_code,
script_code: locale.script_code,
name: TagName.generate_name(locale, tag, evaluator.primary_tag_name),
primary_flg: true, auto_generated: true)
end
end
end
end
+9
ファイルの表示
@@ -0,0 +1,9 @@
FactoryBot.define do
factory :wiki_page do
title { "TestPage" }
association :created_user, factory: :user
association :updated_user, factory: :user
body { ' ' }
end
end
+3 -2
ファイルの表示
@@ -6,8 +6,9 @@ RSpec.describe Language, type: :model do
expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['ja'])
end
it 'accepts Ainu when multilingual support is enabled' do
expect { described_class.create!(code: 'ain', name: 'ain') }
it 'accepts English when multilingual support is enabled' do
pending '日本語以外の Language 有効化は別課題で実装する'
expect { described_class.create!(code: 'en', name: 'English') }
.to change(described_class, :count).by(1)
end
-82
ファイルの表示
@@ -1,82 +0,0 @@
require 'rails_helper'
RSpec.describe 'Locale activation', type: :model do
let!(:language) { Language.create!(code: 'ain', name: 'ain') }
let!(:script) { Script.create!(code: 'Kana', name: 'Kana') }
let!(:first) { create(:tag, primary_name: 'activation_first') }
let!(:second) { create(:tag, primary_name: 'activation_second') }
def build_locale deprecated_at: nil
Locale.new(code: 'ain-Kana', language_code: language.code,
script_code: script.code, region_code: 392, variants: '',
name: 'ain-Kana', deprecated_at:)
end
def generated_names
TagName.where(language_code: 'ain', primary_flg: true)
end
def fail_second_generation!
allow(TagName).to receive(:generate_name).and_call_original
allow(TagName).to receive(:generate_name)
.with(kind_of(Locale), second, second.tag_name('ja'))
.and_raise('backfill failed')
end
it 'rolls back a newly created locale and the preceding generated names' do
locale = build_locale
fail_second_generation!
expect { locale.save! }.to raise_error('backfill failed')
expect(Locale.exists?(code: 'ain-Kana')).to be(false)
expect(generated_names).to be_empty
expect(first.reload.name('ja')).to eq('activation_first')
expect(second.reload.name('ja')).to eq('activation_second')
end
it 'does not backfill while the locale is inactive' do
build_locale(deprecated_at: Time.current).save!
expect(generated_names).to be_empty
end
it 'backfills missing primary names when the locale becomes active' do
locale = build_locale(deprecated_at: Time.current)
locale.save!
locale.update!(deprecated_at: nil)
expect(generated_names.pluck(:tag_id)).to contain_exactly(first.id, second.id)
expect(generated_names).to all(have_attributes(
script_code: 'Kana', primary_flg: true, auto_generated: true))
end
it 'rolls back activation and all generated names when backfill fails' do
locale = build_locale(deprecated_at: Time.current)
locale.save!
previous_deprecated_at = locale.reload.deprecated_at
fail_second_generation!
expect { locale.update!(deprecated_at: nil) }.to raise_error('backfill failed')
expect(locale.reload.deprecated_at).to eq(previous_deprecated_at)
expect(generated_names).to be_empty
end
it 'preserves a human primary through deactivation and repeated activation' do
locale = build_locale
locale.save!
primary = generated_names.find_by!(tag: first)
primary.update!(name: 'human_confirmed', auto_generated: false)
snapshot = generated_names.order(:id).map(&:attributes)
2.times do
locale.update!(deprecated_at: Time.current)
locale.update!(deprecated_at: nil)
end
expect(generated_names.order(:id).map(&:attributes)).to eq(snapshot)
expect(primary.reload).to have_attributes(name: 'human_confirmed', auto_generated: false)
end
end
+55 -111
ファイルの表示
@@ -1,13 +1,13 @@
require 'rails_helper'
RSpec.describe Locale, type: :model do
include_context 'Ainu locale'
include_context 'English locale'
def create_ainu_locale!
prepare_ainu_reference!
described_class.create!(code: 'ain-Kana', language_code: 'ain',
script_code: 'Kana', region_code: 392,
variants: '', name: 'Ainu')
def create_english_locale!
prepare_english_reference!
described_class.create!(code: 'en', language_code: 'en',
script_code: 'Latn', region_code: 826,
variants: '', name: 'English')
end
it 'keeps the active Nipponese locale consistent with its identity tuple' do
@@ -27,6 +27,7 @@ RSpec.describe Locale, type: :model do
end
it 'generates a primary name for every existing tag using the name generator' do
pending '日本語以外の Locale 有効化は別課題で実装する'
first = create(:tag, primary_name: 'first_existing_tag')
second = create(:tag, primary_name: 'second_existing_tag')
allow(TagName).to receive(:generate_name).and_call_original
@@ -34,152 +35,95 @@ RSpec.describe Locale, type: :model do
.with(kind_of(described_class), first, primary_tag_name_for(first, 'ja'))
.and_return('name_from_generator')
locale = create_ainu_locale!
locale = create_english_locale!
expect(TagName).to have_received(:generate_name)
.with(locale, first, primary_tag_name_for(first, 'ja'))
expect(TagName).to have_received(:generate_name)
.with(locale, second, primary_tag_name_for(second, 'ja'))
expect(first.tag_names.find_by!(language_code: 'ain', primary_flg: true))
expect(first.tag_names.find_by!(language_code: 'en', primary_flg: true))
.to have_attributes(name: 'name_from_generator', tag_id: first.id,
language_code: 'ain', script_code: 'Kana',
language_code: 'en', script_code: 'Latn',
primary_flg: true, auto_generated: true)
expect(second.tag_names.find_by!(language_code: 'ain', primary_flg: true))
.to have_attributes(tag_id: second.id, language_code: 'ain',
script_code: 'Kana', primary_flg: true,
expect(second.tag_names.find_by!(language_code: 'en', primary_flg: true))
.to have_attributes(tag_id: second.id, language_code: 'en',
script_code: 'Latn', primary_flg: true,
auto_generated: true)
expect(TagName.where(language_code: 'ain', primary_flg: true,
expect(TagName.where(language_code: 'en', primary_flg: true,
tag_id: [first.id, second.id]).count).to eq(2)
end
it 'does not duplicate an existing primary name in the new language' do
prepare_ainu_reference!
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
tag = create(:tag, primary_name: 'already_named')
existing = create(:tag_name, name: 'existing_ainu', tag:,
language_code: 'ain', script_code: 'Kana')
versions_before = tag.tag_versions.order(:version_no).map(&:attributes)
existing = create(:tag_name, name: 'existing_english', tag:,
language_code: 'en', script_code: 'Latn')
create_ainu_locale!
create_english_locale!
expect(TagName.where(tag_id: tag.id, language_code: 'ain', primary_flg: true))
expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true))
.to contain_exactly(existing)
expect(existing.reload).to have_attributes(
tag_id: tag.id, language_code: 'ain', script_code: 'Kana',
tag_id: tag.id, language_code: 'en', script_code: 'Latn',
primary_flg: true, auto_generated: false)
expect(tag.tag_versions.order(:version_no).map(&:attributes)).to eq(versions_before)
end
it 'adds the new language primary while preserving existing names and aliases' do
tag = create(:tag, primary_name: 'named_tag')
create(:tag_name, :alias, name: 'existing_alias', tag:,
language_code: 'ja', script_code: 'Jpan')
original_names = tag.tag_names.order(:id).map(&:attributes)
it 'creates a primary name when the language has only an alias' do
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
tag = create(:tag, primary_name: 'alias_only_tag')
alias_name = create(:tag_name, :alias, name: 'alias_en',
tag:,
language_code: 'en', script_code: 'Latn')
create_ainu_locale!
create_english_locale!
expect(tag.tag_names.where(language_code: 'ja').order(:id).map(&:attributes))
.to eq(original_names)
generated = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true)
expect(generated).to have_attributes(name: 'named_tag', tag_id: tag.id, language_code: 'ain',
script_code: 'Kana', primary_flg: true,
expect(alias_name.reload).to have_attributes(
tag_id: tag.id,
language_code: 'en', script_code: 'Latn', primary_flg: false)
generated = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true)
expect(generated).to have_attributes(tag_id: tag.id, language_code: 'en',
script_code: 'Latn', primary_flg: true,
auto_generated: true)
expect(TagName.where(tag_id: tag.id, language_code: 'ain', primary_flg: true).count).to eq(1)
expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true).count).to eq(1)
end
it 'rolls back the locale and all generated names when backfill fails' do
prepare_ainu_reference!
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
first = create(:tag, primary_name: 'atomic_first')
second = create(:tag, primary_name: 'atomic_second')
versions_before = TagVersion.order(:id).map(&:attributes)
allow(TagName).to receive(:generate_name).and_call_original
allow(TagName).to receive(:generate_name)
.with(kind_of(described_class), second, primary_tag_name_for(second, 'ja'))
.and_raise('backfill failed')
expect { create_ainu_locale! }.to raise_error('backfill failed')
expect { create_english_locale! }.to raise_error('backfill failed')
expect(described_class.exists?(code: 'ain-Kana')).to be(false)
expect(TagName.where(language_code: 'ain', tag_id: [first.id, second.id])).to be_empty
expect(TagVersion.order(:id).map(&:attributes)).to eq(versions_before)
expect(described_class.exists?(code: 'en')).to be(false)
expect(TagName.where(language_code: 'en', tag_id: [first.id, second.id])).to be_empty
end
it 'records a separate automatic update for each tag without changing its authored create' do
author = create(:user, :admin)
tags = ['locale_history_first', 'locale_history_second'].map do |name|
Tag.find_or_create_by_tag_name!(Locale.nipponese, name, category: :general,
created_by_user: author)
end
originals = tags.map { |tag| tag.tag_versions.sole.attributes }
it 'accepts a stable locale code outside the historical enum values' do
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
expect { create_ainu_locale! }.to change(TagVersion, :count).by(2)
locale = described_class.create!(
code: 'en-x-rspec', language_code: 'en',
script_code: 'Latn', region_code: 826,
variants: 'x-rspec', name: 'English')
tags.zip(originals).each do |tag, original|
versions = tag.tag_versions.order(:version_no).to_a
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.first.attributes).to eq(original)
expect(versions.first.created_by_user_id).to eq(author.id)
expect(versions.last).to have_attributes(version_no: 2, created_by_user_id: nil)
expect(versions.last.names.fetch('ain')).to eq(
'name' => tag.name('ja'), 'script_code' => 'Kana',
'auto_generated' => true, 'aliases' => [])
expect(versions.first.names.keys).to eq(['ja'])
expect(tag.reload.version_no).to eq(2)
end
expect { TagNameCompletion.for_locale!(Locale.find('ain-Kana')) }
.not_to change(TagVersion, :count)
expect(locale.reload).to have_attributes(
code: 'en-x-rspec', language_code: 'en', script_code: 'Latn',
region_code: 826, variants: 'x-rspec')
end
it 'records automatic before and after snapshots for a tag without any history' do
tag = create(:tag, primary_name: 'legacy_before_locale')
expect(tag.tag_versions).to be_empty
it 'registers the future British English locale tuple' do
pending '日本語以外の Locale 有効化は別課題で実装する'
create_ainu_locale!
versions = tag.tag_versions.order(:version_no).to_a
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.map(&:created_by_user_id)).to eq([nil, nil])
expect(versions.first.names.keys).to eq(['ja'])
expect(versions.last.names.keys).to match_array(['ja', 'ain'])
expect(versions.last.names.fetch('ja')).to eq(versions.first.names.fetch('ja'))
end
it 'rolls back earlier tag names and histories when a later history fails' do
first = create(:tag, primary_name: 'history_atomic_first')
second = create(:tag, primary_name: 'history_atomic_second')
names_before = TagName.order(:id).map(&:attributes)
versions_before = TagVersion.order(:id).map(&:attributes)
allow(TagVersioning).to receive(:record!).and_wrap_original do |original, tag, **options|
if tag.id == second.id && options[:event_type] == :update
raise ActiveRecord::RecordNotSaved, 'history failed'
end
original.call(tag, **options)
end
expect { create_ainu_locale! }
.to raise_error(ActiveRecord::RecordNotSaved, 'history failed')
expect(described_class.exists?(code: 'ain-Kana')).to be(false)
expect(TagName.order(:id).map(&:attributes)).to eq(names_before)
expect(TagVersion.order(:id).map(&:attributes)).to eq(versions_before)
expect([first.reload.version_no, second.reload.version_no]).to eq([1, 1])
end
it 'rejects a private locale code outside the supported locale set' do
prepare_ainu_reference!
expect {
described_class.create!(
code: 'ain-Kana-x-rspec', language_code: 'ain',
script_code: 'Kana', region_code: 392,
variants: 'x-rspec', name: 'Ainu')
}.to raise_error(ArgumentError)
end
it 'registers the supported Ainu locale tuple' do
expect(locale).to have_attributes(code: 'ain-Kana', language_code: 'ain',
script_code: 'Kana', region_code: 392,
expect(locale).to have_attributes(code: 'en', language_code: 'en',
script_code: 'Latn', region_code: 826,
variants: '')
end
end
+4 -3
ファイルの表示
@@ -1,7 +1,7 @@
require 'rails_helper'
RSpec.describe Script, type: :model do
include_context 'Ainu locale'
include_context 'English locale'
it 'selects locales by script code, not language code' do
expect(described_class.find('Jpan').locales).to contain_exactly(Locale.nipponese)
@@ -25,10 +25,11 @@ RSpec.describe Script, type: :model do
}
end
it 'selects Kana locales when multilingual support is enabled' do
it 'selects Latin locales when multilingual support is enabled' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
expect(described_class.find('Kana').locales).to contain_exactly(locale)
expect(described_class.find('Latn').locales).to contain_exactly(locale)
expect(described_class.find('Jpan').locales).not_to include(locale)
end
end
+70 -23
ファイルの表示
@@ -1,37 +1,84 @@
require 'rails_helper'
RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do
it 'allocates distinct increasing IDs across the two tables' do
tag = create(:tag)
external = create(:external_tag)
next_tag = create(:tag)
include TagGroupRecords
let(:user) { create_member_user! }
expect(external.id).to be > tag.id
expect(next_tag.id).to be > external.id
def create_resource resource
if resource == :tag
Tag.find_or_create_by_tag_name!(Locale.nipponese, 'shared_id_new', category: :general)
else
ExternalTag.create!(platform: :nico, name: 'shared_id_new')
end
end
it 'does not reuse physically deleted IDs in either table' do
tag = create(:tag)
external = create(:external_tag)
TagName.where(tag_id: tag.id).delete_all
tag.destroy!
external.destroy!
[:tag, :external_tag].each do |resource|
it "starts #{ resource } at 1 when both tables are empty" do
expect(Tag.count).to eq(0)
expect(ExternalTag.count).to eq(0)
expect(create_resource(resource).id).to eq(1)
end
expect(create(:tag).id).to be > external.id
expect(create(:external_tag).id).to be > external.id
it "allocates #{ resource } above the other table's maximum" do
create(:tag, id: resource == :tag ? 1 : 20)
create(:external_tag, id: resource == :external_tag ? 1 : 20)
expect(create_resource(resource).id).to eq(21)
end
it "uses the current maximum after deleting earlier #{ resource } records" do
tag = create(:tag, id: 100)
TagName.where(tag_id: tag.id).update_all(tag_id: nil)
tag.destroy!
create(:external_tag, id: 200).destroy!
expect(Tag.unscoped.count).to eq(0)
expect(ExternalTag.unscoped.count).to eq(0)
expect(create_resource(resource).id).to eq(1)
end
it "preserves legacy overlaps and gaps when creating #{ resource }" do
[1, 2, 5].each { create(:tag, id: _1) }
[1, 3, 8].each { create(:external_tag, id: _1) }
tags = Tag.order(:id).map(&:attributes)
external_tags = ExternalTag.order(:id).map(&:attributes)
record = create_resource(resource)
aggregate_failures do
expect(record.id).to eq(9)
expect(Tag.where(id: [1, 2, 5]).order(:id).map(&:attributes)).to eq(tags)
expect(ExternalTag.where(id: [1, 3, 8]).order(:id).map(&:attributes)).to eq(external_tags)
expect(Tag.exists?(id: 3)).to be(false)
expect(ExternalTag.exists?(id: 2)).to be(false)
end
end
it "ignores the description and group ID space when creating #{ resource }" do
tag = create(:tag, id: 1)
create(:external_tag, id: 2)
TagDescription.create!(id: 100, tag:, language_code: 'ja', body: '', version_no: 1)
seed_group(name: 'separate_space', id: 200)
expect(create_resource(resource).id).to eq(3)
end
end
it 'retains existing overlapping IDs without renumbering on update or insert' do
tag = create(:tag)
external = create(:external_tag)
external.update_columns(id: tag.id)
it 'preserves overlapping IDs during updates without rewriting the other resource' do
tag = create(:tag, id: 5)
external = create(:external_tag, id: 5)
tag.update!(category: :meta)
external.update!(name: 'updated_external')
before_tag = tag.reload.attributes
before_external = external.reload.attributes
expect(create(:tag).id).to be > tag.id
expect(tag.reload.attributes).to eq(before_tag)
expect(external.reload.attributes).to eq(before_external)
expect(tag.reload.id).to eq(5)
expect(external.reload.id).to eq(5)
expect(tag.category).to eq('meta')
expect(external.name).to eq('updated_external')
expect(Tag.count).to eq(1)
expect(ExternalTag.count).to eq(1)
end
it 'allocates distinct IDs for successive creations across both tables' do
records = [:tag, :external_tag].map { create_resource(_1) }
expect(records.map(&:id)).to eq([1, 2])
end
end
-48
ファイルの表示
@@ -1,48 +0,0 @@
require 'rails_helper'
RSpec.describe TagGroup, type: :model do
let(:actor) { create_member_user! }
def create_group name:, members: [], placements: []
TagGroupOperation.create!(
Locale.nipponese, name:, description: nil, members:, placements:, actor:)
end
it 'retains version IDs and snapshots after destroying an empty group' do
group = create_group(name: 'history owner')
versions = group.versions.order(:version_no).map(&:attributes)
expect { group.destroy! }.to change(described_class, :count).by(-1)
.and change(TagGroupVersion, :count).by(0)
expect(TagGroupVersion.where(tag_group_id: group.id).order(:version_no).map(&:attributes))
.to eq(versions)
expect(TagGroupRepr.historical(
Locale.nipponese, TagGroupVersion.find(versions.first.fetch('id'))))
.to include('id' => group.id, 'name' => 'history owner', 'members' => [])
end
it 'destroys a group with nested placements and child membership, retaining history' do
child = create_group(name: 'child group')
parent = create_group(
name: 'parent group',
members: [{ 'tag_group_id' => child.id }],
placements: [{ 'section_name' => 'section', 'description' => nil,
'children' => [{ 'tag_group_id' => child.id, 'children' => [] }] }])
versions = parent.versions.order(:version_no).map(&:attributes)
expect { parent.destroy! }
.to change(TagGroup, :count).by(-1)
.and change(TagGroupVersion, :count).by(0)
expect(TagGroupVersion.where(tag_group_id: parent.id)
.order(:version_no).map(&:attributes)).to eq(versions)
expect(child.reload).to be_persisted
expect(TagGroupPlacement.where(tag_group_id: parent.id)).to be_empty
expect(TagGroupTag.where(tag_group_id: parent.id)).to be_empty
expect(TagGroupGroup.where(tag_group_id: parent.id)).to be_empty
end
end
+33 -74
ファイルの表示
@@ -34,99 +34,39 @@ RSpec.describe TagNameSanitisationRule, type: :model do
described_class.create!(priority: 10, source_pattern: 'foo_', replacement: 'foo')
end
[:primary, :alias].each do |source_kind|
it "keeps the primary identity when a #{ source_kind } collides within its Tag" do
tag = create(:tag, primary_name: source_kind == :primary ? 'temporary' : 'foobar')
primary = tag.tag_name('ja')
alias_name = create(:tag_name, :alias, tag:,
name: source_kind == :primary ? 'foobar' : 'temporary_alias')
source = source_kind == :primary ? primary : alias_name
source.update_columns(name: 'foo_bar')
expect { described_class.apply! }.to change(TagName, :count).by(-1)
.and change(Tag, :count).by(0)
expect(primary.reload).to have_attributes(
name: 'foobar', primary_flg: true, tag_id: tag.id)
expect(TagName.exists?(alias_name.id)).to be(false)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
.to contain_exactly(primary)
expect(tag.tag_versions.order(:version_no).last.names.fetch('ja'))
.to include('name' => 'foobar', 'aliases' => [])
end
end
it 'deduplicates colliding aliases without changing the primary name' do
tag = create(:tag, primary_name: 'primary')
primary = tag.tag_name('ja')
kept = create(:tag_name, :alias, tag:, name: 'foobar')
removed = create(:tag_name, :alias, tag:, name: 'temporary_alias')
removed.update_columns(name: 'foo_bar')
described_class.apply!
expect(primary.reload).to have_attributes(name: 'primary', primary_flg: true)
expect(kept.reload.tag_id).to eq(tag.id)
expect(TagName.exists?(removed.id)).to be(false)
expect(tag.tag_versions.order(:version_no).last.names.fetch('ja').fetch('aliases'))
.to eq([{ 'name' => 'foobar', 'script_code' => 'Jpan' }])
end
it 'rolls back a collision merge when the source owns a description' do
user = create_member_user!
target = create(:tag, primary_name: 'foobar')
source = create(:tag, primary_name: 'temporary')
TagDescriptionOperation.create!(
Locale.nipponese, tag: source, body: 'retained', created_by_user: user)
source.tag_name('ja').update_columns(name: 'foo_bar')
post = create(:post)
PostTag.create!(post:, tag: source)
models = [Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion, PostTag]
snapshots = models.map { |model| model.order(*Array(model.primary_key)).map(&:attributes) }
expect { described_class.apply! }.to raise_error(ActiveRecord::RecordInvalid)
after_state = models.map do |model|
model.order(*Array(model.primary_key)).map(&:attributes)
end
expect(after_state).to eq(snapshots)
expect(post.reload.tags).to contain_exactly(source)
expect(target.reload.name('ja')).to eq('foobar')
end
context 'when only another language has the sanitised name' do
include_context 'Ainu locale'
include_context 'English locale'
it 'keeps both names when sanitisation produces a cross-language match' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag_name, name: 'foobar')
ainu = create(:tag_name, name: 'temporary',
language_code: 'ain', script_code: 'Kana')
ainu.update_columns(name: 'foo_bar')
english = create(:tag_name, name: 'temporary',
language_code: 'en', script_code: 'Latn')
english.update_columns(name: 'foo_bar')
expect { described_class.apply! }.not_to change(TagName, :count)
expect(ainu.reload).to have_attributes(name: 'foobar', language_code: 'ain')
expect(english.reload).to have_attributes(name: 'foobar', language_code: 'en')
expect(nipponese.reload).to have_attributes(name: 'foobar', language_code: 'ja')
end
it 'renames without merging tags or deleting either language identity' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag, primary_name: 'foobar')
primary_tag_name_for(nipponese, 'ain').update!(
name: 'different_ainu_name', auto_generated: false)
ainu_name = create(:tag_name, name: 'temporary',
language_code: 'ain', script_code: 'Kana')
ainu = create(:tag, primary_tag_name: ainu_name)
ainu_name.update_columns(name: 'foo_bar')
english_name = create(:tag_name, name: 'temporary',
language_code: 'en', script_code: 'Latn')
english = create(:tag, primary_tag_name: english_name)
english_name.update_columns(name: 'foo_bar')
described_class.apply!
expect(ainu_name.reload).to have_attributes(
name: 'foobar', language_code: 'ain', tag_id: ainu.id)
expect(english_name.reload).to have_attributes(
name: 'foobar', language_code: 'en', tag_id: english.id)
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
name: 'foobar', language_code: 'ja', tag_id: nipponese.id)
expect(primary_tag_name_for(ainu.reload, 'ain')).to eq(ainu_name)
expect(primary_tag_name_for(english.reload, 'en')).to eq(english_name)
end
end
@@ -158,6 +98,25 @@ RSpec.describe TagNameSanitisationRule, type: :model do
end
end
context 'when the source tag_name has a tag and the existing one has no tag' do
let!(:existing) { create(:tag_name, name: 'foobar') }
let!(:source_tag) { create(:tag, primary_name: 'tmp', category: :general) }
let!(:source_tag_name_id) { primary_tag_name_for(source_tag, 'ja').id }
before do
primary_tag_name_for(source_tag, 'ja').update_columns(
name: 'foo_bar', updated_at: Time.current)
end
it 'moves the tag to the existing tag_name' do
described_class.apply!
expect(existing.reload).to have_attributes(
tag_id: source_tag.id, language_code: 'ja', primary_flg: true)
expect(primary_tag_name_for(source_tag, 'ja')).to eq(existing)
expect(primary_tag_name_for(source_tag.reload, 'ja')).to eq(existing)
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
end
end
context 'when the sanitised name is an alias of an existing tag' do
let!(:existing_tag) { create(:tag) }
+45 -43
ファイルの表示
@@ -1,16 +1,17 @@
require 'rails_helper'
RSpec.describe TagName, type: :model do
include_context 'Ainu locale'
include_context 'English locale'
it 'allows the same name in different languages' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
create(:tag_name, name: 'shared_name')
ainu = build(:tag_name, name: 'shared_name',
language_code: 'ain', script_code: 'Kana')
english = build(:tag_name, name: 'shared_name',
language_code: 'en', script_code: 'Latn')
expect(ainu).to be_valid
expect { ainu.save! }.to change(described_class, :count).by(1)
expect(english).to be_valid
expect { english.save! }.to change(described_class, :count).by(1)
end
it 'rejects a duplicate name within the same language' do
@@ -21,23 +22,16 @@ RSpec.describe TagName, type: :model do
expect(duplicate.errors.of_kind?(:name, :taken)).to be(true)
end
it 'creates Nipponese factory names with an owning tag' do
it 'creates Nipponese factory names without requiring a tag' do
name = create(:tag_name)
expect(name.reload).to have_attributes(
language_code: 'ja', script_code: 'Jpan', primary_flg: true,
auto_generated: false, tag_id: name.tag.id)
end
it 'rejects an ownerless name at model and database boundaries' do
name = build(:tag_name, tag: nil)
expect(name).to be_invalid
expect { name.save!(validate: false) }.to raise_error(ActiveRecord::NotNullViolation)
auto_generated: false, tag_id: nil)
end
it 'defaults ordinary persisted human names to confirmed provenance' do
name = described_class.create!(tag: create(:tag, primary_tag_name: nil),
name: 'ordinary_human_name', language_code: 'ja',
name = described_class.create!(name: 'ordinary_human_name', language_code: 'ja',
script_code: 'Jpan', primary_flg: true)
expect(name.reload.auto_generated).to be(false)
@@ -52,7 +46,7 @@ RSpec.describe TagName, type: :model do
expect(name.tag).to eq(tag)
expect(described_class.find_by!(tag_id: tag.id, language_code: 'ja', primary_flg: true))
.to eq(name)
expect(tag.reload.tag_name('ja')&.id).to eq(name.id)
expect(tag.reload.tag_name_id).to eq(name.id)
end
it 'creates a complete ordinary tag factory under the active Nipponese language' do
@@ -66,10 +60,11 @@ RSpec.describe TagName, type: :model do
end
it 'completes an ordinary tag factory under future active languages' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = create(:tag, primary_name: 'future_factory')
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true))
expect(TagName.where(tag:, language_code: 'en', primary_flg: true))
.to contain_exactly(have_attributes(auto_generated: true))
end
@@ -101,55 +96,62 @@ RSpec.describe TagName, type: :model do
end
describe '.canonicalise' do
it 'leaves detached aliases unchanged' do
create(:tag_name, :alias, name: 'detached_alias')
expect(described_class.canonicalise(Locale.nipponese, ['detached_alias']))
.to eq(['detached_alias'])
end
it 'resolves an alias to its owning primary name' do
tag = create(:tag, primary_name: 'canonical')
create(:tag_name, :alias, tag:, name: 'alias')
it 'does not resolve a detached alias through another detached primary' do
create(:tag_name, :alias, name: 'detached_alias')
create(:tag_name, name: 'unrelated_detached_primary')
expect(described_class.canonicalise(Locale.nipponese, ['alias', 'unknown']))
.to eq(['canonical', 'unknown'])
expect(described_class.canonicalise(Locale.nipponese, ['detached_alias']))
.to eq(['detached_alias'])
end
it 'resolves the primary name of the alias language on the same tag' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = create(:tag, primary_name: '日本語正本')
ainu_primary = primary_tag_name_for(tag, 'ain')
ainu_primary.update!(name: 'ainu_primary', auto_generated: false)
ainu_alias = create(:tag_name, :alias, tag:, name: 'ainu_alias',
language_code: 'ain', script_code: 'Kana')
representative_id = tag.tag_name('ja')&.id
english_primary = primary_tag_name_for(tag, 'en')
english_primary.update!(name: 'english_primary', auto_generated: false)
english_alias = create(:tag_name, :alias, tag:, name: 'english_alias',
language_code: 'en', script_code: 'Latn')
representative_id = tag.tag_name_id
primary = described_class.find_by!(tag_id: ainu_alias.tag_id,
language_code: ainu_alias.language_code,
primary = described_class.find_by!(tag_id: english_alias.tag_id,
language_code: english_alias.language_code,
primary_flg: true)
expect(primary).to eq(ainu_primary)
expect(described_class.canonicalise(locale, [ainu_alias.name]))
.to eq([ainu_primary.name])
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
expect(primary).to eq(english_primary)
expect(described_class.canonicalise(locale, [english_alias.name]))
.to eq([english_primary.name])
expect(tag.reload.tag_name_id).to eq(representative_id)
end
it 'resolves only aliases in the requested language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag, primary_name: 'nipponese_canonical')
ainu_name = create(:tag_name, name: 'ainu_canonical',
language_code: 'ain', script_code: 'Kana')
ainu = create(:tag, primary_tag_name: ainu_name)
english_name = create(:tag_name, name: 'english_canonical',
language_code: 'en', script_code: 'Latn')
english = create(:tag, primary_tag_name: english_name)
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: nipponese)
ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: ainu,
language_code: 'ain', script_code: 'Kana')
english_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: english,
language_code: 'en', script_code: 'Latn')
expect(nipponese_alias.reload).to have_attributes(
tag_id: nipponese.id,
primary_flg: false, language_code: 'ja')
expect(ainu_alias.reload).to have_attributes(
tag_id: ainu.id,
primary_flg: false, language_code: 'ain')
expect(english_alias.reload).to have_attributes(
tag_id: english.id,
primary_flg: false, language_code: 'en')
expect(described_class.canonicalise(locale, ['shared_alias']))
.to eq(['ainu_canonical'])
.to eq(['english_canonical'])
expect(described_class.canonicalise(Locale.nipponese, ['shared_alias']))
.to eq(['nipponese_canonical'])
end
+128 -69
ファイルの表示
@@ -1,20 +1,25 @@
require 'rails_helper'
RSpec.describe Tag, type: :model do
include WikiRecords
describe '#has_description?' do
let(:tag) { create(:tag) }
it 'checks the requested language and treats an empty description as present' do
TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1)
expect(tag.has_description?(language_code: 'ja')).to be(true)
expect(tag.has_description?(language_code: 'ain')).to be(false)
expect(tag.has_description?(language_code: 'en')).to be(false)
end
it 'returns false without a description' do
expect(tag.has_description?(language_code: 'ja')).to be(false)
end
it 'does not use a Wiki page as evidence of a description' do
user = create_member_user!
Wiki::Commit.create_legacy_content!(tag_name: tag.tag_name('ja'), body: 'legacy',
created_by_user: user)
expect(tag.has_description?(language_code: 'ja')).to be(false)
end
it 'reuses preloaded descriptions without reading Wiki or querying again' do
TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1)
@@ -23,16 +28,16 @@ RSpec.describe Tag, type: :model do
subscriber = ->(*args) { queries << args.last.fetch(:sql) }
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
expect(loaded.has_description?(language_code: 'ja')).to be(true)
expect(loaded.has_description?(language_code: 'ain')).to be(false)
expect(loaded.has_description?(language_code: 'en')).to be(false)
end
expect(queries).to be_empty
end
it 'keeps language selection generic without enabling another Language record' do
tag.association(:tag_descriptions).target = [
TagDescription.new(tag:, language_code: 'ain', body: '', version_no: 1)]
TagDescription.new(tag:, language_code: 'en', body: '', version_no: 1)]
tag.association(:tag_descriptions).loaded!
expect(tag.has_description?(language_code: 'ain')).to be(true)
expect(tag.has_description?(language_code: 'en')).to be(true)
expect(tag.has_description?(language_code: 'ja')).to be(false)
end
end
@@ -61,32 +66,33 @@ RSpec.describe Tag, type: :model do
end
describe '.normalise_tags!' do
include_context 'Ainu locale'
include_context 'English locale'
it 'canonicalises aliases in the supplied language only' do
prepare_ainu_reference!
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
nipponese = create(:tag, primary_name: 'nipponese_canonical')
ainu_name = build(:tag_name, tag: nil, name: 'ainu_canonical',
language_code: 'ain', script_code: 'Kana')
ainu = create(:tag, primary_tag_name: ainu_name)
primary_tag_name_for(ainu, 'ja').update!(
name: 'ainu_in_nipponese', auto_generated: false)
ainu_locale = locale
english_name = create(:tag_name, name: 'english_canonical',
language_code: 'en', script_code: 'Latn')
english = create(:tag, primary_tag_name: english_name)
primary_tag_name_for(english, 'ja').update!(
name: 'english_in_nipponese', auto_generated: false)
english_locale = locale
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: nipponese)
ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: ainu,
language_code: 'ain', script_code: 'Kana')
english_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: english,
language_code: 'en', script_code: 'Latn')
expect(nipponese_alias.reload).to have_attributes(
tag_id: nipponese.id,
primary_flg: false, language_code: 'ja')
expect(ainu_alias.reload).to have_attributes(
tag_id: ainu.id,
primary_flg: false, language_code: 'ain')
expect(english_alias.reload).to have_attributes(
tag_id: english.id,
primary_flg: false, language_code: 'en')
expect(described_class.normalise_tags!(
ainu_locale, ['shared_alias'],
with_tagme: false, with_no_deerjikist: false)).to eq([ainu])
english_locale, ['shared_alias'],
with_tagme: false, with_no_deerjikist: false)).to eq([english])
expect(described_class.normalise_tags!(
Locale.nipponese, ['shared_alias'],
with_tagme: false, with_no_deerjikist: false)).to eq([nipponese])
@@ -252,120 +258,160 @@ RSpec.describe Tag, type: :model do
describe '.find_or_create_by_tag_name!' do
context 'with an explicit locale' do
include_context 'Ainu locale'
include_context 'English locale'
it 'creates the representative name with locale attributes and ownership' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = described_class.find_or_create_by_tag_name!(
locale, 'ainu_name', category: :character)
locale, 'english_name', category: :character)
expect(primary_tag_name_for(tag, 'ain')).to have_attributes(
language_code: 'ain', script_code: 'Kana',
expect(primary_tag_name_for(tag, 'en')).to have_attributes(
language_code: 'en', script_code: 'Latn',
primary_flg: true, tag_id: tag.id)
expect(tag.category).to eq('character')
end
it 'copies an Ainu-origin name to Japanese without reverse transcription' do
it 'uses the English primary as the source for a generated Nipponese name' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
allow(TagName).to receive(:generate_name).and_call_original
allow(TagName).to receive(:generate_name).and_return('generated_nipponese')
tag = described_class.find_or_create_by_tag_name!(
locale, 'ainu_origin', category: :general)
locale, 'english_origin', category: :general)
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true))
expect(TagName.where(tag:, language_code: 'en', primary_flg: true))
.to contain_exactly(have_attributes(
name: 'ainu_origin', auto_generated: false))
name: 'english_origin', auto_generated: false))
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
.to contain_exactly(have_attributes(
name: 'ainu_origin', auto_generated: true))
name: 'generated_nipponese', auto_generated: true))
source_tag_name = TagName.find_by!(tag:, language_code: 'en', primary_flg: true)
expect(TagName).to have_received(:generate_name)
.with(Locale.nipponese, tag, tag.tag_name('ain'))
.with(Locale.nipponese, tag, source_tag_name)
end
it 'gives a new tag one primary name for every active language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = described_class.find_or_create_by_tag_name!(
Locale.nipponese, 'new_multilingual_tag', category: :general)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true).count).to eq(1)
expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1)
end
it 'creates one primary per language when multiple locales use Ainu' do
it 'creates one primary per language when multiple locales use English' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
described_class.find_or_create_by_tag_name!(
locale, 'before_second_ainu_locale', category: :general)
locale, 'before_second_english_locale', category: :general)
Locale.create!(code: 'en-x-rspec', language_code: 'en',
script_code: 'Latn', region_code: 826,
variants: 'x-rspec', name: 'English')
tag = described_class.find_or_create_by_tag_name!(
locale, 'one_ainu_primary', category: :general)
TagNameCompletion.for_tag!(tag, locales: [locale, locale.dup, Locale.nipponese])
locale, 'one_english_primary', category: :general)
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true).count).to eq(1)
expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
end
it 'rolls back a new tag and requested name if name generation fails' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
original_tag_count = described_class.count
allow(TagName).to receive(:generate_name).and_raise('generation failed')
expect {
described_class.find_or_create_by_tag_name!(
locale, 'atomic_ainu_name', category: :general)
locale, 'atomic_english_name', category: :general)
}.to raise_error('generation failed')
expect(described_class.count).to eq(original_tag_count)
expect(TagName.where(language_code: 'ain', name: 'atomic_ainu_name')).to be_empty
expect(TagName.where(language_code: 'en', name: 'atomic_english_name')).to be_empty
expect(described_class.joins(:tag_names)
.where(tag_names: { name: 'atomic_ainu_name' })).to be_empty
.where(tag_names: { name: 'atomic_english_name' })).to be_empty
end
it 'keeps a detached requested name detached if generation fails' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
detached = create(:tag_name, :alias, name: 'atomic_detached',
language_code: 'en', script_code: 'Latn')
original_tag_count = described_class.count
allow(TagName).to receive(:generate_name).and_raise('generation failed')
expect {
described_class.find_or_create_by_tag_name!(
locale, detached.name, category: :general)
}.to raise_error('generation failed')
expect(described_class.count).to eq(original_tag_count)
expect(detached.reload).to have_attributes(tag_id: nil, primary_flg: false)
end
it 'promotes a detached alias into the requested primary name' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
detached = create(:tag_name, :alias, name: 'detached_english',
language_code: 'en', script_code: 'Latn')
tag = described_class.find_or_create_by_tag_name!(
locale, detached.name, category: :general)
expect(detached.reload).to have_attributes(
tag_id: tag.id, primary_flg: true, canonical_id: nil,
auto_generated: false)
expect(TagName.where(tag:, language_code: 'en', primary_flg: true))
.to contain_exactly(detached)
end
it 'creates separate tag identities for the same name in different languages' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = described_class.find_or_create_by_tag_name!(
Locale.nipponese, 'same_name', category: :general)
primary_tag_name_for(nipponese, 'ain').update!(
name: 'other_ainu_name', auto_generated: false)
ainu = described_class.find_or_create_by_tag_name!(
english = described_class.find_or_create_by_tag_name!(
locale, 'same_name', category: :character)
expect(ainu).not_to eq(nipponese)
expect(primary_tag_name_for(ainu, 'ain')).to have_attributes(
name: 'same_name', tag_id: ainu.id, language_code: 'ain', primary_flg: true)
expect(english).not_to eq(nipponese)
expect(primary_tag_name_for(english, 'en')).to have_attributes(
name: 'same_name', tag_id: english.id, language_code: 'en', primary_flg: true)
expect(primary_tag_name_for(nipponese, 'ja')).to have_attributes(
name: 'same_name', tag_id: nipponese.id, language_code: 'ja', primary_flg: true)
expect(described_class.find_or_create_by_tag_name!(
locale, 'same_name', category: :general)).to eq(ainu)
locale, 'same_name', category: :general)).to eq(english)
expect(described_class.find_or_create_by_tag_name!(
Locale.nipponese, 'same_name', category: :general)).to eq(nipponese)
end
it 'preserves the V1 representative when looking up another primary language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = create(:tag, primary_name: '日本語代表名')
representative_id = tag.tag_name('ja')&.id
ainu = primary_tag_name_for(tag, 'ain')
ainu.update!(name: 'ainu_primary', auto_generated: false)
representative_id = tag.tag_name_id
english = primary_tag_name_for(tag, 'en')
english.update!(name: 'english_primary', auto_generated: false)
found = described_class.find_or_create_by_tag_name!(
locale, ainu.name, category: :general)
locale, english.name, category: :general)
expect(found).to eq(tag)
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
expect(ainu.reload.tag_id).to eq(tag.id)
expect(tag.reload.tag_name_id).to eq(representative_id)
expect(english.reload.tag_id).to eq(tag.id)
end
it 'normalises names using the supplied locale' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tags = described_class.normalise_tags!(
locale, ['character:normalised_ainu'],
locale, ['character:normalised_english'],
with_tagme: false, with_no_deerjikist: false)
expect(tags.length).to eq(1)
expect(primary_tag_name_for(tags.first, 'ain')).to have_attributes(
name: 'normalised_ainu', language_code: 'ain', script_code: 'Kana',
expect(primary_tag_name_for(tags.first, 'en')).to have_attributes(
name: 'normalised_english', language_code: 'en', script_code: 'Latn',
primary_flg: true, tag_id: tags.first.id)
end
end
@@ -384,10 +430,22 @@ RSpec.describe Tag, type: :model do
expect(primary_tag_name_for(tag, 'ja').auto_generated).to be(false)
end
it 'promotes a detached Nipponese alias into a human-confirmed primary' do
detached = create(:tag_name, :alias, name: 'detached_nipponese')
tag = described_class.find_or_create_by_tag_name!(
Locale.nipponese, detached.name, category: :general)
expect(detached.reload).to have_attributes(
tag_id: tag.id, primary_flg: true, canonical_id: nil,
auto_generated: false)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
.to contain_exactly(detached)
end
it 'reuses the owning tag for an alias without changing its category' do
tag = create(:tag, category: :character)
representative_id = tag.tag_name('ja')&.id
representative_id = tag.tag_name_id
alias_name = create(:tag_name, :alias, name: 'lookup_alias', tag:)
expect(alias_name.reload).to have_attributes(
tag_id: tag.id,
@@ -401,7 +459,7 @@ RSpec.describe Tag, type: :model do
expect(found).to eq(tag)
expect(found.category).to eq('character')
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
expect(tag.reload.tag_name_id).to eq(representative_id)
end
it 'reuses the owning tag through another alias' do
@@ -434,25 +492,26 @@ RSpec.describe Tag, type: :model do
end
context 'with names in multiple languages' do
include_context 'Ainu locale'
include_context 'English locale'
it 'keeps the target primary and moves source names as aliases' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
ainu_primary = primary_tag_name_for(target_tag, 'ain')
source_primary = primary_tag_name_for(source_tag, 'ain')
english_primary = primary_tag_name_for(target_tag, 'en')
source_primary = primary_tag_name_for(source_tag, 'en')
source_alias = create(:tag_name, :alias, tag: source_tag,
name: 'source_ainu_alias', language_code: 'ain', script_code: 'Kana')
name: 'source_english_alias', language_code: 'en', script_code: 'Latn')
expect([ainu_primary, source_primary]).to all(have_attributes(
expect([english_primary, source_primary]).to all(have_attributes(
primary_flg: true, auto_generated: true))
described_class.merge_tags!(target_tag, [source_tag])
expect(primary_tag_name_for(target_tag.reload, 'ain')).to eq(ainu_primary)
expect(TagName.where(tag: target_tag, language_code: 'ain', primary_flg: true))
.to contain_exactly(ainu_primary)
expect(primary_tag_name_for(target_tag.reload, 'en')).to eq(english_primary)
expect(TagName.where(tag: target_tag, language_code: 'en', primary_flg: true))
.to contain_exactly(english_primary)
expect([source_primary.reload, source_alias.reload]).to all(have_attributes(
tag_id: target_tag.id, language_code: 'ain', primary_flg: false))
tag_id: target_tag.id, language_code: 'en', primary_flg: false))
expect(TagName.where(tag: target_tag, language_code: 'ja', primary_flg: true))
.to contain_exactly(primary_tag_name_for(target_tag, 'ja'))
expect(source_tag_name.reload).to have_attributes(
@@ -624,7 +683,7 @@ RSpec.describe Tag, type: :model do
let!(:wiki_page) do
admin = create_admin_user!
seed_description(
Wiki::Commit.create_content!(
tag_name: source_tag_name,
body: 'source wiki body',
created_by_user: admin,
-25
ファイルの表示
@@ -1,25 +0,0 @@
require 'rails_helper'
RSpec.describe User, type: :model do
it 'nullifies description authorship without deleting native content or history' do
author = create_member_user!
editor = create_member_user!
tag = create(:tag)
description = TagDescriptionOperation.create!(
Locale.nipponese, tag:, body: 'initial', created_by_user: author)
TagDescriptionOperation.update!(
Locale.nipponese, tag:, body: 'edited', updated_by_user: editor)
versions = TagDescriptionVersion.where(tag_id: tag.id).order(:version_no)
snapshots = versions.map(&:attributes)
expect(author.created_tag_descriptions).to contain_exactly(description)
expect(editor.updated_tag_descriptions).to contain_exactly(description)
expect { author.destroy! }.to change(TagDescription, :count).by(0)
.and change(TagDescriptionVersion, :count).by(0)
expect(description.reload).to have_attributes(
body: 'edited', created_by_user_id: nil, updated_by_user_id: editor.id)
expect { editor.destroy! }.not_to change(TagDescription, :count)
expect(description.reload.updated_by_user_id).to be_nil
expect(versions.reload.map(&:attributes)).to eq(snapshots)
end
end
-8
ファイルの表示
@@ -53,14 +53,6 @@ RSpec.configure do |config|
config.include AuthHelper, type: :request
config.include JsonHelper, type: :request
config.around(:each, type: :request) do |example|
previous = ApplicationController.allow_forgery_protection
ApplicationController.allow_forgery_protection = example.metadata.fetch(:csrf, false)
example.run
ensure
ApplicationController.allow_forgery_protection = previous
end
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
config.fixture_paths = [
Rails.root.join('spec/fixtures')
-96
ファイルの表示
@@ -1,96 +0,0 @@
require 'rails_helper'
RSpec.describe 'Counts API', type: :request do
let!(:actor) { create(:user, :member) }
let!(:post) { create(:post) }
let!(:tag) { create(:tag, primary_name: 'counts tag', category: :general) }
before do
snapshot = { tag_id: tag.id, version_no: 1, name: 'counts tag',
category: 'general', sections: [] }
[1, 2, 3].each do |number|
time = Time.utc(2024, number == 3 ? 2 : 1, number, 0, 0)
PostVersion.create!(post:, version_no: number,
event_type: number == 1 ? 'create' : 'update',
tags: '', url: post.url, title: "Snapshot #{ number }", parent_post_ids: '',
tags_json: number == 1 ? [snapshot] : [],
created_by_user: number == 3 ? nil : actor, created_at: time)
TagVersion.create!(tag:, version_no: number,
event_type: number == 1 ? 'create' : 'update',
name: 'counts tag', category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: 'counts tag', script_code: 'Jpan',
auto_generated: false, aliases: [] } },
created_by_user: number == 3 ? nil : actor, created_at: time)
end
end
['posts', 'tags'].each do |resource|
context resource do
let(:path) { "/counts/#{ resource }" }
let(:id) { resource == 'posts' ? post.id : tag.id }
let(:filters) { { resource_id: id, user_id: actor.id,
from: '2024-01-01T00:00Z', before: '2024-02-01T00:00Z' } }
it 'uses the same AND filters for SQL totals, groups and details' do
get path, params: filters.merge(axis: 'event_type')
expect(response).to have_http_status(:ok)
summary = response.parsed_body
expect(summary['count']).to eq(2)
expect(summary['groups'].sum { _1['count'] }).to eq(2)
expect(summary['groups'].map { _1['key'] }).to eq(['create', 'update'])
get path, params: filters.merge(mode: 'details')
expect(response.parsed_body['count']).to eq(2)
expect(response.parsed_body['versions'].map { _1['version_no'] }).to eq([2, 1])
end
it 'paginates details and groups independently from totals' do
get path, params: { resource_id: id, mode: 'details', limit: 1, page: 2 }
expect(response.parsed_body['count']).to eq(3)
expect(response.parsed_body['versions'].sole['version_no']).to eq(2)
get path, params: { resource_id: id, axis: 'month', limit: 1, page: 2 }
expect(response.parsed_body['group_count']).to eq(2)
expect(response.parsed_body['groups'].sole).to include('key' => '2024-02', 'count' => 1)
end
it 'groups null actors and supports their drill-down filter' do
get path, params: { resource_id: id, axis: 'user' }
expect(response.parsed_body['groups']).to include(
{ 'key' => nil, 'name' => nil, 'count' => 1 })
get path, params: { resource_id: id, mode: 'details', user_id: 'none' }
expect(response.parsed_body['count']).to eq(1)
end
it 'uses an inclusive start and exclusive end' do
get path, params: { resource_id: id,
from: '2024-01-01T00:00Z', before: '2024-01-02T00:00Z' }
expect(response.parsed_body['count']).to eq(1)
end
it 'rejects unsupported or malformed query values' do
[
{ axis: 'SQL' }, { limit: 101 }, { page: '1x' }, { user_id: '-1' },
{ event_type: 'invalid' }, { columns: 'password' }, { order: 'id' },
{ from: '2024-02-30T00:00Z' }, { from: '2024-01-01' },
{ user_id: ['1'] }, { mode: 'invalid' }].each do |invalid|
get path, params: invalid
expect(response).to have_http_status(:bad_request)
end
end
it 'retains the existing banned-user boundary' do
actor.update!(banned_at: Time.current)
sign_in_as(actor)
get path
expect(response).to have_http_status(:forbidden)
end
end
end
it 'matches removed tags from the previous snapshot, not current post_tags' do
get '/counts/posts', params: { tag: tag.id, mode: 'details' }
expect(response.parsed_body['count']).to eq(2)
expect(response.parsed_body['versions'].map { _1['version_no'] }).to eq([2, 1])
expect(response.parsed_body['versions'].first['tags'].sole['type']).to eq('removed')
end
end
-54
ファイルの表示
@@ -1,54 +0,0 @@
require 'rails_helper'
RSpec.describe 'API locale resolution', type: :request do
let!(:tag) { create(:tag, primary_name: '日本語正本') }
it 'defaults an omitted locale to Japanese' do
get "/tags/#{ tag.id }"
expect(response).to have_http_status(:ok)
expect(json.fetch('name')).to eq('日本語正本')
end
it 'accepts an explicitly requested active Japanese locale' do
get "/tags/#{ tag.id }", params: { locale: 'ja' }
expect(response).to have_http_status(:ok)
expect(json.fetch('name')).to eq('日本語正本')
end
['', 'en', 'ain', 'Jpan', 'JA', 'unknown', ['ja']].each do |code|
it "rejects invalid locale #{ code.inspect } instead of defaulting to Japanese" do
get "/tags/#{ tag.id }", params: { locale: code }
expect(response).to have_http_status(:not_found)
expect(response.body).not_to include('日本語正本')
end
end
it 'rejects a supported locale that has not been registered' do
get "/tags/#{ tag.id }", params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:not_found)
end
it 'rejects an inactive locale' do
Locale.nipponese.update!(deprecated_at: Time.current)
get "/tags/#{ tag.id }", params: { locale: 'ja' }
expect(response).to have_http_status(:not_found)
end
it 'does not write posts or tag names under an invalid locale' do
sign_in_as(create_member_user!)
expect do
post '/posts', params: {
locale: 'en', title: 'invalid locale', url: 'https://example.com/invalid-locale',
tags: 'unwritten_tag' }
end.to change(Post, :count).by(0).and change(TagName, :count).by(0)
expect(response).to have_http_status(:not_found)
end
end
+81 -81
ファイルの表示
@@ -1,7 +1,7 @@
require 'rails_helper'
RSpec.describe 'Locale propagation on write paths', type: :request do
include_context 'Ainu locale'
include_context 'English locale'
let(:member) { create(:user, :member) }
@@ -14,19 +14,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
expect(tag_name).to have_attributes(
script_code: expected_locale.script_code, primary_flg: true)
expect(tag_name.tag_id).to be_present
tag = tag_name.tag
version = tag.tag_versions.sole
expect(version).to have_attributes(
event_type: 'create', version_no: 1, created_by_user_id: member.id)
expect(tag.reload.version_no).to eq(1)
expect(version.names.keys).to match_array(
Locale.where(deprecated_at: nil).distinct.pluck(:language_code))
tag.tag_names.where(primary_flg: true).each do |primary|
expect(version.names.fetch(primary.language_code)).to include(
'name' => primary.name, 'script_code' => primary.script_code,
'auto_generated' => primary.auto_generated, 'aliases' => [])
end
tag
tag_name.tag
end
it 'confirms a generated Nipponese primary when a human renames it' do
@@ -66,12 +54,13 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end
end
['ain-Kana', nil].each do |requested_locale|
['en', nil].each do |requested_locale|
context "with locale #{ requested_locale.inspect }" do
let(:locale_params) { requested_locale ? { locale: requested_locale } : { } }
let(:expected_locale) { requested_locale ? locale : Locale.nipponese }
it 'creates post tags in the requested language or Nipponese fallback' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
post '/posts', params: locale_params.merge(
title: 'Locale post', url: 'https://example.com/locale-post',
@@ -83,6 +72,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end
it 'updates post tags in the requested language or Nipponese fallback' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
record = create(:post)
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
@@ -97,6 +87,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end
it 'passes the resolved locale to bulk creation' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
# Worker propagation is exercised in post_bulk_creator_spec.
expect_any_instance_of(PostBulkCreator).to receive(:run)
@@ -111,6 +102,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end
it 'creates materials with tags in the resolved locale' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
post '/materials', params: locale_params.merge(
tag: 'locale_material', url: 'https://example.com/material')
@@ -121,6 +113,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end
it 'updates materials with tags in the resolved locale' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
tag = create(:tag, category: :material)
material = Material.create!(tag:, url: 'https://example.com/material')
@@ -134,6 +127,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end
it 'links Nico external tags to internal tags in the resolved locale' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
external = create(:external_tag)
NicoTagVersionRecorder.record!(
@@ -147,6 +141,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end
it 'creates parent tags in the resolved locale' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
tag = create(:tag)
@@ -162,179 +157,184 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end
context 'with existing names in both languages' do
let(:ainu_name) do
create(:tag_name, name: 'shared_name', language_code: 'ain', script_code: 'Kana')
end
let(:ainu_tag) do
create(:tag, primary_tag_name: ainu_name, category: :material).tap do |tag|
primary_tag_name_for(tag, 'ja').update!(name: 'ainu_in_japanese', auto_generated: false)
end
let(:english_name) do
create(:tag_name, name: 'shared_name', language_code: 'en', script_code: 'Latn')
end
let(:english_tag) { create(:tag, primary_tag_name: english_name, category: :material) }
let(:nipponese_tag) do
create(:tag, primary_name: 'temporary_nipponese', category: :general).tap do |tag|
primary_tag_name_for(tag, 'ja').update!(name: 'shared_name')
# Isolate lookup from the separately tested uniqueness validation.
primary_tag_name_for(tag, 'ja').update_columns(name: 'shared_name')
end
end
it 'plans and creates the post using the Ainu identity and category' do
it 'plans and creates the post using the English identity and category' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
ainu_tag
english_tag
nipponese_tag
post '/posts', params: {
locale: 'ain-Kana', title: 'Ainu identity',
url: 'https://example.com/ainu-identity', tags: 'shared_name', parent_post_ids: '' }
locale: 'en', title: 'English identity',
url: 'https://example.com/english-identity', tags: 'shared_name', parent_post_ids: '' }
expect(response).to have_http_status(:created), response.body
record = Post.find(json.fetch('id'))
expect(record.tags).to include(ainu_tag)
expect(record.tags).to include(english_tag)
expect(record.tags).not_to include(nipponese_tag)
expect(ainu_tag.reload.category).to eq('material')
expect(english_tag.reload.category).to eq('material')
expect(nipponese_tag.reload.category).to eq('general')
end
it 'updates the post using the Ainu identity' do
it 'updates the post using the English identity' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
ainu_tag
english_tag
nipponese_tag
record = create(:post)
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
put "/posts/#{ record.id }", params: {
locale: 'ain-Kana', title: record.title, tags: 'shared_name', parent_post_ids: '',
locale: 'en', title: record.title, tags: 'shared_name', parent_post_ids: '',
base_version_no: record.reload.version_no }
expect(response).to have_http_status(:ok), response.body
expect(record.reload.tags).to include(ainu_tag)
expect(record.reload.tags).to include(english_tag)
expect(record.tags).not_to include(nipponese_tag)
end
it 'resolves the Ainu material tag' do
it 'resolves the English material tag' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
ainu_tag
english_tag
nipponese_tag
post '/materials', params: {
locale: 'ain-Kana', tag: 'shared_name', url: 'https://example.com/ainu-material' }
locale: 'en', tag: 'shared_name', url: 'https://example.com/english-material' }
expect(response).to have_http_status(:created), response.body
expect(Material.find(json.fetch('id')).tag).to eq(ainu_tag)
expect(Material.find(json.fetch('id')).tag).to eq(english_tag)
expect(Material.where(tag: nipponese_tag)).to be_empty
end
it 'resolves the Ainu parent tag' do
it 'resolves the English parent tag' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
ainu_tag
english_tag
nipponese_tag
child = create(:tag)
put "/tags/#{ child.id }", params: {
locale: 'ain-Kana', name: primary_tag_name_for(child, 'ja').name,
locale: 'en', name: primary_tag_name_for(child, 'ja').name,
category: child.category, deprecated: false,
aliases: '', parent_tags: 'shared_name' }
expect(response).to have_http_status(:ok), response.body
expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([ainu_tag.id])
expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([english_tag.id])
end
it 'resolves the Ainu internal tag for a Nico link' do
it 'resolves the English internal tag for a Nico link' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
ainu_tag
english_tag
nipponese_tag
external = create(:external_tag)
NicoTagVersionRecorder.record!(
external_tag: external, event_type: :create, created_by_user: member)
put "/tags/nico/#{ external.id }", params: { locale: 'ain-Kana', tags: 'shared_name' }
put "/tags/nico/#{ external.id }", params: { locale: 'en', tags: 'shared_name' }
expect(response).to have_http_status(:ok), response.body
expect(external.reload.linked_tags).to contain_exactly(ainu_tag)
expect(external.reload.linked_tags).to contain_exactly(english_tag)
end
end
context 'when renaming or updating aliases in Ainu' do
let(:ainu_name) do
create(:tag_name, name: 'ainu_original', language_code: 'ain', script_code: 'Kana')
context 'when renaming or updating aliases in English' do
let(:english_name) do
create(:tag_name, name: 'english_original', language_code: 'en', script_code: 'Latn')
end
let(:tag) { create(:tag, primary_tag_name: ainu_name) }
let(:tag) { create(:tag, primary_tag_name: english_name) }
it 'marks a human-renamed generated primary as no longer auto-generated' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
generated_tag = create(:tag, primary_name: 'rename_generated_source')
generated_name = primary_tag_name_for(generated_tag, 'ain')
generated_name = primary_tag_name_for(generated_tag, 'en')
expect(generated_name.auto_generated).to be(true)
patch "/tags/#{ generated_tag.id }", params: {
locale: 'ain-Kana', name: 'human_ainu_name' }
locale: 'en', name: 'human_english_name' }
expect(response).to have_http_status(:ok), response.body
expect(generated_name.reload).to have_attributes(
name: 'human_ainu_name', auto_generated: false)
name: 'human_english_name', auto_generated: false)
end
it 'does not steal another tag primary for an alias' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
other_tag = create(:tag, primary_name: 'other_nipponese')
other_primary = primary_tag_name_for(other_tag, 'ain')
other_primary.update!(name: 'other_ainu_primary', auto_generated: false)
create(:tag_name, :alias, tag:, name: 'existing_ainu_alias',
language_code: 'ain', script_code: 'Kana')
primary_name_before = primary_tag_name_for(tag, 'ain').name
other_primary = primary_tag_name_for(other_tag, 'en')
other_primary.update!(name: 'other_english_primary', auto_generated: false)
create(:tag_name, :alias, tag:, name: 'existing_english_alias',
language_code: 'en', script_code: 'Latn')
primary_name_before = primary_tag_name_for(tag, 'en').name
aliases_before =
tag.tag_names.where(language_code: 'ain', primary_flg: false)
tag.tag_names.where(language_code: 'en', primary_flg: false)
.order(:name).pluck(:name)
category_before = tag.category
put "/tags/#{ tag.id }", params: {
locale: 'ain-Kana', name: 'renamed_before_alias_collision',
locale: 'en', name: 'renamed_before_alias_collision',
category: 'character', deprecated: false,
aliases: 'other_ainu_primary', parent_tags: '' }
aliases: 'other_english_primary', parent_tags: '' }
aggregate_failures do
expect(response).to have_http_status(:unprocessable_entity)
expect(other_primary.reload).to have_attributes(
tag_id: other_tag.id, language_code: 'ain', primary_flg: true)
expect(TagName.where(tag: other_tag, language_code: 'ain', primary_flg: true))
tag_id: other_tag.id, language_code: 'en', primary_flg: true)
expect(TagName.where(tag: other_tag, language_code: 'en', primary_flg: true))
.to contain_exactly(other_primary)
expect(primary_tag_name_for(tag.reload, 'ain').name).to eq(primary_name_before)
expect(primary_tag_name_for(tag.reload, 'en').name).to eq(primary_name_before)
expect(tag.category).to eq(category_before)
aliases_after =
tag.tag_names.where(language_code: 'ain', primary_flg: false)
tag.tag_names.where(language_code: 'en', primary_flg: false)
.order(:name).pluck(:name)
expect(aliases_after).to eq(aliases_before)
end
end
it 'allows a rename to a name already used in Nipponese' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag, primary_name: 'rename_target')
primary_tag_name_for(nipponese, 'ain').update!(
name: 'different_ainu_target', auto_generated: false)
patch "/tags/#{ tag.id }", params: { locale: 'ain-Kana', name: 'rename_target' }
patch "/tags/#{ tag.id }", params: { locale: 'en', name: 'rename_target' }
expect(response).to have_http_status(:ok), response.body
expect(primary_tag_name_for(tag.reload, 'ain')).to have_attributes(
name: 'rename_target', language_code: 'ain', tag_id: tag.id)
expect(primary_tag_name_for(tag.reload, 'en')).to have_attributes(
name: 'rename_target', language_code: 'en', tag_id: tag.id)
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
name: 'rename_target', language_code: 'ja', tag_id: nipponese.id)
end
it 'creates aliases in the requested language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
put "/tags/#{ tag.id }", params: {
locale: 'ain-Kana', name: ainu_name.name,
locale: 'en', name: english_name.name,
category: tag.category, deprecated: false,
aliases: 'ainu_alias', parent_tags: '' }
aliases: 'english_alias', parent_tags: '' }
expect(response).to have_http_status(:ok), response.body
expect(TagName.find_by!(language_code: 'ain', name: 'ainu_alias'))
expect(TagName.find_by!(language_code: 'en', name: 'english_alias'))
.to have_attributes(
script_code: 'Kana',
tag_id: tag.id, primary_flg: false, language_code: 'ain')
primary = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true)
expect(primary).to have_attributes(name: 'ainu_original', script_code: 'Kana')
script_code: 'Latn',
tag_id: tag.id, primary_flg: false, language_code: 'en')
primary = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true)
expect(primary).to have_attributes(name: 'english_original', script_code: 'Latn')
end
it 'does not take an alias from another language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag, primary_name: 'nipponese_owner')
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
@@ -344,7 +344,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
primary_flg: false, language_code: 'ja')
put "/tags/#{ tag.id }", params: {
locale: 'ain-Kana', name: ainu_name.name,
locale: 'en', name: english_name.name,
category: tag.category, deprecated: false,
aliases: 'shared_alias', parent_tags: '' }
@@ -352,12 +352,12 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
expect(nipponese_alias.reload).to have_attributes(
tag_id: nipponese.id,
primary_flg: false, language_code: 'ja')
expect(TagName.find_by!(language_code: 'ain', name: 'shared_alias'))
expect(TagName.find_by!(language_code: 'en', name: 'shared_alias'))
.to have_attributes(
tag_id: tag.id, script_code: 'Kana',
primary_flg: false, language_code: 'ain')
tag_id: tag.id, script_code: 'Latn',
primary_flg: false, language_code: 'en')
expect(TagName.where(name: 'shared_alias').pluck(:language_code, :tag_id))
.to contain_exactly(['ja', nipponese.id], ['ain', tag.id])
.to contain_exactly(['ja', nipponese.id], ['en', tag.id])
end
end
end

変更されたファイルが多すぎるため、一部のファイルは表示されません さらに表示