コミットを比較

..
7 コミット
57個のファイルの変更818行の追加2235行の削除
+2
ファイルの表示
@@ -92,4 +92,6 @@ class ApplicationController < ActionController::API
value value
end end
def resolve_locale! = Locale.find_by(code: params[:locale]) || Locale.nipponese
end end
+4 -5
ファイルの表示
@@ -72,6 +72,7 @@ class MaterialsController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
tag_name_raw = params[:tag].to_s.strip tag_name_raw = params[:tag].to_s.strip
file = params[:file] file = params[:file]
file_sha256 = MaterialFileSha256.from_upload(file) file_sha256 = MaterialFileSha256.from_upload(file)
@@ -89,7 +90,7 @@ class MaterialsController < ApplicationController
begin begin
Material.transaction do Material.transaction do
tag = resolve_material_tag!(tag_name_raw) tag = resolve_material_tag!(locale, tag_name_raw)
material = Material.new(tag:, url:, material = Material.new(tag:, url:,
created_by_user: current_user, created_by_user: current_user,
updated_by_user: current_user) updated_by_user: current_user)
@@ -236,10 +237,8 @@ class MaterialsController < ApplicationController
nil nil
end end
def resolve_material_tag! tag_name_raw def resolve_material_tag! locale, tag_name_raw
tag_name = TagName.find_or_create_by!(name: tag_name_raw) Tag.find_or_create_by_tag_name!(locale, tag_name_raw, category: :material)
tag = tag_name.tag
tag || Tag.create!(tag_name:, category: :material)
end end
def material_index_needs_tag_name? filters def material_index_needs_tag_name? filters
+24 -39
ファイルの表示
@@ -15,22 +15,17 @@ class NicoTagsController < ApplicationController
limit = 1 if limit < 1 limit = 1 if limit < 1
post_tag_max_sql = post_tag_max_sql =
PostExternalTag PostTag
.select('external_tag_id, MAX(created_at) AS max_created_at') .select('tag_id, MAX(created_at) AS max_created_at')
.group('external_tag_id') .group('tag_id')
.to_sql .to_sql
q = q = Tag.nico_tags
ExternalTag .joins(:tag_name)
.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') 'ON post_tag_max.tag_id = tags.id')
.includes(linked_tags: { tag_name: :wiki_page }) .includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page })
if name q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
q = q.where(('external_tags.name LIKE ? ' +
"OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"),
"%#{ name }%", "%#{ name }")
end
if linked_tag if linked_tag
linked_tag_ids = linked_tag_ids =
Tag Tag
@@ -43,7 +38,7 @@ class NicoTagsController < ApplicationController
if link_status.in?(['linked', 'unlinked']) if link_status.in?(['linked', 'unlinked'])
exists_sql = 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)' 'WHERE nico_tag_relations.nico_tag_id = tags.id)'
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }") q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
end end
@@ -51,21 +46,21 @@ class NicoTagsController < ApplicationController
sort_sql = sort_sql =
case order[0] case order[0]
when 'name' when 'name'
'external_tags.name' 'tag_names.name'
when 'updated_at' when 'updated_at'
'post_tag_max.max_created_at' 'post_tag_max.max_created_at'
else else
"external_tags.#{ order[0] }" "tags.#{ order[0] }"
end end
tags = q.reselect('external_tags.*', tags = q.reselect('tags.*',
Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at')) Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at'))
.order(Arel.sql("#{ sort_sql } #{ order[1] }, external_tags.id #{ order[1] }")) .order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.limit(limit) .limit(limit)
.offset((page - 1) * limit) .offset((page - 1) * limit)
.to_a .to_a
render json: { tags: tags.map { |tag| render json: { tags: tags.map { |tag|
external_tag_json(tag).merge( TagRepr.base(tag).merge(
recent_post_tag_created_at: tag.recent_post_tag_created_at, recent_post_tag_created_at: tag.recent_post_tag_created_at,
linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) }) linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) })
}, count: } }, count: }
@@ -75,25 +70,30 @@ class NicoTagsController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
id = params[:id].to_i id = params[:id].to_i
tag = ExternalTag.find(id) tag = Tag.find(id)
return render_bad_request('ニコニコ・タグを指定してください.') unless tag.nico?
linked_tag_names = params[:tags].to_s.split linked_tag_names = params[:tags].to_s.split
linked_tags = nil linked_tags = nil
ApplicationRecord.transaction do ApplicationRecord.transaction do
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false, linked_tags = Tag.normalise_tags!(locale, linked_tag_names,
with_tagme: false,
with_no_deerjikist: false) with_no_deerjikist: false)
if linked_tags.any? { |t| t.nico? }
raise Tag::NicoTagNormalisationError
end
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user) TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
tag.linked_tags = linked_tags tag.linked_tags = linked_tags
tag.save! tag.save!
NicoTagVersionRecorder.record!(external_tag: tag, NicoTagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
event_type: :update,
created_by_user: current_user)
end end
render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
@@ -105,21 +105,6 @@ class NicoTagsController < ApplicationController
private private
def external_tag_json tag
{ id: tag.id,
name: "#{ tag.platform }:#{ tag.name }",
category: 'nico',
post_count: tag.post_count,
created_at: tag.created_at,
updated_at: tag.created_at,
deprecated_at: nil,
aliases: [],
parents: [],
has_wiki: false,
material_id: nil,
has_deerjikists: false }
end
def render_nico_tag_form_record_invalid record def render_nico_tag_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag) if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message| render_validation_error fields: { tags: record.errors.full_messages.map { |message|
+48 -56
ファイルの表示
@@ -2,9 +2,6 @@ class PostVersionsController < ApplicationController
def index def index
post_id = params[:post].presence post_id = params[:post].presence
tag_id = params[:tag].presence&.to_i tag_id = params[:tag].presence&.to_i
external_tag_id = params[:external_tag].presence&.to_i
return head :bad_request if tag_id && external_tag_id
page = (params[:page].presence || 1).to_i page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i limit = (params[:limit].presence || 20).to_i
@@ -29,14 +26,9 @@ class PostVersionsController < ApplicationController
'prev.original_created_from AS prev_original_created_from', 'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before') 'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id q = q.where('post_versions.post_id = ?', post_id) if post_id
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id))) if tag_id
q = q.where('JSON_CONTAINS(post_versions.tags_json,' + q = q.where("JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('id', #{ tag_id })) " +
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " + "OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('id', #{ tag_id }))")
'OR JSON_CONTAINS(prev.tags_json,' +
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id }))")
elsif tag_id
q = q.where("JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('tag_id', #{ tag_id })) " +
"OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('tag_id', #{ tag_id }))")
end end
count = q.except(:select, :order, :limit, :offset).count count = q.except(:select, :order, :limit, :offset).count
@@ -51,19 +43,50 @@ class PostVersionsController < ApplicationController
private private
def serialise_versions rows def serialise_versions rows
rows = rows.to_a
user_ids = rows.map(&:created_by_user_id).compact.uniq user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
snapshots = rows.flat_map { |row|
[normalise_json(row.tags_json),
normalise_json(row.attributes['prev_tags_json']) || []]
}
external_tag_names = external_tag_names_for(snapshots)
rows.map do |row| rows.map do |row|
cur_tags = snapshot_tag_literals(normalise_json(row.tags_json), external_tag_names) cur_tags =
prev_tags = snapshot_tag_literals( normalise_json(row.tags_json)
normalise_json(row.attributes['prev_tags_json']) || [], external_tag_names) .sort_by { [(case _1.fetch('category')
when 'deerjikist'
0
when 'meme'
1
when 'character'
2
when 'general'
3
when 'material'
4
when 'meta'
5
else
6
end),
_1.fetch('name').downcase] }
.map { Post.tag_snapshot_literal(_1) }
prev_tags =
(normalise_json(row.attributes['prev_tags_json']) || [])
.sort_by { [(case _1.fetch('category')
when 'deerjikist'
0
when 'meme'
1
when 'character'
2
when 'general'
3
when 'material'
4
when 'meta'
5
else
6
end),
_1.fetch('name').downcase] }
.map { Post.tag_snapshot_literal(_1) }
{ post_id: row.post_id, { post_id: row.post_id,
version_no: row.version_no, version_no: row.version_no,
@@ -90,41 +113,7 @@ class PostVersionsController < ApplicationController
end end
end end
def external_tag_names_for snapshots def build_version_tags(cur_tags, prev_tags)
ids = snapshots.flatten.filter_map { _1['external_tag_id'] }.uniq
names = ExternalTag.where(id: ids).pluck(:id, :platform, :name).to_h { |id, platform, name|
[id, "#{ platform }:#{ name }"]
}
missing_ids = ids - names.keys
NicoTagVersion
.where(tag_id: missing_ids)
.order(:tag_id, version_no: :desc)
.pluck(:tag_id, :name)
.each { |id, name| names[id] ||= name }
names
end
def snapshot_tag_literals snapshots, external_tag_names
snapshots
.filter_map { |snapshot|
if snapshot.key?('tag_id')
[tag_category_order(snapshot['category']),
Post.tag_snapshot_literal(snapshot)]
elsif external_tag_names[snapshot['external_tag_id']]
[6, external_tag_names[snapshot['external_tag_id']]]
end
}
.sort_by { |order, name| [order, name.downcase] }
.map(&:second)
end
def tag_category_order category
['deerjikist', 'meme', 'character', 'general', 'material', 'meta'].index(category) || 6
end
def build_version_tags cur_tags, prev_tags
(cur_tags | prev_tags).map do |name| (cur_tags | prev_tags).map do |name|
type = type =
if cur_tags.include?(name) && prev_tags.include?(name) if cur_tags.include?(name) && prev_tags.include?(name)
@@ -135,7 +124,10 @@ class PostVersionsController < ApplicationController
'removed' 'removed'
end end
{ name:, type: } {
name:,
type:
}
end end
end end
end end
+22 -31
ファイルの表示
@@ -49,7 +49,7 @@ class PostsController < ApplicationController
filtered_posts filtered_posts
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(:external_tags, :uploaded_user, :parents, :children, .preload(:uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials, post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }]) { tag_name: :wiki_page }] }])
.with_attached_thumbnail .with_attached_thumbnail
@@ -102,9 +102,7 @@ class PostsController < ApplicationController
end end
def random def random
post = post = filtered_posts.preload(:uploaded_user, :parents, :children,
filtered_posts
.preload(:uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials, post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }]) { tag_name: :wiki_page }] }])
.with_attached_thumbnail .with_attached_thumbnail
@@ -310,6 +308,7 @@ class PostsController < ApplicationController
base_version_no = parse_base_version_no base_version_no = parse_base_version_no
return render_bad_request('base_version_no は必須です.') if !(force) && !(base_version_no) return render_bad_request('base_version_no は必須です.') if !(force) && !(base_version_no)
locale = Locale.find_by(code: params[:locale].presence) || Locale.nipponese
title = params[:title].presence title = params[:title].presence
tag_names = params[:tags].to_s.split tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from] original_created_from = params[:original_created_from]
@@ -331,7 +330,8 @@ class PostsController < ApplicationController
base_snapshot = post_snapshot_from_version(base_version) base_snapshot = post_snapshot_from_version(base_version)
current_snapshot = post_snapshot_from_record(post) current_snapshot = post_snapshot_from_record(post)
end end
incoming_snapshot = post_incoming_snapshot(title:, incoming_snapshot = post_incoming_snapshot(locale:,
title:,
original_created_from:, original_created_from:,
original_created_before:, original_created_before:,
tag_names:, tag_names:,
@@ -360,7 +360,7 @@ class PostsController < ApplicationController
end end
end end
apply_post_snapshot!(post, snapshot_to_apply) apply_post_snapshot!(locale, post, snapshot_to_apply)
end end
return render json: conflict_json, status: :conflict if conflict_json return render json: conflict_json, status: :conflict if conflict_json
@@ -428,20 +428,8 @@ class PostsController < ApplicationController
end end
end end
def tagged_post_ids_for(name) def tagged_post_ids_for(name) =
posts_by_internal_tags = Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
Post
.joins(tags: :tag_name)
.where(tag_names: { 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 def sync_post_tags! post, desired_tags, sections
desired_tags.each do |t| desired_tags.each do |t|
@@ -519,10 +507,7 @@ class PostsController < ApplicationController
memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:) memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:)
end end
internal_tags = root_ids.filter_map { |id| build_node.call(id, []) } root_ids.filter_map { |id| build_node.call(id, []) }
external_tags =
post.external_tags.map { ExternalTagRepr.inline(_1).merge(children: [], sections: []) }
internal_tags + external_tags
end end
def sibling_posts_by_parent parent_post_ids def sibling_posts_by_parent parent_post_ids
@@ -670,7 +655,7 @@ class PostsController < ApplicationController
def editable_tag_names_from_version version def editable_tag_names_from_version version
version.tags_json version.tags_json
.select { _1.key?('tag_id') } .reject { _1.fetch('category') == 'nico' }
.map { Post.tag_snapshot_literal(_1) } .map { Post.tag_snapshot_literal(_1) }
.sort .sort
end end
@@ -688,6 +673,7 @@ class PostsController < ApplicationController
post post
.post_tags .post_tags
.joins(tag: :tag_name) .joins(tag: :tag_name)
.merge(Tag.not_nico)
.merge(Tag.where(deprecated_at: nil)) .merge(Tag.where(deprecated_at: nil))
.includes(:sections, tag: :tag_name) .includes(:sections, tag: :tag_name)
.order('tag_names.name') .order('tag_names.name')
@@ -701,10 +687,12 @@ class PostsController < ApplicationController
end end
end end
def post_incoming_snapshot title:, original_created_from:, original_created_before:, def post_incoming_snapshot locale:, title:, original_created_from:, original_created_before:,
tag_names:, video_ms_param:, duration_param:, parent_post_ids: tag_names:, video_ms_param:, duration_param:, parent_post_ids:
validate_original_created_values!(original_created_from, original_created_before) validate_original_created_values!(original_created_from, original_created_before)
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true, Tag.normalise_tags!(locale, tag_names,
with_tagme: false,
deny_deprecated: true,
with_sections: true) => { tags:, sections: } with_sections: true) => { tags:, sections: }
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
@@ -840,7 +828,7 @@ class PostsController < ApplicationController
(added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present? (added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present?
end end
def apply_post_snapshot! post, snapshot def apply_post_snapshot! locale, post, snapshot
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
post.update!(title: snapshot[:title], post.update!(title: snapshot[:title],
@@ -848,12 +836,15 @@ class PostsController < ApplicationController
original_created_from: snapshot[:original_created_from], original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before]) original_created_before: snapshot[:original_created_before])
Tag.normalise_tags!(snapshot[:tag_names], Tag.normalise_tags!(locale, snapshot[:tag_names],
with_tagme: false, with_tagme: false,
deny_deprecated: true, deny_deprecated: true,
with_sections: true) => { tags:, sections: } with_sections: true) => { tags: editable_tags, sections: }
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
readonly_tags = post.tags.nico.to_a
tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil
+2
ファイルの表示
@@ -10,6 +10,7 @@ class TagChildrenController < ApplicationController
parent = Tag.find(parent_id) parent = Tag.find(parent_id)
child = Tag.find(child_id) child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user) TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
@@ -32,6 +33,7 @@ class TagChildrenController < ApplicationController
parent = Tag.find(parent_id) parent = Tag.find(parent_id)
child = Tag.find(child_id) child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user) TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
+70 -196
ファイルの表示
@@ -34,155 +34,49 @@ class TagsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
tags = q =
if post_id.present? if post_id.present?
Tag.joins(:posts, :tag_name).where(posts: { id: post_id }) Tag.joins(:posts, :tag_name)
else else
Tag.joins(:tag_name) Tag.joins(:tag_name)
end end
.includes(:tag_name, :materials, tag_name: :wiki_page)
q = q.where(posts: { id: post_id }) if post_id.present?
external_tags = q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
if post_id.present? q = q.where(category:) if category
ExternalTag.joins(:posts).where(posts: { id: post_id }) q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0]
else q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1]
ExternalTag.all q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
end q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1]
q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0]
if name q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1]
tags = tags.where('tag_names.name LIKE ?', "%#{ name }%")
external_tags =
external_tags.where("CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?",
"%#{ name }%")
end
if category == 'nico'
tags = tags.none
elsif category
tags = tags.where(category:)
external_tags = external_tags.none
end
if post_count_between[0]
tags = tags.where('tags.post_count >= ?', post_count_between[0])
external_tags = external_tags.where('external_tags.post_count >= ?', post_count_between[0])
end
if post_count_between[1]
tags = tags.where('tags.post_count <= ?', post_count_between[1])
external_tags = external_tags.where('external_tags.post_count <= ?', post_count_between[1])
end
if created_between[0]
tags = tags.where('tags.created_at >= ?', created_between[0])
external_tags = external_tags.where('external_tags.created_at >= ?', created_between[0])
end
if created_between[1]
tags = tags.where('tags.created_at <= ?', created_between[1])
external_tags = external_tags.where('external_tags.created_at <= ?', created_between[1])
end
if updated_between[0]
tags = tags.where('tags.updated_at >= ?', updated_between[0])
external_tags = external_tags.where('external_tags.created_at >= ?', updated_between[0])
end
if updated_between[1]
tags = tags.where('tags.updated_at <= ?', updated_between[1])
external_tags = external_tags.where('external_tags.created_at <= ?', updated_between[1])
end
if deprecated_given if deprecated_given
if deprecated q = deprecated ? q.where.not(deprecated_at: nil) : q.where(deprecated_at: nil)
tags = tags.where.not(deprecated_at: nil)
external_tags = external_tags.none
else
tags = tags.where(deprecated_at: nil)
end end
end
tag_sql = tags.select("'tag' AS source",
'tags.id',
'tag_names.name',
'tags.category',
'tags.post_count',
'tags.created_at',
'tags.updated_at',
'tags.deprecated_at').to_sql
external_tag_sql = external_tags.select(
"'external_tag' AS source",
'external_tags.id',
"CONCAT(external_tags.platform, ':', external_tags.name) AS name",
"'nico' AS category",
'external_tags.post_count',
'external_tags.created_at',
'external_tags.created_at AS updated_at',
'NULL AS deprecated_at').to_sql
union_sql = "#{ tag_sql } UNION ALL #{ external_tag_sql }"
sort_sql = sort_sql =
if order[0] == 'category' case order[0]
'CASE category ' + when 'name'
'tag_names.name'
when 'category'
'CASE tags.category ' +
"WHEN 'deerjikist' THEN 0 " + "WHEN 'deerjikist' THEN 0 " +
"WHEN 'meme' THEN 1 " + "WHEN 'meme' THEN 1 " +
"WHEN 'character' THEN 2 " + "WHEN 'character' THEN 2 " +
"WHEN 'general' THEN 3 " + "WHEN 'general' THEN 3 " +
"WHEN 'material' THEN 4 " + "WHEN 'material' THEN 4 " +
"WHEN 'meta' THEN 5 " + "WHEN 'meta' THEN 5 " +
"WHEN 'nico' THEN 6 " + "WHEN 'nico' THEN 6 END"
'END'
else else
order[0] "tags.#{ order[0] }"
end end
tags = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.limit(limit)
.offset(offset)
.to_a
connection = ApplicationRecord.connection render json: { tags: TagRepr.many(tags), count: q.size }
count = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
(#{ union_sql }) legacy_tags
SQL
rows = connection.select_all(<<~SQL).to_a
SELECT
source
, id
FROM
(#{ union_sql }) legacy_tags
ORDER BY
#{ sort_sql } #{ order[1] }
, id #{ order[1] }
, source #{ order[1] }
LIMIT
#{ limit }
OFFSET
#{ offset }
SQL
tag_ids = rows.filter { _1['source'] == 'tag' }.map { _1['id'] }
external_tag_ids = rows.filter { _1['source'] == 'external_tag' }.map { _1['id'] }
tags_by_id =
Tag
.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.where(id: tag_ids)
.index_by(&:id)
external_tags_by_id =
ExternalTag
.where(id: external_tag_ids)
.index_by(&:id)
render json: { tags: rows.map { |row|
if row['source'] == 'tag'
TagRepr.base(tags_by_id.fetch(row['id']))
else
external_tag_json(external_tags_by_id.fetch(row['id']))
end
}, count: }
end end
def with_depth def with_depth
@@ -206,14 +100,13 @@ class TagsController < ApplicationController
def autocomplete def autocomplete
q = params[:q].to_s.strip.sub(/\Anot:/i, '') q = params[:q].to_s.strip.sub(/\Anot:/i, '')
prefix = "#{ ActiveRecord::Base.sanitize_sql_like(q) }%"
with_nico = bool?(:nico, default: true) with_nico = bool?(:nico, default: true)
present_only = bool?(:present, default: true) present_only = bool?(:present, default: true)
alias_rows = alias_rows =
TagName TagName
.where('name LIKE ?', prefix) .where('name LIKE ?', "#{ q }%")
.where.not(canonical_id: nil) .where.not(canonical_id: nil)
.pluck(:canonical_id, :name) .pluck(:canonical_id, :name)
@@ -225,43 +118,29 @@ class TagsController < ApplicationController
matched_alias_by_tag_name_id[canonical_id] ||= alias_name matched_alias_by_tag_name_id[canonical_id] ||= alias_name
end end
base = base = Tag.joins(:tag_name)
Tag
.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, :materials, tag_name: :wiki_page)
.where(deprecated_at: nil) .where(deprecated_at: nil)
base = base.where('tags.post_count > 0') if present_only base = base.where('tags.post_count > 0') if present_only
canonical_hit = base.where('tag_names.name LIKE ?', prefix) canonical_hit =
base
.where(((with_nico ? '(tags.category = ? AND tag_names.name LIKE ?) OR ' : '') +
'tag_names.name LIKE ?'),
*(with_nico ? ['nico', "nico:#{ q }%"] : []), "#{ q }%")
internal_tags = canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq)) tags =
if canonical_ids.present?
canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq))
else
canonical_hit
end
internal_rows = tags = tags.order(Arel.sql('post_count DESC, tag_names.name')).limit(20).to_a
internal_tags
.order(Arel.sql('tags.post_count DESC, tag_names.name')) render json: tags.map { |tag|
.limit(20)
.map { |tag|
TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id]) TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
} }
return render json: internal_rows unless with_nico
external_base = ExternalTag.all
external_base = external_base.where('post_count > 0') if present_only
external_rows =
external_base
.where("CONCAT(platform, ':', name) LIKE ? OR name LIKE ?", prefix, prefix)
.order(post_count: :desc, name: :asc)
.limit(20)
.map { external_tag_json(_1) }
rows =
(internal_rows + external_rows)
.sort_by { |row| [-row['post_count'], row['name']] }
.first(20)
render json: rows
end end
def show def show
@@ -279,20 +158,14 @@ class TagsController < ApplicationController
name = params[:name].to_s.strip name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank? return render_bad_request('name は必須です.') if name.blank?
tag = tag = Tag.joins(:tag_name)
Tag
.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(tag_names: { name: }) .find_by(tag_names: { name: })
return render json: TagRepr.base(tag) if tag if tag
render json: TagRepr.base(tag)
platform, external_name = name.split(':', 2) else
return head :not_found unless external_name head :not_found
end
external_tag = ExternalTag.find_by(platform:, name: external_name)
return head :not_found unless external_tag
render json: ExternalTagRepr.base(external_tag)
end end
def deerjikists def deerjikists
@@ -405,11 +278,17 @@ class TagsController < ApplicationController
return unless validate_tag_rename(tag, name) return unless validate_tag_rename(tag, name)
locale = resolve_locale!
alias_names = params[:aliases].to_s.split.uniq alias_names = params[:aliases].to_s.split.uniq
parent_names = params[:parent_tags].to_s.split.uniq parent_names = params[:parent_tags].to_s.split.uniq
deprecated = bool?(:deprecated) deprecated = bool?(:deprecated)
if category == 'nico' if tag.nico? && deprecated
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
end
if tag.nico? || category == 'nico'
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
end end
@@ -431,7 +310,7 @@ class TagsController < ApplicationController
alias_names.delete(name) alias_names.delete(name)
update_aliases!(tag, alias_names) update_aliases!(tag, alias_names)
update_parent_tags!(tag, parent_names) update_parent_tags!(locale, tag, parent_names)
tag.reload tag.reload
@@ -457,9 +336,13 @@ class TagsController < ApplicationController
tag = Tag.find(params[:id]) tag = Tag.find(params[:id])
if tag.nico? && deprecated_given && deprecated
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
end
return unless validate_tag_rename(tag, name) return unless validate_tag_rename(tag, name)
if category.present? && category == 'nico' if tag.nico? || (category.present? && category == 'nico')
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
end end
@@ -666,6 +549,11 @@ class TagsController < ApplicationController
end end
def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return
end
TagVersionRecorder.record!(tag:, event_type:, created_by_user:) TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return unless name_changed return unless name_changed
@@ -773,9 +661,11 @@ class TagsController < ApplicationController
end end
end end
def update_parent_tags! tag, parent_names def update_parent_tags! locale, tag, parent_names
parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false, parent_tags = Tag.normalise_tags!(locale, parent_names,
with_no_deerjikist: false) with_tagme: false,
with_no_deerjikist: false,
deny_nico: true)
old_parent_tags = tag.parents.to_a old_parent_tags = tag.parents.to_a
@@ -903,20 +793,4 @@ class TagsController < ApplicationController
render_validation_error fields: render_validation_error fields:
end end
def external_tag_json tag
{ 'id' => tag.id,
'name' => "#{ tag.platform }:#{ tag.name }",
'category' => 'nico',
'deprecated_at' => nil,
'created_at' => tag.created_at,
'updated_at' => tag.created_at,
'post_count' => tag.post_count,
'matched_alias' => nil,
'aliases' => [],
'parents' => [],
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false }
end
end end
-20
ファイルの表示
@@ -1,20 +0,0 @@
class ExternalTag < ApplicationRecord
enum :platform, nico: 'nico'
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
has_many :post_external_tags, dependent: :delete_all
has_many :posts, through: :post_external_tags
has_many :nico_tag_versions, foreign_key: :tag_id, inverse_of: :external_tag
has_many :nico_tag_relations,
foreign_key: :nico_tag_id,
inverse_of: :nico_tag,
dependent: :destroy
has_many :linked_tags, through: :nico_tag_relations, source: :tag
def snapshot_linked_tag_names
linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
end
end
+3
ファイルの表示
@@ -0,0 +1,3 @@
class Language < ApplicationRecord
has_many :languages, class_name: 'Locale', foreign_key: :language_code
end
+24
ファイルの表示
@@ -0,0 +1,24 @@
class Locale < ApplicationRecord
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
def self.nipponese = Locale.find('ja')
private
def generate_tag_names!
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.name),
script_code:,
primary_flg: true,
# TODO: 公証実装したら書く.
# auto_generated: true,
canonical_id: nil)
end
end
end
+19 -5
ファイルの表示
@@ -1,10 +1,24 @@
class NicoTagRelation < ApplicationRecord class NicoTagRelation < ApplicationRecord
belongs_to :nico_tag, belongs_to :nico_tag, class_name: 'Tag'
class_name: 'ExternalTag', belongs_to :tag, class_name: 'Tag'
foreign_key: :nico_tag_id,
inverse_of: :nico_tag_relations
belongs_to :tag, class_name: 'Tag', foreign_key: :tag_id
validates :nico_tag_id, presence: true validates :nico_tag_id, presence: true
validates :tag_id, presence: true validates :tag_id, presence: true
validate :nico_tag_must_be_nico
validate :tag_mustnt_be_nico
private
def nico_tag_must_be_nico
if nico_tag && nico_tag.category != 'nico'
errors.add :nico_tag_id, 'タグのカテゴリがニコニコである必要があります.'
end
end
def tag_mustnt_be_nico
if tag && tag.category == 'nico'
errors.add :tag_id, '連携先タグのカテゴリはニコニコであってはなりません.'
end
end
end end
+1 -1
ファイルの表示
@@ -1,7 +1,7 @@
class NicoTagVersion < ApplicationRecord class NicoTagVersion < ApplicationRecord
include VersionRecord include VersionRecord
belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions belongs_to :tag
validates :name, presence: true validates :name, presence: true
end end
+3 -14
ファイルの表示
@@ -92,9 +92,6 @@ class Post < ApplicationRecord
inverse_of: :parent_post inverse_of: :parent_post
has_many :children, through: :child_post_implications, source: :post has_many :children, through: :child_post_implications, source: :post
has_many :post_external_tags, dependent: :destroy
has_many :external_tags, through: :post_external_tags
has_one_attached :thumbnail has_one_attached :thumbnail
attribute :version_no, :integer, default: 1 attribute :version_no, :integer, default: 1
@@ -153,27 +150,19 @@ class Post < ApplicationRecord
end end
def snapshot_tags_json def snapshot_tags_json
tag_snapshots =
post_tags post_tags
.joins(tag: :tag_name) .joins(tag: :tag_name)
.includes(:sections, tag: :tag_name) .includes(:sections, tag: :tag_name)
.order('tags.id') .order('tags.id')
.map { |pt| .map do |pt|
{ 'tag_id' => pt.tag.id, { 'id' => pt.tag.id,
'version_no' => pt.tag.version_no, 'version_no' => pt.tag.version_no,
'name' => pt.tag.name, 'name' => pt.tag.name,
'category' => pt.tag.category, 'category' => pt.tag.category,
'sections' => pt.sections.sort_by(&:begin_ms).map { 'sections' => pt.sections.sort_by(&:begin_ms).map {
{ 'begin_ms' => _1.begin_ms, 'end_ms' => _1.end_ms } { 'begin_ms' => _1.begin_ms, 'end_ms' => _1.end_ms }
} } } }
} end
external_tag_snapshots =
post_external_tags.order(:external_tag_id).map {
{ 'external_tag_id' => _1.external_tag_id }
}
tag_snapshots + external_tag_snapshots
end end
def self.section_literal section def self.section_literal section
-4
ファイルの表示
@@ -1,4 +0,0 @@
class PostExternalTag < ApplicationRecord
belongs_to :post
belongs_to :external_tag, counter_cache: :post_count
end
+3
ファイルの表示
@@ -0,0 +1,3 @@
class Script < ApplicationRecord
;
end
+55 -21
ファイルの表示
@@ -28,10 +28,11 @@ class Tag < ApplicationRecord
has_many :post_tags, inverse_of: :tag has_many :post_tags, inverse_of: :tag
has_many :posts, through: :post_tags has_many :posts, through: :post_tags
has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy
has_many :linked_tags, through: :nico_tag_relations, source: :tag
has_many :reversed_nico_tag_relations, has_many :reversed_nico_tag_relations,
class_name: 'NicoTagRelation', class_name: 'NicoTagRelation', foreign_key: :tag_id, dependent: :destroy
foreign_key: :tag_id,
dependent: :destroy
has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag
has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy
@@ -49,7 +50,9 @@ class Tag < ApplicationRecord
has_many :materials has_many :materials
has_many :tag_versions has_many :tag_versions
has_many :nico_tag_versions
has_many :tag_names
belongs_to :tag_name belongs_to :tag_name
delegate :wiki_page, to: :tag_name delegate :wiki_page, to: :tag_name
@@ -63,13 +66,17 @@ class Tag < ApplicationRecord
character: 'character', character: 'character',
general: 'general', general: 'general',
material: 'material', material: 'material',
nico: 'nico',
meta: 'meta' meta: 'meta'
validates :category, presence: true, inclusion: { in: Tag.categories.keys } validates :category, presence: true, inclusion: { in: Tag.categories.keys }
validate :tag_name_mustnt_start_with_nico validate :nico_tag_name_must_start_with_nico
validate :tag_name_must_be_canonical validate :tag_name_must_be_canonical
validate :category_must_be_deerjikist_with_deerjikists validate :category_must_be_deerjikist_with_deerjikists
validate :nico_tags_cannot_be_deprecated
scope :nico_tags, -> { nico }
CATEGORY_PREFIXES = { CATEGORY_PREFIXES = {
'general:' => :general, 'general:' => :general,
@@ -95,18 +102,21 @@ class Tag < ApplicationRecord
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists? def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta) def self.tagme = find_or_create_by_tag_name!(Locale.nipponese, 'タグ希望', category: :meta)
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta) def self.bot = find_or_create_by_tag_name!(Locale.nipponese, 'bot操作', category: :meta)
def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta) def self.no_deerjikist =
def self.video = find_or_create_by_tag_name!('動画', category: :meta) find_or_create_by_tag_name!(Locale.nipponese, 'ニジラー情報不詳', category: :meta)
def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta) def self.video = find_or_create_by_tag_name!(Locale.nipponese, '動画', category: :meta)
def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta) def self.niconico = find_or_create_by_tag_name!(Locale.nipponese, 'ニコニコ', category: :meta)
def self.youtube = find_or_create_by_tag_name!(Locale.nipponese, 'YouTube', category: :meta)
def self.normalise_tags! tag_names, with_tagme: true, def self.normalise_tags! locale, tag_names,
with_tagme: true,
with_no_deerjikist: true, with_no_deerjikist: true,
deny_nico: true,
deny_deprecated: false, deny_deprecated: false,
with_sections: false with_sections: false
if tag_names.any? { |n| n.downcase.start_with?('nico:') } if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError raise NicoTagNormalisationError
end end
@@ -134,7 +144,7 @@ class Tag < ApplicationRecord
name = TagName.canonicalise(name).first name = TagName.canonicalise(name).first
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| find_or_create_by_tag_name!(locale, name, category: (cat || :general)).tap do |tag|
if deny_deprecated && tag.deprecated? if deny_deprecated && tag.deprecated?
raise DeprecatedTagNormalisationError, [tag.name] raise DeprecatedTagNormalisationError, [tag.name]
end end
@@ -222,13 +232,25 @@ class Tag < ApplicationRecord
[left_end_ms, right_end_ms].max [left_end_ms, right_end_ms].max
end end
def self.find_or_create_by_tag_name! name, category: def self.find_or_create_by_tag_name! locale, name, category:
tn = TagName.find_or_create_by!(name: name.to_s.strip) language_code = locale.language_code
tn = tn.canonical if tn.canonical_id? name = name.to_s.strip
Tag.find_or_create_by!(tag_name_id: tn.id) do |t| tn = TagName.find_or_create_by!(language_code:, name:) do
t.category = category _1.script_code = locale.script_code
_1.primary_flg = true
end end
tag = tn.tag
if tag
tag.update!(tag_name_id: tn.id) if tag.tag_name_id != tn.id
return tag
end
tag = Tag.create!(tag_name: tn, category:)
tn.update!(tag:)
tag
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
retry retry
end end
@@ -266,8 +288,12 @@ class Tag < ApplicationRecord
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
source_tag.destroy! source_tag.destroy!
if source_tag.nico?
source_tag_name.destroy!
else
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current) updated_at: Time.current)
end
TagVersioning.record!(target_tag, event_type: :update, created_by_user:) TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
end end
@@ -294,9 +320,11 @@ class Tag < ApplicationRecord
private private
def tag_name_mustnt_start_with_nico def nico_tag_name_must_start_with_nico
if name.to_s.downcase.start_with?('nico:') n = name.to_s
errors.add :name, 'タグの命名規則に反してゐます.' if ((nico? && !(n.downcase.start_with?('nico:'))) ||
(!(nico?) && n.downcase.start_with?('nico:')))
errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.'
end end
end end
@@ -337,4 +365,10 @@ class Tag < ApplicationRecord
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
end end
def nico_tags_cannot_be_deprecated
if nico? && deprecated_at.present?
errors.add :deprecated_at, 'ニコタグは廃止できません.'
end
end
end end
+7 -1
ファイルの表示
@@ -1,5 +1,5 @@
class TagName < ApplicationRecord class TagName < ApplicationRecord
has_one :tag belongs_to :tag, optional: true
has_one :wiki_page has_one :wiki_page
belongs_to :canonical, class_name: 'TagName', optional: true belongs_to :canonical, class_name: 'TagName', optional: true
@@ -21,6 +21,12 @@ class TagName < ApplicationRecord
names.map { |name| tns[name]&.canonical&.name || name }.uniq names.map { |name| tns[name]&.canonical&.name || name }.uniq
end end
def self.generate_name locale, tag, name
# TODO: 言語ごとの自動命名ロジック完成したら書く.
"Tag_##{ tag.id }"
end
private private
def canonical_must_be_canonical def canonical_must_be_canonical
-20
ファイルの表示
@@ -1,20 +0,0 @@
module ExternalTagRepr
module_function
def base tag
{ 'id' => tag.id,
'name' => "#{ tag.platform }:#{ tag.name }",
'category' => 'nico',
'post_count' => tag.post_count,
'created_at' => tag.created_at,
'updated_at' => tag.created_at,
'deprecated_at' => nil,
'aliases' => [],
'parents' => [],
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false }
end
def inline(tag) = base(tag)
end
+2 -11
ファイルの表示
@@ -87,24 +87,15 @@ module PostRepr
end end
def tag_json post def tag_json post
internal_tags =
post post
.post_tags .post_tags
.reject { _1.tag.deprecated? } .reject { _1.tag.deprecated? }
.sort_by { _1.tag.name } .sort_by { _1.tag.name }
.map { |post_tag| .map do |post_tag|
TagRepr.inline(post_tag.tag).merge( TagRepr.inline(post_tag.tag).merge(
'children' => [], 'children' => [],
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms])) 'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
} end
external_tags =
post
.external_tags
.sort_by { _1.name }
.map { ExternalTagRepr.base(_1).merge('children' => [], 'sections' => []) }
internal_tags + external_tags
end end
def thumbnail_url post, host: nil def thumbnail_url post, host: nil
+6 -9
ファイルの表示
@@ -1,22 +1,19 @@
class NicoTagVersionRecorder < VersionRecorder class NicoTagVersionRecorder < VersionRecorder
def self.record! external_tag:, event_type:, created_by_user: def self.record! tag:, event_type:, created_by_user:
new(external_tag:, event_type:, created_by_user:).record! new(tag:, event_type:, created_by_user:).record!
end end
def initialize external_tag:, event_type:, created_by_user: def initialize tag:, event_type:, created_by_user:
super(record: external_tag, event_type:, created_by_user:) super(record: tag, event_type:, created_by_user:)
end end
private private
def version_class = NicoTagVersion def version_class = NicoTagVersion
def version_association = :nico_tag_versions def version_association = :nico_tag_versions
def record_key = :external_tag def record_key = :tag
def snapshot_attributes def snapshot_attributes
{ name: "#{ @record.platform }:#{ @record.name }", { name: @record.name, linked_tags: @record.snapshot_linked_tag_names.join(' ') }
linked_tags: @record.snapshot_linked_tag_names.join(' ') }
end end
def tracks_version_no_on_record? = false
end end
+8 -6
ファイルの表示
@@ -20,7 +20,8 @@ class PostCreatePlan
parent_post_ids = normalise_parent_post_ids parent_post_ids = normalise_parent_post_ids
validate_parent_post_ids!(parent_post_ids) validate_parent_post_ids!(parent_post_ids)
{ url: @attributes[:url], {
url: @attributes[:url],
title: @attributes[:title].to_s, title: @attributes[:title].to_s,
thumbnail_base: @attributes[:thumbnail_base].presence, thumbnail_base: @attributes[:thumbnail_base].presence,
original_created_from: @attributes[:original_created_from].presence, original_created_from: @attributes[:original_created_from].presence,
@@ -49,6 +50,7 @@ class PostCreatePlan
tag_names.each do |raw_name| tag_names.each do |raw_name|
tag_name, category, sections = parse_raw_tag_name(raw_name) tag_name, category, sections = parse_raw_tag_name(raw_name)
existing_tag = existing_tags_by_name[tag_name] existing_tag = existing_tags_by_name[tag_name]
raise Tag::NicoTagNormalisationError if existing_tag&.nico?
raise Tag::DeprecatedTagNormalisationError, [existing_tag.name] if existing_tag&.deprecated? raise Tag::DeprecatedTagNormalisationError, [existing_tag.name] if existing_tag&.deprecated?
direct_tag_specs << { direct_tag_specs << {
@@ -137,14 +139,14 @@ class PostCreatePlan
Tag.expand_parent_tags(existing_snapshot_tags) Tag.expand_parent_tags(existing_snapshot_tags)
.reject(&:deprecated?) .reject(&:deprecated?)
.map { |tag| .map { |tag|
{ name: tag.name, {
category: tag.category.to_sym } name: tag.name,
} category: tag.category.to_sym } }
merge_tag_specs(snapshot_tag_specs + expanded_parent_specs) merge_tag_specs(snapshot_tag_specs + expanded_parent_specs)
end end
def merge_tag_specs specs def merge_tag_specs specs
specs.each_with_object({ }) { |spec, merged| specs.each_with_object({ }) do |spec, merged|
merged[spec[:name]] = merged[spec[:name]] =
if merged.key?(spec[:name]) && merged[spec[:name]][:category] != :general if merged.key?(spec[:name]) && merged[spec[:name]][:category] != :general
merged[spec[:name]] merged[spec[:name]]
@@ -153,7 +155,7 @@ class PostCreatePlan
name: spec[:name], name: spec[:name],
category: spec[:category] } category: spec[:category] }
end end
}.values.sort_by { _1[:name] } end.values.sort_by { _1[:name] }
end end
def existing_tags_by_name def existing_tags_by_name
+9 -9
ファイルの表示
@@ -9,7 +9,7 @@ class PostCreator
@field_warnings = { } @field_warnings = { }
end end
def create! def create!(locale)
thumbnail_attachment = prepare_thumbnail_attachment thumbnail_attachment = prepare_thumbnail_attachment
post = Post.new(title: @attributes[:title].presence, post = Post.new(title: @attributes[:title].presence,
url: @attributes[:url], url: @attributes[:url],
@@ -25,7 +25,7 @@ class PostCreator
post_tags = planned_post_tags post_tags = planned_post_tags
sections = planned_sections sections = planned_sections
TagVersioning.record_tag_snapshots!(snapshot_tags, created_by_user: @actor) TagVersioning.record_tag_snapshots!(snapshot_tags, created_by_user: @actor)
post.video_ms = planned_video_ms post.video_ms = planned_video_ms(locale)
post.save! post.save!
sync_post_tags!(post, post_tags, sections) sync_post_tags!(post, post_tags, sections)
sync_parent_posts!(post, planned_parent_post_ids) sync_parent_posts!(post, planned_parent_post_ids)
@@ -53,15 +53,15 @@ class PostCreator
def planned_parent_post_ids = planned_create_attributes[:normalised_parent_post_ids] def planned_parent_post_ids = planned_create_attributes[:normalised_parent_post_ids]
def planned_video_ms def planned_video_ms locale
planned_create_attributes[:video_ms] planned_create_attributes(locale)[:video_ms]
end end
def planned_create_attributes def planned_create_attributes locale
@planned_create_attributes ||= begin @planned_create_attributes ||= begin
if @attributes.key?(:snapshot_tag_specs) if @attributes.key?(:snapshot_tag_specs)
snapshot_tags = materialise_tags(@attributes[:snapshot_tag_specs] || []) snapshot_tags = materialise_tags(locale, @attributes[:snapshot_tag_specs] || [])
post_tags = materialise_tags(@attributes[:post_tag_specs] || []) post_tags = materialise_tags(locale, @attributes[:post_tag_specs] || [])
{ {
snapshot_tags: snapshot_tags, snapshot_tags: snapshot_tags,
post_tags: post_tags, post_tags: post_tags,
@@ -92,13 +92,13 @@ class PostCreator
video_ms: plan[:video_ms] } video_ms: plan[:video_ms] }
end end
def materialise_tags specs def materialise_tags locale, specs
Array(specs).each_with_object({ }) do |spec, tags| Array(specs).each_with_object({ }) do |spec, tags|
name = spec[:name] || spec['name'] name = spec[:name] || spec['name']
category = spec[:category] || spec['category'] category = spec[:category] || spec['category']
next if name.blank? || category.blank? next if name.blank? || category.blank?
tag = Tag.find_or_create_by_tag_name!(name, category:) tag = Tag.find_or_create_by_tag_name!(locale, name, category:)
tag.update!(category:) if tag.category.to_sym != category.to_sym tag.update!(category:) if tag.category.to_sym != category.to_sym
tags[name] ||= tag tags[name] ||= tag
end.values end.values
+17 -1
ファイルの表示
@@ -1,16 +1,32 @@
class TagVersioning class TagVersioning
def self.record! tag, event_type:, created_by_user: def self.record! tag, event_type:, created_by_user:
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
else
TagVersionRecorder.record!(tag:, event_type:, created_by_user:) TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
end end
end
def self.ensure_snapshot! tag, created_by_user: def self.ensure_snapshot! tag, created_by_user:
if tag.nico?
return if tag.nico_tag_versions.exists?
NicoTagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
else
return if tag.tag_versions.exists? return if tag.tag_versions.exists?
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:) TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
end end
end
def self.record_tag_snapshot! tag, created_by_user: def self.record_tag_snapshot! tag, created_by_user:
event_type = tag.tag_versions.exists? ? :update : :create event_type =
if tag.nico?
tag.nico_tag_versions.exists? ? :update : :create
else
tag.tag_versions.exists? ? :update : :create
end
record!(tag, event_type:, created_by_user:) record!(tag, event_type:, created_by_user:)
end end
+1 -5
ファイルの表示
@@ -47,14 +47,10 @@ class VersionRecorder
end end
def update_record_version_no! version_no def update_record_version_no! version_no
return unless tracks_version_no_on_record?
@record.update_columns(version_no:) @record.update_columns(version_no:)
@record.version_no = version_no @record.version_no = version_no
end end
def tracks_version_no_on_record? = true
def validate_version_sequence! latest def validate_version_sequence! latest
if !(latest) && @event_type != 'create' if !(latest) && @event_type != 'create'
raise "#{ version_class.name } first event must be create" raise "#{ version_class.name } first event must be create"
@@ -64,7 +60,7 @@ class VersionRecorder
raise "#{ version_class.name } create event already exists" raise "#{ version_class.name } create event already exists"
end end
return if !(latest) || !(tracks_version_no_on_record?) return unless latest
if @record.version_no != latest.version_no if @record.version_no != latest.version_no
raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " + raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " +
-54
ファイルの表示
@@ -1,54 +0,0 @@
class CreateExternalTags < ActiveRecord::Migration[8.0]
def up
create_table :external_tags do |t|
t.string :platform, limit: 16, null: false
t.string :name, limit: 255, null: false
t.integer :post_count, null: false, default: 0
t.datetime :created_at, null: false
t.index [:platform, :name], unique: true
end
execute <<~SQL
INSERT INTO
external_tags(id, platform, name, post_count, created_at)
SELECT
t.id
, 'nico' AS platform
, SUBSTR(tn.name, 6) AS name
, t.post_count
, t.created_at
FROM
tags t
INNER JOIN
tag_names tn
ON
tn.id = t.tag_name_id
AND t.category = 'nico'
SQL
execute <<~SQL
INSERT INTO
external_tags(id, platform, name, post_count, created_at)
SELECT
ntv.tag_id
, 'nico' AS platform
, SUBSTR(ntv.name, 6) AS name
, 0 AS post_count
, ntv.created_at
FROM
nico_tag_versions ntv
LEFT JOIN
external_tags et
ON
et.id = ntv.tag_id
WHERE
ntv.version_no = 1
AND et.id IS NULL
SQL
end
def down
drop_table :external_tags
end
end
-29
ファイルの表示
@@ -1,29 +0,0 @@
class CreatePostExternalTags < ActiveRecord::Migration[8.0]
def up
create_table :post_external_tags, primary_key: [:post_id, :external_tag_id] do |t|
t.references :post, null: false, index: false, foreign_key: true
t.references :external_tag, null: false, foreign_key: true
t.datetime :created_at, null: false
end
execute <<~SQL
INSERT INTO
post_external_tags(post_id, external_tag_id, created_at)
SELECT
pt.post_id
, pt.tag_id AS external_tag_id
, pt.created_at
FROM
post_tags pt
INNER JOIN
tags t
ON
pt.tag_id = t.id
AND t.category = 'nico'
SQL
end
def down
drop_table :post_external_tags
end
end
-6
ファイルの表示
@@ -1,6 +0,0 @@
class ChangeForeignKeyOnNicoTagRelations < ActiveRecord::Migration[8.0]
def change
remove_foreign_key :nico_tag_relations, :tags, column: :nico_tag_id
add_foreign_key :nico_tag_relations, :external_tags, column: :nico_tag_id
end
end
-199
ファイルの表示
@@ -1,199 +0,0 @@
class MigrateExternalTags < ActiveRecord::Migration[8.0]
class MigrationPostVersion < ActiveRecord::Base
self.table_name = 'post_versions'
end
def up
x = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
post_tag_sections pts
INNER JOIN
tags t
ON
t.id = pts.tag_id
WHERE
t.category = 'nico'
SQL
if x > 0
raise "post_tag_sections に #{ x } 件のチンカスがあります!"
end
x = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
materials m
INNER JOIN
tags t
ON
t.id = m.tag_id
WHERE
t.category = 'nico'
SQL
if x > 0
raise "materials に #{ x } 件のチンカスがあります!"
end
x = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
tag_implications ti
INNER JOIN
tags t
ON
t.id = ti.tag_id
OR t.id = ti.parent_tag_id
WHERE
t.category = 'nico'
SQL
if x > 0
raise "tag_implications に #{ x } 件のチンカスがあります!"
end
execute <<~SQL
DELETE
ts
FROM
tag_similarities ts
INNER JOIN
tags t
ON
t.category = 'nico'
AND (t.id = ts.tag_id
OR t.id = ts.target_tag_id)
SQL
execute <<~SQL
DELETE
tset
FROM
theatre_skip_event_tags tset
INNER JOIN
tags t
ON
t.category = 'nico'
AND t.id = tset.tag_id
SQL
remove_check_constraint :post_versions, name: 'chk_post_versions_tags_json_schema'
say_with_time 'Migrate post_versions.tags_json' do
count = 0
MigrationPostVersion.find_each(batch_size: 500) do |version|
tags = version.tags_json.map do |tag|
if tag.fetch('category') == 'nico'
{ 'external_tag_id' => tag.fetch('id') }
else
{ 'tag_id' => tag.fetch('id'),
'version_no' => tag.fetch('version_no'),
'name' => tag.fetch('name'),
'category' => tag.fetch('category'),
'sections' => tag.fetch('sections') }
end
end
version.update_columns(tags_json: tags)
count += 1
end
count
end
add_tags_json_constraint!
tag_name_ids = connection.select_values(<<~SQL)
SELECT
tag_name_id
FROM
tags
WHERE
category = 'nico'
SQL
connection.transaction do
execute <<~SQL
DELETE
pt
FROM
post_tags pt
INNER JOIN
tags t
ON
t.category = 'nico'
AND t.id = pt.tag_id
SQL
execute <<~SQL
DELETE
FROM
tags
WHERE
category = 'nico'
SQL
unless tag_name_ids.empty?
execute <<~SQL
DELETE
FROM
tag_names
WHERE
id IN (#{ tag_name_ids.join(', ') })
SQL
end
end
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
private
def add_tags_json_constraint!
schema = { type: 'array',
items: { oneOf: [internal_tag_schema, external_tag_schema] } }
quoted_schema = connection.quote(JSON.generate(schema))
add_check_constraint :post_versions,
"JSON_SCHEMA_VALID(#{ quoted_schema }, tags_json)",
name: 'chk_post_versions_tags_json_schema'
end
def internal_tag_schema
{ type: 'object',
properties: { tag_id: { type: 'integer', minimum: 1 },
version_no: { type: 'integer', minimum: 1 },
name: { type: 'string', minLength: 1 },
category: { type: 'string',
enum: ['deerjikist',
'meme',
'character',
'general',
'material',
'meta'] },
sections: sections_schema },
required: ['tag_id', 'version_no', 'sections'],
additionalProperties: false }
end
def external_tag_schema
{ type: 'object',
properties: { external_tag_id: { type: 'integer', minimum: 1 } },
required: ['external_tag_id'],
additionalProperties: false }
end
def sections_schema
{ type: 'array',
items: { type: 'object',
properties: { begin_ms: { type: 'integer', minimum: 0 },
end_ms: { type: ['integer', 'null'], minimum: 0 } },
required: ['begin_ms', 'end_ms'],
additionalProperties: false } }
end
end
+20
ファイルの表示
@@ -0,0 +1,20 @@
class CreateLanguages < ActiveRecord::Migration[8.0]
def up
create_table :languages, id: { type: :string, limit: 16 }, primary_key: :code do |t|
t.string :name, null: false
t.datetime :deprecated_at, index: true
t.datetime :created_at, null: false
end
execute <<~SQL
INSERT INTO
languages(code, name, created_at)
VALUES
('ja', '日本語', #{ connection.quote Time.current })
SQL
end
def down
drop_table :languages
end
end
+20
ファイルの表示
@@ -0,0 +1,20 @@
class CreateScripts < ActiveRecord::Migration[8.0]
def up
create_table :scripts, id: { type: 'CHAR(4)' }, primary_key: :code do |t|
t.string :name, null: false
t.datetime :deprecated_at, index: true
t.datetime :created_at, null: false
end
execute <<~SQL
INSERT INTO
scripts(code, name, created_at)
VALUES
('Jpan', '漢字および仮名文字', #{ connection.quote Time.current })
SQL
end
def down
drop_table :scripts
end
end
+25
ファイルの表示
@@ -0,0 +1,25 @@
class CreateLocales < ActiveRecord::Migration[8.0]
def up
create_table :locales, id: { type: :string, limit: 32 }, primary_key: :code do |t|
t.string :language_code, limit: 16, null: false, index: true
t.column :script_code, 'CHAR(4)', null: false
t.string :name, null: false
t.datetime :deprecated_at, index: true
t.datetime :created_at, null: false
t.foreign_key :languages, column: :language_code, primary_key: :code
t.foreign_key :scripts, column: :script_code, primary_key: :code
end
execute <<~SQL
INSERT INTO
locales(code, language_code, script_code, name, created_at)
VALUES
('ja', 'ja', 'Jpan', '日本語', #{ connection.quote Time.current })
SQL
end
def down
drop_table :locales
end
end
+59
ファイルの表示
@@ -0,0 +1,59 @@
class AddColumnsToTagNames < ActiveRecord::Migration[8.0]
def up
add_reference :tag_names, :tag, after: :id, foreign_key: true
add_column :tag_names, :language_code, :string,
limit: 16, null: false, after: :tag_id, default: 'ja'
add_column :tag_names, :script_code, 'CHAR(4)',
null: false, after: :name, default: 'Jpan'
add_column :tag_names, :primary_flg, :boolean,
null: false, after: :script_code, default: true
add_foreign_key :tag_names, :languages, column: :language_code, primary_key: :code
add_foreign_key :tag_names, :scripts, column: :script_code, primary_key: :code
remove_index :tag_names, :name
add_index :tag_names, [:language_code, :name], unique: true
change_column_default :tag_names, :language_code, from: 'ja', to: nil
change_column_default :tag_names, :script_code, from: 'Jpan', to: nil
execute <<~SQL
UPDATE
tag_names tn
LEFT JOIN
tags AS t
ON
t.tag_name_id = COALESCE(tn.canonical_id, tn.id)
SET
tn.tag_id = t.id
, tn.primary_flg = CASE
WHEN tn.canonical_id IS NULL THEN
1
ELSE
0
END
SQL
change_column_default :tag_names, :primary_flg, from: true, to: nil
add_column :tag_names, :primary_tag_id, :bigint,
as: 'CASE WHEN primary_flg THEN tag_id ELSE NULL END'
add_index :tag_names, [:primary_tag_id, :language_code], unique: true
end
def down
remove_index :tag_names, [:primary_tag_id, :language_code]
remove_column :tag_names, :primary_tag_id
remove_foreign_key :tag_names, column: :language_code
remove_foreign_key :tag_names, column: :script_code
remove_index :tag_names, [:language_code, :name]
add_index :tag_names, :name, unique: true
remove_column :tag_names, :primary_flg
remove_column :tag_names, :script_code
remove_column :tag_names, :language_code
remove_reference :tag_names, :tag, foreign_key: true
end
end
生成ファイル
+3 -20
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -48,14 +48,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do
t.index ["tag_id"], name: "index_deerjikists_on_tag_id" t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
end end
create_table "external_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "platform", limit: 16, null: false
t.string "name", null: false
t.integer "post_count", default: 0, null: false
t.datetime "created_at", null: false
t.index ["platform", "name"], name: "index_external_tags_on_platform_and_name", unique: true
end
create_table "gekanator_ai_runs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "gekanator_ai_runs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "model", null: false t.string "model", null: false
t.integer "input_tokens", default: 0, null: false t.integer "input_tokens", default: 0, null: false
@@ -289,13 +281,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive" t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end end
create_table "post_external_tags", primary_key: ["post_id", "external_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "external_tag_id", null: false
t.datetime "created_at", null: false
t.index ["external_tag_id"], name: "index_post_external_tags_on_external_tag_id"
end
create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false t.bigint "post_id", null: false
t.bigint "parent_post_id", null: false t.bigint "parent_post_id", null: false
@@ -367,7 +352,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive" t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid" t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive" t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"oneOf\":[{\"type\":\"object\",\"properties\":{\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1},\"name\":{\"type\":\"string\",\"minLength\":1},\"category\":{\"type\":\"string\",\"enum\":[\"deerjikist\",\"meme\",\"character\",\"general\",\"material\",\"meta\"]},\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"begin_ms\":{\"type\":\"integer\",\"minimum\":0},\"end_ms\":{\"type\":[\"integer\",\"null\"],\"minimum\":0}},\"required\":[\"begin_ms\",\"end_ms\"],\"additionalProperties\":false}}},\"required\":[\"tag_id\",\"version_no\",\"sections\"],\"additionalProperties\":false},{\"type\":\"object\",\"properties\":{\"external_tag_id\":{\"type\":\"integer\",\"minimum\":1}},\"required\":[\"external_tag_id\"],\"additionalProperties\":false}]}}',`tags_json`)", name: "chk_post_versions_tags_json_schema" t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1},\"name\":{\"type\":\"string\",\"minLength\":1},\"category\":{\"type\":\"string\",\"enum\":[\"deerjikist\",\"meme\",\"character\",\"general\",\"material\",\"meta\",\"nico\"]},\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"begin_ms\":{\"type\":\"integer\",\"minimum\":0},\"end_ms\":{\"type\":[\"integer\",\"null\"],\"minimum\":0}},\"required\":[\"begin_ms\",\"end_ms\"],\"additionalProperties\":false}}},\"required\":[\"id\",\"version_no\",\"name\",\"category\",\"sections\"],\"additionalProperties\":false}}',`tags_json`)", name: "chk_post_versions_tags_json_schema"
end end
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -715,11 +700,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do
add_foreign_key "materials", "tags" add_foreign_key "materials", "tags"
add_foreign_key "materials", "users", column: "created_by_user_id" add_foreign_key "materials", "users", column: "created_by_user_id"
add_foreign_key "materials", "users", column: "updated_by_user_id" add_foreign_key "materials", "users", column: "updated_by_user_id"
add_foreign_key "nico_tag_relations", "external_tags", column: "nico_tag_id"
add_foreign_key "nico_tag_relations", "tags" add_foreign_key "nico_tag_relations", "tags"
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id" add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id"
add_foreign_key "post_external_tags", "external_tags"
add_foreign_key "post_external_tags", "posts"
add_foreign_key "post_implications", "posts" add_foreign_key "post_implications", "posts"
add_foreign_key "post_implications", "posts", column: "parent_post_id" add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts"
+32 -56
ファイルの表示
@@ -30,28 +30,9 @@ namespace :nico do
end end
end end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each(&:destroy!) PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
pt.destroy!
end end
def sync_post_external_tags! post, desired_external_tag_ids, current_external_tag_ids: nil
current_external_tag_ids ||=
PostExternalTag.where(post_id: post.id).pluck(:external_tag_id).to_set
desired_external_tag_ids = desired_external_tag_ids.compact.to_set
to_add = desired_external_tag_ids - current_external_tag_ids
to_remove = current_external_tag_ids - desired_external_tag_ids
ExternalTag.where(id: to_add.to_a).find_each do |external_tag|
begin
PostExternalTag.create!(post:, external_tag:)
rescue ActiveRecord::RecordNotUnique
;
end
end
PostExternalTag
.where(post_id: post.id, external_tag_id: to_remove.to_a)
.find_each(&:destroy!)
end end
mysql_user = ENV['MYSQL_USER'] mysql_user = ENV['MYSQL_USER']
@@ -130,63 +111,58 @@ namespace :nico do
sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id]) sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id])
end end
tags = post.tags
# 既存のタグ Id. 集合 # 既存のタグ Id. 集合
kept_tag_ids = post.tags.pluck(:id).to_set kept_tag_ids = tags.pluck(:id).to_set
# うち内部タグ Id. 集合
# 既存の外部タグ Id. 集合 kept_non_nico_tag_ids = tags.not_nico.pluck(:id).to_set
kept_external_tag_ids = post.external_tags.nico.pluck(:id).to_set
# 記載すべき外部タグ Id. のリスト
desired_external_tag_ids = []
# 記載すべき外部タグ Id. および連携される内部タグ Id. のリスト
desired_nico_tag_based_ids = []
# 記載すべき内部タグ Id. のリスト # 記載すべき内部タグ Id. のリスト
desired_tag_ids = kept_tag_ids.to_a desired_non_nico_tag_ids = []
datum['tags'].each do |raw| datum['tags'].each do |raw|
tag = ExternalTag.find_or_create_by!(platform: :nico, name: raw) name = TagNameSanitisationRule.sanitise("nico:#{ raw }")
tag = Tag.find_or_create_by_tag_name!(Locale.nipponese, name, category: :nico)
unless tag.nico_tag_versions.exists? event_type = tag.nico_tag_versions.exists? ? :update : :create
NicoTagVersionRecorder.record!(external_tag: tag, NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil)
event_type: :create,
created_by_user: nil)
end
desired_external_tag_ids << tag.id desired_nico_tag_based_ids << tag.id
# 新たに記載される外部タグと連携される内部タグを記載 # 新たに記載される外部タグと連携される内部タグを記載
# 連携タグは記載すれども消除せず. unless tag.id.in?(kept_tag_ids)
unless tag.id.in?(kept_external_tag_ids) linked_ids = tag.linked_tags.pluck(:id)
desired_tag_ids.concat(tag.linked_tags.pluck(:id)) desired_non_nico_tag_ids.concat(linked_ids)
desired_nico_tag_based_ids.concat(linked_ids)
end end
end end
deerjikist = Deerjikist.find_by(platform: :nico, code: datum['user']) deerjikist = Deerjikist.find_by(platform: :nico, code: datum['user'])
if deerjikist if deerjikist
desired_tag_ids << deerjikist.tag_id desired_non_nico_tag_ids << deerjikist.tag_id
elsif !(Tag.where(id: kept_tag_ids).where(category: :deerjikist).exists?) desired_nico_tag_based_ids << deerjikist.tag_id
desired_tag_ids << Tag.no_deerjikist.id elsif !(Tag.where(id: kept_non_nico_tag_ids).where(category: :deerjikist).exists?)
desired_non_nico_tag_ids << Tag.no_deerjikist.id
desired_nico_tag_based_ids << Tag.no_deerjikist.id
end end
desired_external_tag_ids.uniq! desired_nico_tag_based_ids.uniq!
desired_tag_ids.uniq!
# 外部タグの記載に際しては “bot 操作” タグを記載しなぃ. desired_all_tag_ids = kept_non_nico_tag_ids.to_a + desired_nico_tag_based_ids
if kept_tag_ids != desired_tag_ids.to_set desired_non_nico_tag_ids.concat(kept_non_nico_tag_ids.to_a)
desired_tag_ids << Tag.bot.id desired_non_nico_tag_ids.uniq!
desired_tag_ids.uniq! if kept_non_nico_tag_ids != desired_non_nico_tag_ids.to_set
desired_all_tag_ids << Tag.bot.id
end end
desired_all_tag_ids.uniq!
tags_changed = sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids)
kept_tag_ids != desired_tag_ids.to_set ||
kept_external_tag_ids != desired_external_tag_ids.to_set
sync_post_tags!(post, desired_tag_ids, current_tag_ids: kept_tag_ids)
sync_post_external_tags!(post, desired_external_tag_ids,
current_external_tag_ids: kept_external_tag_ids)
if post_created if post_created
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil) PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
elsif post_changed || tags_changed elsif post_changed || kept_tag_ids != desired_all_tag_ids.to_set
PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil) PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
end end
-191
ファイルの表示
@@ -1,191 +0,0 @@
require 'rails_helper'
require_relative '../../db/migrate/20260921040000_create_external_tags'
require_relative '../../db/migrate/20260921050000_create_post_external_tags'
require_relative '../../db/migrate/20260921060000_change_foreign_key_on_nico_tag_relations'
require_relative '../../db/migrate/20260921230000_migrate_external_tags'
RSpec.describe 'external tag migrations' do
self.use_transactional_tests = false
before do
record_class = Class.new(ActiveRecord::Base) do
self.abstract_class = true
end
stub_const('ExternalTagMigrationRecord', record_class)
config = ActiveRecord::Base.connection_db_config.configuration_hash
@database = "btrc_hub_test_external_tags_#{ Process.pid }_#{ SecureRandom.hex(4) }"
record_class.establish_connection(config.merge(database: nil))
@connection = record_class.lease_connection
@connection.create_database(@database)
@database_created = true
@connection.execute("USE #{ @connection.quote_table_name(@database) }")
version_class = Class.new(record_class) do
self.table_name = 'post_versions'
end
stub_const('MigrateExternalTags::MigrationPostVersion', version_class)
create_legacy_tables
seed_legacy_records
end
after do
@connection.drop_database(@database) if @database_created
ensure
ExternalTagMigrationRecord.remove_connection
end
def migrate klass
migration = klass.new
allow(migration).to receive(:connection).and_return(@connection)
migration.suppress_messages { migration.migrate(:up) }
end
def create_legacy_tables
@connection.create_table(:tag_names) { |t| t.string :name }
@connection.create_table(:tags) do |t|
t.references :tag_name, foreign_key: true
t.string :category
t.integer :post_count
t.datetime :created_at
end
@connection.create_table(:posts)
@connection.create_table(:post_tags) do |t|
t.references :post, foreign_key: true
t.references :tag, foreign_key: true
t.datetime :created_at
end
@connection.create_table(:nico_tag_versions) do |t|
t.bigint :tag_id
t.integer :version_no
t.string :name
t.datetime :created_at
end
@connection.create_table(:nico_tag_relations) do |t|
t.references :tag, foreign_key: true
t.references :nico_tag, foreign_key: { to_table: :tags }
end
[:post_tag_sections, :materials, :theatre_skip_event_tags].each do |table|
@connection.create_table(table) { |t| t.references :tag, foreign_key: true }
end
@connection.create_table(:tag_implications) do |t|
t.references :tag, foreign_key: true
t.references :parent_tag, foreign_key: { to_table: :tags }
end
@connection.create_table(:tag_similarities) do |t|
t.references :tag, foreign_key: true
t.references :target_tag, foreign_key: { to_table: :tags }
end
@connection.create_table(:post_versions) { |t| t.json :tags_json, null: false }
@connection.add_check_constraint(:post_versions, 'JSON_VALID(tags_json)',
name: 'chk_post_versions_tags_json_schema')
end
def seed_legacy_records
@connection.execute(<<~SQL)
INSERT INTO tag_names (id, name) VALUES (1, 'internal'), (2, 'nico:raw tag[]');
SQL
@connection.execute(<<~SQL)
INSERT INTO tags (id, tag_name_id, category, post_count, created_at) VALUES
(1, 1, 'general', 1, '2026-09-01'), (2, 2, 'nico', 1, '2026-09-02')
SQL
@connection.execute('INSERT INTO posts (id) VALUES (1)')
@connection.execute(<<~SQL)
INSERT INTO post_tags (post_id, tag_id, created_at) VALUES
(1, 1, '2026-09-03'), (1, 2, '2026-09-04')
SQL
@connection.execute(<<~SQL)
INSERT INTO nico_tag_versions (tag_id, version_no, name, created_at) VALUES
(2, 1, 'nico:raw tag[]', '2026-09-02'),
(3, 1, 'nico:deleted', '2026-09-01'),
(3, 2, 'nico:deleted_later', '2026-09-02')
SQL
@connection.execute('INSERT INTO nico_tag_relations (tag_id, nico_tag_id) VALUES (1, 2)')
@connection.execute(<<~SQL)
INSERT INTO tag_similarities (tag_id, target_tag_id) VALUES (1, 1), (1, 2), (2, 1)
SQL
@connection.execute('INSERT INTO theatre_skip_event_tags (tag_id) VALUES (1), (2)')
@legacy_internal = { 'id' => 1, 'version_no' => 2,
'name' => 'internal', 'category' => 'general',
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => nil }] }
snapshots = [@legacy_internal,
{ 'id' => 2, 'category' => 'nico' },
{ 'id' => 3, 'category' => 'nico' }]
@connection.execute(<<~SQL)
INSERT INTO post_versions (tags_json) VALUES
(#{ @connection.quote(snapshots.to_json) }), ('[]')
SQL
end
def prepare_external_tables
migrate(CreateExternalTags)
migrate(CreatePostExternalTags)
migrate(ChangeForeignKeyOnNicoTagRelations)
end
it 'preserves ids, raw names, counts, timestamps, post links and historical-only tags' do
prepare_external_tables
rows = @connection.select_rows(<<~SQL)
SELECT id, platform, name, post_count, DATE_FORMAT(created_at, '%Y-%m-%d')
FROM external_tags ORDER BY id
SQL
expect(rows).to eq([
[2, 'nico', 'raw tag[]', 1, '2026-09-02'],
[3, 'nico', 'deleted', 0, '2026-09-01']])
expect(@connection.select_rows(<<~SQL)).to eq([[1, 2, '2026-09-04']])
SELECT post_id, external_tag_id, DATE_FORMAT(created_at, '%Y-%m-%d')
FROM post_external_tags
SQL
expect(@connection.foreign_key_exists?(:nico_tag_relations, :external_tags,
column: :nico_tag_id)).to be(true)
expect(@connection.foreign_key_exists?(:nico_tag_relations, :tags,
column: :tag_id)).to be(true)
end
it 'converts snapshots and removes only obsolete internal rows and derived links' do
prepare_external_tables
migrate(MigrateExternalTags)
expect(@connection.select_values('SELECT id FROM tags')).to eq([1])
expect(@connection.select_values('SELECT id FROM tag_names')).to eq([1])
expect(@connection.select_values('SELECT tag_id FROM post_tags')).to eq([1])
expect(@connection.select_rows('SELECT tag_id, nico_tag_id FROM nico_tag_relations'))
.to eq([[1, 2]])
expect(@connection.select_rows('SELECT tag_id, target_tag_id FROM tag_similarities'))
.to eq([[1, 1]])
expect(@connection.select_values('SELECT tag_id FROM theatre_skip_event_tags')).to eq([1])
expect(@connection.select_value('SELECT COUNT(*) FROM nico_tag_versions')).to eq(3)
versions = MigrateExternalTags::MigrationPostVersion.order(:id)
expect(versions.first.tags_json).to eq([
@legacy_internal.except('id').merge('tag_id' => 1),
{ 'external_tag_id' => 2 }, { 'external_tag_id' => 3 }])
expect(versions.last.tags_json).to eq([])
invalid_snapshots = [@legacy_internal,
{ 'external_tag_id' => 0 },
{ 'external_tag_id' => 2, 'tag_id' => 1 },
{ 'external_tag_id' => 2, 'name' => 'extra' },
{ 'tag_id' => 1, 'version_no' => 1 }]
invalid_snapshots.each do |snapshot|
expect {
MigrateExternalTags::MigrationPostVersion.create!(tags_json: [snapshot])
}.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
expect { MigrateExternalTags.new.down }
.to raise_error(ActiveRecord::IrreversibleMigration)
end
{ post_tag_sections: '(tag_id) VALUES (2)',
materials: '(tag_id) VALUES (2)',
tag_implications: '(tag_id, parent_tag_id) VALUES (1, 2)' }.each do |table, values|
it "aborts before deleting data when #{ table } references a legacy nico tag" do
prepare_external_tables
@connection.execute("INSERT INTO #{ table } #{ values }")
expect { migrate(MigrateExternalTags) }.to raise_error(RuntimeError, /#{ table }/)
expect(@connection.select_values('SELECT id FROM tags ORDER BY id')).to eq([1, 2])
expect(MigrateExternalTags::MigrationPostVersion.first.tags_json.first)
.to eq(@legacy_internal)
end
end
end
-6
ファイルの表示
@@ -1,6 +0,0 @@
FactoryBot.define do
factory :external_tag do
platform { :nico }
sequence(:name) { |n| "external_tag_#{ n }" }
end
end
+7
ファイルの表示
@@ -11,5 +11,12 @@ FactoryBot.define do
after(:build) do |tag, evaluator| after(:build) do |tag, evaluator|
tag.name = evaluator.name if evaluator.name.present? tag.name = evaluator.name if evaluator.name.present?
end end
trait :nico do
category { :nico }
transient do
name { "nico:#{ SecureRandom.hex(4) }" }
end
end
end end
end end
-38
ファイルの表示
@@ -1,38 +0,0 @@
require 'rails_helper'
RSpec.describe ExternalTag, type: :model do
it 'preserves names without internal tag sanitisation or TagName records' do
external = nil
expect {
external = described_class.create!(platform: :nico, name: 'raw tag[]')
}.not_to change(TagName, :count)
expect(external.reload.name).to eq('raw tag[]')
end
it 'deletes post associations without deleting posts or version history' do
external = create(:external_tag)
post = create(:post)
PostExternalTag.create!(post:, external_tag: external)
version = NicoTagVersionRecorder.record!(
external_tag: external, event_type: :create, created_by_user: nil)
external.destroy!
expect(PostExternalTag.where(external_tag_id: external.id)).to be_empty
expect(post.reload.external_tags).to be_empty
expect(version.reload.tag_id).to eq(external.id)
end
it 'deletes external links without deleting linked internal tags' do
external = create(:external_tag)
tag = create(:tag)
NicoTagRelation.create!(nico_tag: external, tag:)
external.destroy!
expect(NicoTagRelation.where(nico_tag_id: external.id)).to be_empty
expect(Tag.exists?(tag.id)).to be(true)
end
end
-27
ファイルの表示
@@ -1,27 +0,0 @@
require 'rails_helper'
RSpec.describe NicoTagRelation, type: :model do
it 'does not constrain the ExternalTag association to the nico platform' do
external_tag = create(:external_tag)
tag = create(:tag)
allow(external_tag).to receive(:platform).and_return('registered_external')
allow(external_tag).to receive(:nico?).and_return(false)
expect {
described_class.create!(nico_tag: external_tag, tag:)
}.to change(described_class, :count).by(1)
expect(tag.linked_nico_tags).to contain_exactly(external_tag)
end
it 'rejects an internal Tag through the external association type' do
expect {
described_class.new(nico_tag: create(:tag), tag: create(:tag))
}.to raise_error(ActiveRecord::AssociationTypeMismatch)
end
it 'rejects an ExternalTag through the internal association type' do
expect {
described_class.new(nico_tag: create(:external_tag), tag: create(:external_tag))
}.to raise_error(ActiveRecord::AssociationTypeMismatch)
end
end
-25
ファイルの表示
@@ -1,25 +0,0 @@
require 'rails_helper'
RSpec.describe PostExternalTag, type: :model do
it 'exposes both sides and enforces a unique post and external tag pair' do
post = create(:post)
external_tag = create(:external_tag)
described_class.create!(post:, external_tag:)
expect(post.external_tags).to contain_exactly(external_tag)
expect(external_tag.posts).to contain_exactly(post)
expect { described_class.create!(post:, external_tag:) }
.to raise_error(ActiveRecord::RecordNotUnique)
end
it 'removes associations when the post is deleted while retaining the external tag' do
post = create(:post)
external_tag = create(:external_tag)
described_class.create!(post:, external_tag:)
post.destroy!
expect(described_class.where(post_id: post.id)).to be_empty
expect(external_tag.reload.posts).to be_empty
end
end
-19
ファイルの表示
@@ -6,25 +6,6 @@ RSpec.describe Post, type: :model do
PostUrlSanitisationRule.unscoped.delete_all PostUrlSanitisationRule.unscoped.delete_all
end end
describe '#snapshot_tags_json' do
it 'keeps internal snapshots and external identifiers distinct, even with the same id' do
post = create(:post)
tag = create(:tag)
external = create(:external_tag, id: tag.id)
create(:post_tag, post:, tag:)
create(:post_tag_section, post:, tag:, begin_ms: 2000, end_ms: nil)
PostExternalTag.create!(post:, external_tag: external)
expect(post.snapshot_tags_json).to eq([
{ 'tag_id' => tag.id,
'version_no' => tag.version_no,
'name' => tag.name,
'category' => tag.category,
'sections' => [{ 'begin_ms' => 2000, 'end_ms' => nil }] },
{ 'external_tag_id' => external.id }])
end
end
describe 'URL normalisation' do describe 'URL normalisation' do
it 'normalises the HTTP URL before applying sanitisation rules' do it 'normalises the HTTP URL before applying sanitisation rules' do
PostUrlSanitisationRule.create!( PostUrlSanitisationRule.create!(
+59 -26
ファイルの表示
@@ -1,29 +1,6 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Tag, type: :model do RSpec.describe Tag, type: :model do
describe 'external tag separation' do
['nico:reserved', 'NiCo:reserved'].each do |name|
it "rejects the reserved prefix #{ name } for internal tags" do
tag = build(:tag, name:)
expect(tag).to be_invalid
expect(tag.errors[:name]).to be_present
expect {
described_class.normalise_tags!([name])
}.to raise_error(Tag::NicoTagNormalisationError)
end
end
it 'finds external links by the internal tag id even when ids differ' do
tag = create(:tag)
external = create(:external_tag, id: tag.id + 10_000)
# The migration retains existing links without running model validation.
NicoTagRelation.insert_all!([{ tag_id: tag.id, nico_tag_id: external.id }])
expect(tag.linked_nico_tags).to contain_exactly(external)
end
end
describe '.normalise_tags!' do describe '.normalise_tags!' do
it 'rejects deprecated tags when deny_deprecated is enabled' do it 'rejects deprecated tags when deny_deprecated is enabled' do
tag_name = TagName.create!(name: 'normalise deprecated tag') tag_name = TagName.create!(name: 'normalise deprecated tag')
@@ -182,6 +159,20 @@ RSpec.describe Tag, type: :model do
end end
end end
describe 'deprecated validation' do
it 'rejects deprecated nico tags' do
tag = build(
:tag,
name: 'nico:deprecated_validation',
category: :nico,
deprecated_at: Time.current
)
expect(tag).not_to be_valid
expect(tag.errors[:deprecated_at]).to include('ニコタグは廃止できません.')
end
end
describe '.find_or_create_by_tag_name!' do describe '.find_or_create_by_tag_name!' do
it 'creates a tag and name with the requested category after stripping whitespace' do it 'creates a tag and name with the requested category after stripping whitespace' do
tag = nil tag = nil
@@ -299,7 +290,7 @@ RSpec.describe Tag, type: :model do
it 'deletes source relationships while preserving unrelated relationships' do it 'deletes source relationships while preserving unrelated relationships' do
parent = create(:tag) parent = create(:tag)
child = create(:tag) child = create(:tag)
nico_tag = create(:external_tag) nico_tag = create(:tag, :nico)
TagImplication.create!(tag: source_tag, parent_tag: parent) TagImplication.create!(tag: source_tag, parent_tag: parent)
TagImplication.create!(tag: child, parent_tag: source_tag) TagImplication.create!(tag: child, parent_tag: source_tag)
kept_implication = TagImplication.create!(tag: target_tag, parent_tag: parent) kept_implication = TagImplication.create!(tag: target_tag, parent_tag: parent)
@@ -421,6 +412,48 @@ RSpec.describe Tag, type: :model do
end end
end end
context 'when merging a nico source tag' do
let!(:target_tag) do
create(:tag, category: :nico, tag_name: create(:tag_name, name: 'nico:foo'))
end
let!(:source_tag) do
create(:tag, category: :nico, tag_name: create(:tag_name, name: 'nico:bar'))
end
let!(:source_tag_name_id) { source_tag.tag_name_id }
it 'deletes the source tag and name instead of keeping an alias' do
described_class.merge_tags!(target_tag, [source_tag])
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
expect(target_tag.reload.post_count).to eq(0)
end
it 'keeps nico history while deleting source links and allows recreating the name' do
linked_tag = create(:tag)
NicoTagRelation.create!(nico_tag: source_tag, tag: linked_tag)
kept_relation = NicoTagRelation.create!(nico_tag: target_tag, tag: linked_tag)
user = create_member_user!
source_name = source_tag.name
described_class.merge_tags!(target_tag, [source_tag], created_by_user: user)
expect(NicoTagRelation.all).to contain_exactly(kept_relation)
versions = NicoTagVersion.where(tag_id: source_tag.id).order(:version_no)
expect(versions.pluck(:version_no, :event_type))
.to eq([[1, 'create'], [2, 'discard']])
expect(versions.last).to have_attributes(
name: source_name, linked_tags: linked_tag.name, created_by_user: user)
recreated = described_class.find_or_create_by_tag_name!(source_name, category: :nico)
expect(recreated.id).not_to eq(source_tag.id)
expect(recreated.tag_name_id).not_to eq(source_tag_name_id)
expect(recreated.nico_tag_versions).to be_empty
expect(versions.reload.size).to eq(2)
end
end
def snapshot_tags(post) def snapshot_tags(post)
post.snapshot_tag_names.join(' ') post.snapshot_tag_names.join(' ')
end end
@@ -466,8 +499,8 @@ RSpec.describe Tag, type: :model do
expect(latest.event_type).to eq('update') expect(latest.event_type).to eq('update')
expect(latest.created_by_user).to be_nil expect(latest.created_by_user).to be_nil
expect(latest.tags).to eq(snapshot_tags(post_record.reload)) expect(latest.tags).to eq(snapshot_tags(post_record.reload))
expect(latest.tags_json.map { |item| item.fetch('tag_id') }).to eq([target_tag.id]) expect(latest.tags_json.map { |item| item.fetch('id') }).to eq([target_tag.id])
expect(affected_versions.first.tags_json.map { |item| item.fetch('tag_id') }) expect(affected_versions.first.tags_json.map { |item| item.fetch('id') })
.to eq([source_tag.id]) .to eq([source_tag.id])
expect(unaffected_post.reload.post_versions.count).to eq(1) expect(unaffected_post.reload.post_versions.count).to eq(1)
+5 -5
ファイルの表示
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe VersionRecord, type: :model do RSpec.describe VersionRecord, type: :model do
let!(:tag) { create(:tag, name: 'version_record_tag') } let!(:tag) { create(:tag, name: 'version_record_tag') }
let!(:nico_tag) { create(:external_tag, name: 'version_record_tag') } let!(:nico_tag) { create(:tag, :nico, name: 'nico:version_record_tag') }
it 'makes TagVersion read only after create' do it 'makes TagVersion read only after create' do
version = TagVersion.create!( version = TagVersion.create!(
@@ -42,10 +42,10 @@ RSpec.describe VersionRecord, type: :model do
it 'makes NicoTagVersion read only after create' do it 'makes NicoTagVersion read only after create' do
version = NicoTagVersion.create!( version = NicoTagVersion.create!(
external_tag: nico_tag, tag: nico_tag,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
name: "nico:#{ nico_tag.name }", name: nico_tag.name,
linked_tags: '', linked_tags: '',
created_at: Time.current, created_at: Time.current,
created_by_user: nil created_by_user: nil
@@ -58,10 +58,10 @@ RSpec.describe VersionRecord, type: :model do
it 'prevents NicoTagVersion destroy' do it 'prevents NicoTagVersion destroy' do
version = NicoTagVersion.create!( version = NicoTagVersion.create!(
external_tag: nico_tag, tag: nico_tag,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
name: "nico:#{ nico_tag.name }", name: nico_tag.name,
linked_tags: '', linked_tags: '',
created_at: Time.current, created_at: Time.current,
created_by_user: nil created_by_user: nil
+36 -75
ファイルの表示
@@ -3,34 +3,10 @@ require 'rails_helper'
RSpec.describe 'NicoTags', type: :request do RSpec.describe 'NicoTags', type: :request do
describe 'GET /tags/nico' do describe 'GET /tags/nico' do
it 'returns the legacy Tag-compatible external fields' do
external = create(:external_tag, name: 'legacy_external', post_count: 3)
get '/tags/nico', params: { name: 'legacy_external' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('tags')).to contain_exactly(
a_hash_including(
'id' => external.id,
'name' => 'nico:legacy_external',
'category' => 'nico',
'post_count' => 3,
'created_at' => external.created_at.as_json,
'updated_at' => external.created_at.as_json,
'deprecated_at' => nil,
'aliases' => [],
'parents' => [],
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false,
'linked_tags' => []))
end
it 'returns paginated tags and total count' do it 'returns paginated tags and total count' do
3.times { |i| create(:external_tag, name: "pagination_#{ i }") } create_list(:tag, 3, :nico)
get '/tags/nico', params: { page: 2, limit: 2, name: 'pagination_' } get '/tags/nico', params: { page: 2, limit: 2 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['tags'].size).to eq(1) expect(json['tags'].size).to eq(1)
@@ -38,12 +14,12 @@ RSpec.describe 'NicoTags', type: :request do
end end
it 'filters by nico tag name, linked tag name, and link status' do it 'filters by nico tag name, linked tag name, and link status' do
linked = create(:external_tag) linked = create(:tag, :nico)
linked.update!(name: 'search_linked') linked.tag_name.update!(name: 'nico:search_linked')
unlinked = create(:external_tag) unlinked = create(:tag, :nico)
unlinked.update!(name: 'search_unlinked') unlinked.tag_name.update!(name: 'nico:search_unlinked')
other = create(:external_tag) other = create(:tag, :nico)
other.update!(name: 'other') other.tag_name.update!(name: 'nico:other')
destination = create(:tag, :general) destination = create(:tag, :general)
destination.tag_name.update!(name: 'destination_search') destination.tag_name.update!(name: 'destination_search')
NicoTagRelation.create!(nico_tag: linked, tag: destination) NicoTagRelation.create!(nico_tag: linked, tag: destination)
@@ -65,41 +41,27 @@ RSpec.describe 'NicoTags', type: :request do
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([unlinked.id]) expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([unlinked.id])
end end
it 'filters by the qualified legacy name as well as the raw name' do
external = create(:external_tag, name: 'qualified_filter')
create(:external_tag, name: 'unrelated_filter')
['qualified_filter', 'nico:qualified_filter'].each do |name|
get '/tags/nico', params: { name: }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('tags')).to contain_exactly(
a_hash_including('id' => external.id, 'name' => 'nico:qualified_filter'))
end
end
it 'sorts by name and timestamps' do it 'sorts by name and timestamps' do
older = create(:external_tag) older = create(:tag, :nico)
older.update!(name: 'ordered_a') older.tag_name.update!(name: 'nico:a')
older.update_columns(created_at: 2.days.ago) older.update_columns(created_at: 2.days.ago)
newer = create(:external_tag) newer = create(:tag, :nico)
newer.update!(name: 'ordered_b') newer.tag_name.update!(name: 'nico:b')
newer.update_columns(created_at: 1.day.ago) newer.update_columns(created_at: 1.day.ago)
older_post_tag = older_post_tag =
PostExternalTag.create!(post: create(:post), external_tag: older) PostTag.create!(post: Post.create!(url: 'https://example.com/nico-older'), tag: older)
older_post_tag.update_columns(created_at: 1.hour.ago) older_post_tag.update_columns(created_at: 1.hour.ago)
newer_post_tag = newer_post_tag =
PostExternalTag.create!(post: create(:post), external_tag: newer) PostTag.create!(post: Post.create!(url: 'https://example.com/nico-newer'), tag: newer)
newer_post_tag.update_columns(created_at: 2.hours.ago) newer_post_tag.update_columns(created_at: 2.hours.ago)
get '/tags/nico', params: { order: 'name:desc', name: 'ordered_' } get '/tags/nico', params: { order: 'name:desc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([newer.id, older.id]) expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([newer.id, older.id])
get '/tags/nico', params: { order: 'created_at:asc', name: 'ordered_' } get '/tags/nico', params: { order: 'created_at:asc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id]) expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
get '/tags/nico', params: { order: 'updated_at:desc', name: 'ordered_' } get '/tags/nico', params: { order: 'updated_at:desc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id]) expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
expect(Time.zone.parse(json.fetch('tags').first.fetch('recent_post_tag_created_at'))) expect(Time.zone.parse(json.fetch('tags').first.fetch('recent_post_tag_created_at')))
.to be_within(1.second).of(older_post_tag.created_at) .to be_within(1.second).of(older_post_tag.created_at)
@@ -109,7 +71,7 @@ RSpec.describe 'NicoTags', type: :request do
describe 'PATCH /tags/nico/:id' do describe 'PATCH /tags/nico/:id' do
let(:member) { create(:user, :member) } let(:member) { create(:user, :member) }
let(:admin) { create(:user, :admin) } let(:admin) { create(:user, :admin) }
let(:nico_tag) { create(:external_tag) } let(:nico_tag) { create(:tag, :nico) }
it '401 when not logged in' do it '401 when not logged in' do
sign_out sign_out
@@ -123,18 +85,18 @@ RSpec.describe 'NicoTags', type: :request do
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:forbidden)
end end
it '404 when only an internal tag exists for the target id' do it '400 when target is not nico category' do
sign_in_as(member) sign_in_as(member)
non_nico = create(:tag, :general) non_nico = create(:tag, :general)
expect(ExternalTag.exists?(non_nico.id)).to be(false)
patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' } patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:bad_request)
end end
it '200 and updates linked tags while recording tag versions' do it '200 and updates linked tags while recording tag versions' do
sign_in_as(admin) sign_in_as(admin)
nico_tag = create(:external_tag, name: 'nico_tags_spec_source') nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
linked_a_name = TagName.create!(name: 'nico_linked_a') linked_a_name = TagName.create!(name: 'nico_linked_a')
linked_a = Tag.create!(tag_name: linked_a_name, category: :general) linked_a = Tag.create!(tag_name: linked_a_name, category: :general)
@@ -142,8 +104,7 @@ RSpec.describe 'NicoTags', type: :request do
linked_b_name = TagName.create!(name: 'nico_linked_b') linked_b_name = TagName.create!(name: 'nico_linked_b')
linked_b = Tag.create!(tag_name: linked_b_name, category: :general) linked_b = Tag.create!(tag_name: linked_b_name, category: :general)
NicoTagVersionRecorder.record!(external_tag: nico_tag, TagVersioning.ensure_snapshot!(nico_tag, created_by_user: admin)
event_type: :create, created_by_user: admin)
expect { expect {
patch "/tags/nico/#{nico_tag.id}", params: { patch "/tags/nico/#{nico_tag.id}", params: {
@@ -165,26 +126,26 @@ RSpec.describe 'NicoTags', type: :request do
expect(versions.map(&:event_type)).to eq(['create', 'update']) expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.last.linked_tags.split).to match_array([ expect(versions.last.linked_tags.split).to match_array([
'nico_linked_a', 'nico_linked_a',
'nico_linked_b']) 'nico_linked_b'
])
expect(versions.last.created_by_user_id).to eq(admin.id) expect(versions.last.created_by_user_id).to eq(admin.id)
end end
it 'clears existing links and records the empty mapping for a member' do it 'returns 422 when linked tag normalises to nico tag' do
sign_in_as(member) sign_in_as(member)
linked = create(:tag)
NicoTagRelation.insert_all!([{ nico_tag_id: nico_tag.id, tag_id: linked.id }]) other_nico = create(:tag, :nico, name: 'nico:linked_ng')
NicoTagVersionRecorder.record!(external_tag: nico_tag, TagName.create!(name: 'linked_ng_alias', canonical: other_nico.tag_name)
event_type: :create, created_by_user: member)
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: member)
expect { expect {
patch "/tags/nico/#{ nico_tag.id }", params: { tags: '' } patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
}.to change(NicoTagVersion, :count).by(1) }.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:unprocessable_entity)
expect(json).to eq([]) expect(json.fetch('errors')).to include(
expect(nico_tag.reload.linked_tags).to be_empty 'tags' => ['ニコニコ・タグ同士は連携できません.'])
expect(nico_tag.nico_tag_versions.order(:version_no).last)
.to have_attributes(linked_tags: '', created_by_user: member)
end end
it 'returns the tags field error when a nico tag is specified directly' do it 'returns the tags field error when a nico tag is specified directly' do
+14 -254
ファイルの表示
@@ -17,7 +17,7 @@ RSpec.describe 'Posts API', type: :request do
end end
def create_nico_tag!(name) def create_nico_tag!(name)
ExternalTag.find_or_create_by!(platform: :nico, name: name.delete_prefix('nico:')) Tag.find_or_create_by_tag_name!(name, category: :nico)
end end
def dummy_upload def dummy_upload
@@ -93,26 +93,6 @@ RSpec.describe 'Posts API', type: :request do
count count
end end
def expect_external_tag_json tag_json, external_tag
external_tag.reload
expect(tag_json).to include(
'id' => external_tag.id,
'name' => "#{ external_tag.platform }:#{ external_tag.name }",
'category' => 'nico',
'created_at' => external_tag.created_at.as_json,
'updated_at' => external_tag.created_at.as_json,
'deprecated_at' => nil,
'aliases' => [],
'parents' => [],
'post_count' => external_tag.post_count,
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false,
'children' => [],
'sections' => [])
end
let!(:tag_name) { TagName.create!(name: 'spec_tag') } let!(:tag_name) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) } let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) }
@@ -253,93 +233,6 @@ RSpec.describe 'Posts API', type: :request do
end end
end end
context 'with legacy external tag name searches' do
let!(:external_tag) { create(:external_tag, id: tag.id, name: 'search_external') }
let!(:both_post) do
create(:post).tap do |post|
PostTag.create!(post:, tag:)
PostExternalTag.create!(post:, external_tag:)
end
end
before do
PostExternalTag.create!(post: miss_post, external_tag:)
end
it 'keeps internal name searches independent of colliding external ids' do
get '/posts', params: { tags: tag.name }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(3)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(post_record.id, hit_post.id, both_post.id)
end
it 'finds posts through PostExternalTag by the qualified legacy name' do
get '/posts', params: { tags: 'nico:search_external' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(miss_post.id, both_post.id)
end
[nil, 'all'].each do |match|
it "intersects internal and external matches with match=#{ match || 'omitted' }" do
params = { tags: "#{ tag.name } nico:search_external" }
params[:match] = match if match
get '/posts', params: params
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('posts').map { _1.fetch('id') }).to eq([both_post.id])
end
end
it 'unions internal alias and external matches without duplicate posts' do
get '/posts', params: { tags: 'manko nico:search_external', match: 'any' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(4)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(post_record.id, hit_post.id, miss_post.id, both_post.id)
end
it 'excludes external matches from an internal tag search' do
get '/posts', params: { tags: "#{ tag.name } not:nico:search_external" }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(post_record.id, hit_post.id)
end
it 'unions an external match with a negated internal match' do
get '/posts', params: { tags: "nico:search_external not:#{ tag.name }", match: 'any' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(miss_post.id, both_post.id)
end
it 'keeps a missing qualified external name empty' do
get '/posts', params: { tags: 'nico:missing_search_external' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(0)
expect(json.fetch('posts')).to be_empty
end
it 'applies the same mixed name search to the existing random endpoint' do
get '/posts/random', params: { tags: "#{ tag.name } nico:search_external", match: 'all' }
expect(response).to have_http_status(:ok)
expect(json.fetch('id')).to eq(both_post.id)
end
end
context 'when tags contain not:' do context 'when tags contain not:' do
let!(:foo_tag_name) { TagName.create!(name: 'not_spec_foo') } let!(:foo_tag_name) { TagName.create!(name: 'not_spec_foo') }
let!(:foo_tag) { Tag.create!(tag_name: foo_tag_name, category: :general) } let!(:foo_tag) { Tag.create!(tag_name: foo_tag_name, category: :general) }
@@ -719,30 +612,6 @@ RSpec.describe 'Posts API', type: :request do
expect(json.fetch('count')).to eq(2) expect(json.fetch('count')).to eq(2)
end end
end end
it 'returns internal and external tags with colliding ids in the legacy tags array' do
external_tag = create(:external_tag, id: tag.id, name: 'post_index_external')
PostExternalTag.create!(post: hit_post, external_tag:)
get '/posts'
expect(response).to have_http_status(:ok)
post_json =
json
.fetch('posts')
.find { _1.fetch('id') == hit_post.id }
external_json =
post_json
.fetch('tags')
.find { _1['name'] == 'nico:post_index_external' }
expect(post_json.fetch('tags')).to include(
a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general'))
expect(external_json).not_to be_nil
expect_external_tag_json(external_json, external_tag)
end
end end
describe 'GET /posts/:id' do describe 'GET /posts/:id' do
@@ -900,25 +769,6 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(query_count).to be <= 45 expect(query_count).to be <= 45
end end
it 'returns external tags as root nodes in the legacy tag tree' do
external_tag = create(:external_tag, id: tag.id, name: 'post_detail_external')
PostExternalTag.create!(post: post_record, external_tag:)
request
expect(response).to have_http_status(:ok)
expect(json.fetch('tags')).to include(
a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general'))
external_json =
json
.fetch('tags')
.find { _1['name'] == 'nico:post_detail_external' }
expect(external_json).not_to be_nil
expect_external_tag_json(external_json, external_tag)
end
end end
context 'when post does not exist' do context 'when post does not exist' do
@@ -1400,9 +1250,9 @@ RSpec.describe 'Posts API', type: :request do
) )
end end
context 'when the external nico tag already exists' do context 'when nico tag already exists in tags' do
before do before do
create(:external_tag, name: 'nico_tag') Tag.find_or_create_by_tag_name!('nico:nico_tag', category: :nico)
end end
it 'returns 422 with tag field errors' do it 'returns 422 with tag field errors' do
@@ -1680,9 +1530,9 @@ RSpec.describe 'Posts API', type: :request do
versions = post_record.post_versions.order(:version_no) versions = post_record.post_versions.order(:version_no)
expect(versions.first.tags_json).to include( expect(versions.first.tags_json).to include(
a_hash_including('tag_id' => tag.id, a_hash_including('id' => tag.id,
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => 2000 }])) 'sections' => [{ 'begin_ms' => 1000, 'end_ms' => 2000 }]))
expect(versions.last.tags_json.map { |item| item.fetch('tag_id') }) expect(versions.last.tags_json.map { |item| item.fetch('id') })
.not_to include(tag.id) .not_to include(tag.id)
end end
@@ -1701,7 +1551,7 @@ RSpec.describe 'Posts API', type: :request do
expect(PostTag.find_by!(post: post_record, tag:).created_user).to eq(member) expect(PostTag.find_by!(post: post_record, tag:).created_user).to eq(member)
expect(tag.reload.post_count).to eq(1) expect(tag.reload.post_count).to eq(1)
snapshots = post_record.post_versions.order(:version_no).map do |version| snapshots = post_record.post_versions.order(:version_no).map do |version|
version.tags_json.map { |item| item.fetch('tag_id') } version.tags_json.map { |item| item.fetch('id') }
end end
expect(snapshots.map { |ids| ids.include?(tag.id) }).to eq([true, false, true]) expect(snapshots.map { |ids| ids.include?(tag.id) }).to eq([true, false, true])
end end
@@ -1726,9 +1576,9 @@ RSpec.describe 'Posts API', type: :request do
) )
end end
context 'when the external nico tag already exists' do context 'when nico tag already exists in tags' do
before do before do
create(:external_tag, name: 'nico_tag') Tag.find_or_create_by_tag_name!('nico:nico_tag', category: :nico)
end end
it 'returns 422 with tag field errors' do it 'returns 422 with tag field errors' do
@@ -2045,7 +1895,7 @@ RSpec.describe 'Posts API', type: :request do
base_version = create_post_version_for!(post_record.reload) base_version = create_post_version_for!(post_record.reload)
nico_tag = create_nico_tag!('nico:optimistic_lock_nico') nico_tag = create_nico_tag!('nico:optimistic_lock_nico')
PostExternalTag.create!(post: post_record, external_tag: nico_tag) PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
PostVersionRecorder.record!( PostVersionRecorder.record!(
post: post_record.reload, post: post_record.reload,
@@ -2065,14 +1915,14 @@ RSpec.describe 'Posts API', type: :request do
expect(names).to include('spec_tag') expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name) expect(names).to include(Tag.no_deerjikist.name)
expect(post_record.external_tags).to contain_exactly(nico_tag) expect(names).to include(nico_tag.name)
end end
it 'keeps nico tags even when they are not included in PUT tags' do it 'keeps nico tags even when they are not included in PUT tags' do
sign_in_as(member) sign_in_as(member)
nico_tag = create_nico_tag!('nico:readonly_update_nico') nico_tag = create_nico_tag!('nico:readonly_update_nico')
PostExternalTag.create!(post: post_record, external_tag: nico_tag) PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
base_version = create_post_version_for!(post_record.reload) base_version = create_post_version_for!(post_record.reload)
@@ -2087,15 +1937,7 @@ RSpec.describe 'Posts API', type: :request do
expect(names).to include('spec_tag') expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name) expect(names).to include(Tag.no_deerjikist.name)
expect(post_record.external_tags).to contain_exactly(nico_tag) expect(names).to include(nico_tag.name)
external_json =
json
.fetch('tags')
.find { _1['name'] == "nico:#{ nico_tag.name }" }
expect(external_json).not_to be_nil
expect_external_tag_json(external_json, nico_tag)
end end
it 'allows non-nico tags linked from nico tags to be removed by normal post update' do it 'allows non-nico tags linked from nico tags to be removed by normal post update' do
@@ -2105,7 +1947,7 @@ RSpec.describe 'Posts API', type: :request do
linked_tag = Tag.find_or_create_by_tag_name!('relation_linked_tag', category: :general) linked_tag = Tag.find_or_create_by_tag_name!('relation_linked_tag', category: :general)
NicoTagRelation.create!(nico_tag:, tag: linked_tag) NicoTagRelation.create!(nico_tag:, tag: linked_tag)
PostExternalTag.create!(post: post_record, external_tag: nico_tag) PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
PostTag.create!(post: post_record, tag: linked_tag, created_user: member) PostTag.create!(post: post_record, tag: linked_tag, created_user: member)
base_version = create_post_version_for!(post_record.reload) base_version = create_post_version_for!(post_record.reload)
@@ -2119,7 +1961,7 @@ RSpec.describe 'Posts API', type: :request do
names = post_record.reload.tags.map(&:name) names = post_record.reload.tags.map(&:name)
expect(post_record.external_tags).to contain_exactly(nico_tag) expect(names).to include(nico_tag.name)
expect(names).to include('spec_tag') expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name) expect(names).to include(Tag.no_deerjikist.name)
expect(names).not_to include(linked_tag.name) expect(names).not_to include(linked_tag.name)
@@ -2338,88 +2180,6 @@ RSpec.describe 'Posts API', type: :request do
expect(first.fetch('created_at')).to eq(t_v1.iso8601) expect(first.fetch('created_at')).to eq(t_v1.iso8601)
end end
context 'with external tag history' do
let(:external_id) { tag.id }
let(:external) { create(:external_tag, id: external_id) }
let(:external_post) { create(:post) }
before do
PostExternalTag.create!(post: external_post, external_tag: external)
PostVersionRecorder.record!(
post: external_post, event_type: :create, created_by_user: member)
unrelated_post = create(:post)
PostExternalTag.create!(post: unrelated_post, external_tag: create(:external_tag))
PostVersionRecorder.record!(
post: unrelated_post, event_type: :create, created_by_user: member)
end
it 'prefers Tag over ExternalTag for the legacy tag parameter' do
get '/posts/versions', params: { tag: tag.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(3)
expect(json.fetch('versions').map { [_1.fetch('post_id'), _1.fetch('version_no')] })
.to contain_exactly(
[post_record.id, 1], [post_record.id, 2], [other_post_version.post_id, 1])
end
it 'explicitly filters ExternalTag even when its id collides with Tag' do
get '/posts/versions', params: { external_tag: external.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('versions')).to contain_exactly(
a_hash_including('post_id' => external_post.id, 'version_no' => 1))
end
# Temporary compatibility shim until the frontend sends external_tag explicitly.
context 'with legacy tag fallback and no internal Tag with the external id' do
let(:external_id) { Tag.maximum(:id).to_i + 10_000 }
it 'falls back to ExternalTag for the legacy tag parameter' do
expect(Tag.exists?(external.id)).to be(false)
get '/posts/versions', params: { tag: external.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('versions')).to contain_exactly(
a_hash_including('post_id' => external_post.id, 'version_no' => 1))
end
[:tag, :external_tag].each do |parameter|
it "includes external removal history through the legacy API's #{ parameter }" do
external_post.post_external_tags.destroy_all
PostVersionRecorder.record!(
post: external_post.reload, event_type: :update, created_by_user: member)
get '/posts/versions', params: { parameter => external.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(json.fetch('versions')).to contain_exactly(
a_hash_including('post_id' => external_post.id, 'version_no' => 1),
a_hash_including('post_id' => external_post.id, 'version_no' => 2))
end
end
end
end
it 'can render history containing external identifiers' do
PostExternalTag.create!(post: post_record, external_tag: create(:external_tag))
post_record.update_columns(version_no: 2)
PostVersionRecorder.record!(post: post_record,
event_type: :update, created_by_user: member)
get '/posts/versions', params: { post: post_record.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(3)
expect(json.fetch('versions').first.fetch('tags')).to include(
'name' => tag2.name, 'type' => 'context')
end
it 'filters versions by tag when the current snapshot includes the tag' do it 'filters versions by tag when the current snapshot includes the tag' do
get '/posts/versions', params: { post: post_record.id, tag: tag2.id } get '/posts/versions', params: { post: post_record.id, tag: tag2.id }
+58
ファイルの表示
@@ -80,6 +80,38 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end
context 'when parent is nico' do
before { stub_current_user(admin) }
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400 and does not create relation' do
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:bad_request)
end
end
context 'when child is nico' do
before { stub_current_user(admin) }
let!(:child) { create(:tag, :nico, name: 'nico:child_ng') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400 and does not create relation' do
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:bad_request)
end
end
end end
describe "DELETE /tag_children" do describe "DELETE /tag_children" do
@@ -154,5 +186,31 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end
context 'when parent is nico' do
before { stub_current_user(admin) }
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng_delete') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end
end
context 'when child is nico' do
before { stub_current_user(admin) }
let!(:child) { create(:tag, :nico, name: 'nico:child_ng_delete') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end
end
end end
end end
+70 -197
ファイルの表示
@@ -30,30 +30,6 @@ RSpec.describe 'Tags API', type: :request do
end end
describe 'GET /tags' do describe 'GET /tags' do
it 'includes legacy external JSON alongside an internal tag with the same id' do
external = create(:external_tag, id: tag.id, name: 'spec_external', post_count: 3)
get '/tags', params: { name: 'spec_' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(response_tags).to contain_exactly(
a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general'),
a_hash_including(
'id' => external.id,
'name' => 'nico:spec_external',
'category' => 'nico',
'post_count' => 3,
'created_at' => external.created_at.as_json,
'updated_at' => external.created_at.as_json,
'deprecated_at' => nil,
'aliases' => [],
'parents' => [],
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false))
end
it 'returns tags with count and metadata' do it 'returns tags with count and metadata' do
get '/tags' get '/tags'
@@ -188,79 +164,20 @@ RSpec.describe 'Tags API', type: :request do
Tag.create!(tag_name: TagName.create!(name: 'cat_general'), category: :general) Tag.create!(tag_name: TagName.create!(name: 'cat_general'), category: :general)
Tag.create!(tag_name: TagName.create!(name: 'cat_material'), category: :material) Tag.create!(tag_name: TagName.create!(name: 'cat_material'), category: :material)
Tag.create!(tag_name: TagName.create!(name: 'cat_meta'), category: :meta) Tag.create!(tag_name: TagName.create!(name: 'cat_meta'), category: :meta)
create(:external_tag, name: 'cat_nico') Tag.create!(tag_name: TagName.create!(name: 'nico:cat_nico'), category: :nico)
get '/tags', params: { name: 'cat_', order: 'category:asc', limit: 20 } get '/tags', params: { name: 'cat_', order: 'category:asc', limit: 20 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response_names).to eq([ expect(response_names).to eq(%w[
'cat_deerjikist', 'cat_meme', 'cat_character', cat_deerjikist
'cat_general', 'cat_material', 'cat_meta', 'nico:cat_nico']) cat_meme
expect(json.fetch('count')).to eq(7) cat_character
end cat_general
cat_material
context 'with mixed legacy pagination' do cat_meta
let!(:first_tag) do nico:cat_nico
create(:tag, ])
tag_name: create(:tag_name, name: 'a_mixed_page'),
category: :meme)
end
let!(:middle_tag) do
create(:tag,
tag_name: create(:tag_name, name: 'm_mixed_page'),
category: :meta)
end
let!(:last_tag) do
create(:tag,
tag_name: create(:tag_name, name: 'z_mixed_page'),
category: :general)
end
let!(:first_external) do
create(:external_tag, id: first_tag.id, name: 'a_mixed_page')
end
let!(:last_external) do
create(:external_tag, id: last_tag.id, name: 'z_mixed_page')
end
let(:name_order) do
[
[first_tag.id, 'a_mixed_page'], [middle_tag.id, 'm_mixed_page'],
[first_external.id, 'nico:a_mixed_page'], [last_external.id, 'nico:z_mixed_page'],
[last_tag.id, 'z_mixed_page']]
end
let(:category_order) do
[
[first_tag.id, 'a_mixed_page'], [last_tag.id, 'z_mixed_page'],
[middle_tag.id, 'm_mixed_page'], [first_external.id, 'nico:a_mixed_page'],
[last_external.id, 'nico:z_mixed_page']]
end
['name', 'category'].each do |order|
['asc', 'desc'].each do |direction|
it "orders the combined records by #{ order }:#{ direction } before paging" do
ascending = order == 'name' ? name_order : category_order
expected = direction == 'asc' ? ascending : ascending.reverse
[2, 3].each do |limit|
pages = expected.each_slice(limit).to_a + [[]]
pages.each.with_index(1) do |expected_page, page|
get '/tags', params: {
name: 'mixed_page', order: "#{ order }:#{ direction }", page:, limit: }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(5)
expect(response_tags.size).to eq(expected_page.size)
expect(response_tags.size).to be <= limit
expect(response_tags.map { [_1.fetch('id'), _1.fetch('name')] })
.to eq(expected_page)
end
end
end
end
end
end end
it 'paginates and keeps total count' do it 'paginates and keeps total count' do
@@ -382,89 +299,6 @@ RSpec.describe 'Tags API', type: :request do
end end
describe 'GET /tags/autocomplete' do describe 'GET /tags/autocomplete' do
it 'combines internal and external matches without conflating equal ids' do
internal = Tag.create!(category: :general, name: 'mixed_internal', post_count: 2)
external = create(:external_tag, id: internal.id,
name: 'mixed_external', post_count: 3)
get '/tags/autocomplete', params: { q: 'not:mixed' }
expect(response).to have_http_status(:ok)
expect(json.map { |row| row.fetch('name') })
.to eq(['nico:mixed_external', 'mixed_internal'])
expect(json.first).to include(
'id' => external.id, 'category' => 'nico', 'post_count' => 3,
'created_at' => external.created_at.as_json,
'updated_at' => external.created_at.as_json,
'deprecated_at' => nil, 'matched_alias' => nil,
'aliases' => [], 'parents' => [], 'has_wiki' => false,
'material_id' => nil, 'has_deerjikists' => false)
end
it 'matches an external tag by its platform prefix' do
create(:external_tag, name: 'prefix_match', post_count: 1)
get '/tags/autocomplete', params: { q: 'nico:prefix' }
expect(json.map { |row| row.fetch('name') }).to eq(['nico:prefix_match'])
end
it 'excludes unused external tags unless present is false' do
create(:external_tag, name: 'unused_external')
get '/tags/autocomplete', params: { q: 'unused' }
expect(json).to be_empty
get '/tags/autocomplete', params: { q: 'unused', present: '0' }
expect(json.map { |row| row.fetch('name') }).to eq(['nico:unused_external'])
end
it 'limits the combined results to 20 and sorts ties by displayed name' do
11.times do |i|
name = "combined_#{ i.to_s.rjust(2, '0') }"
Tag.create!(category: :general, name:, post_count: 1)
create(:external_tag, name:, post_count: 1)
end
get '/tags/autocomplete', params: { q: 'combined' }
expected = 11.times.map { |i| "combined_#{ i.to_s.rjust(2, '0') }" }
expected += expected.map { |name| "nico:#{ name }" }
expect(json.map { |row| row.fetch('name') }).to eq(expected.first(20))
end
it 'excludes external tags but preserves internal alias matches when nico is false' do
internal = Tag.create!(category: :general, name: 'switch_internal', post_count: 1)
alias_target = Tag.create!(category: :general, name: 'alias_target', post_count: 1)
TagName.create!(name: 'switch_alias', canonical: alias_target.tag_name)
create(:external_tag, name: 'switch_external', post_count: 1)
get '/tags/autocomplete', params: { q: 'switch', nico: '0' }
expect(response).to have_http_status(:ok)
expect(json).to contain_exactly(
a_hash_including('id' => internal.id, 'name' => internal.name),
a_hash_including('id' => alias_target.id, 'name' => alias_target.name,
'matched_alias' => 'switch_alias'))
end
['%', '_'].each do |wildcard|
it "treats #{ wildcard } literally for canonical, alias, and external names" do
literal = "literal#{ wildcard }match"
Tag.create!(category: :general, name: literal, post_count: 1)
alias_target = Tag.create!(category: :general, name: 'literal_alias_target', post_count: 1)
TagName.create!(name: "#{ literal }_alias", canonical: alias_target.tag_name)
create(:external_tag, name: literal, post_count: 1)
Tag.create!(category: :general, name: 'literalXmatch', post_count: 2)
create(:external_tag, name: 'literalXmatch', post_count: 2)
get '/tags/autocomplete', params: { q: "literal#{ wildcard }" }
expect(json.map { |row| row.fetch('name') })
.to contain_exactly(literal, alias_target.name, "nico:#{ literal }")
end
end
it 'returns matching tags by q' do it 'returns matching tags by q' do
get '/tags/autocomplete', params: { q: 'spec' } get '/tags/autocomplete', params: { q: 'spec' }
@@ -506,27 +340,6 @@ RSpec.describe 'Tags API', type: :request do
end end
describe 'GET /tags/name/:name' do describe 'GET /tags/name/:name' do
it 'preserves qualified external name lookup used by wiki pages' do
external = create(:external_tag, name: 'detail_external', post_count: 3)
get "/tags/name/#{ CGI.escape('nico:detail_external') }"
expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => external.id,
'name' => 'nico:detail_external',
'category' => 'nico',
'post_count' => 3,
'created_at' => external.created_at.as_json,
'updated_at' => external.created_at.as_json,
'deprecated_at' => nil,
'aliases' => [],
'parents' => [],
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false)
end
it 'returns tag by name' do it 'returns tag by name' do
get "/tags/name/#{ CGI.escape('spec_tag') }" get "/tags/name/#{ CGI.escape('spec_tag') }"
@@ -674,6 +487,18 @@ RSpec.describe 'Tags API', type: :request do
expect(json.fetch('deprecated_at')).to be_present expect(json.fetch('deprecated_at')).to be_present
end end
it 'rejects deprecating a nico tag' do
nico_tag = Tag.create!(name: 'nico:deprecated_update', category: :nico)
patch "/tags/#{ nico_tag.id }", params: { deprecated: '1' }
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.deprecated_at).to be_nil
expect(json.fetch('errors')).to include(
'deprecated' => ['ニコタグは廃止できません.']
)
end
it 'returns 422 when changing normal tag category to nico' do it 'returns 422 when changing normal tag category to nico' do
expect { expect {
patch "/tags/#{tag.id}", params: { category: 'nico' } patch "/tags/#{tag.id}", params: { category: 'nico' }
@@ -683,6 +508,32 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.reload.category).to eq('general') expect(tag.reload.category).to eq('general')
end end
it 'returns 422 when updating nico tag name' do
nico_tag_name = TagName.create!(name: 'nico:tags_spec_source')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
expect {
patch "/tags/#{ nico_tag.id }", params: { name: 'nico:tags_spec_renamed' }
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.name).to eq('nico:tags_spec_source')
expect(nico_tag.category).to eq('nico')
end
it 'returns 422 when changing nico tag category to normal category' do
nico_tag_name = TagName.create!(name: 'nico:category_change_ng')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
expect {
patch "/tags/#{nico_tag.id}", params: { category: 'general' }
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.category).to eq('nico')
end
it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do
wiki_page = wiki_page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
@@ -1318,6 +1169,28 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.category).to eq('general') expect(tag.category).to eq('general')
end end
it 'nico tag は更新できない' do
nico_tag = Tag.create!(
tag_name: TagName.create!(name: 'nico:put_update_all_ng'),
category: :nico
)
expect {
put "/tags/#{ nico_tag.id }", params: {
name: 'nico:put_update_all_renamed',
category: 'nico',
aliases: '',
parent_tags: '',
deprecated: '0',
}
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.name).to eq('nico:put_update_all_ng')
expect(nico_tag.category).to eq('nico')
end
it 'system tag の name は変更できない' do it 'system tag の name は変更できない' do
system_tag = Tag.tagme system_tag = Tag.tagme
old_name = system_tag.name old_name = system_tag.name
-76
ファイルの表示
@@ -1,76 +0,0 @@
require 'rails_helper'
RSpec.describe NicoTagVersionRecorder do
let(:external_tag) { create(:external_tag, name: 'raw tag[]') }
let(:member) { create(:user, :member) }
def record event_type
described_class.record!(external_tag:, event_type:, created_by_user: member)
end
it 'records the external association and platform-qualified name' do
version = record(:create)
expect(version).to have_attributes(
external_tag:, version_no: 1, event_type: 'create',
name: 'nico:raw tag[]', linked_tags: '', created_by_user: member)
expect(external_tag.reload.nico_tag_versions).to contain_exactly(version)
end
it 'uses the latest history number when the record has no version_no column' do
first = record(:create)
external_tag.update!(name: 'changed')
second = record(:update)
expect(second).to have_attributes(version_no: 2, name: 'nico:changed')
expect(first.reload.name).to eq('nico:raw tag[]')
expect(external_tag.reload.has_attribute?(:version_no)).to be(false)
end
it 'builds the qualified name from the registered platform value' do
external = create(:external_tag, name: 'foo')
locked_scope = instance_double(ActiveRecord::Relation)
allow(ExternalTag).to receive(:unscoped).and_return(locked_scope)
allow(locked_scope).to receive(:lock).and_return(locked_scope)
allow(locked_scope).to receive(:find).with(external.id).and_return(external)
allow(external).to receive(:platform).and_return('registered_external')
version = described_class.record!(
external_tag: external, event_type: :create, created_by_user: member)
expect(version).to have_attributes(
external_tag: external, name: 'registered_external:foo', version_no: 1,
event_type: 'create', linked_tags: '', created_by_user: member)
end
it 'returns the latest version without appending an unchanged snapshot' do
first = record(:create)
expect { expect(record(:update)).to eq(first) }
.not_to change(NicoTagVersion, :count)
end
it 'still requires a create event before any update' do
expect { record(:update) }
.to raise_error(RuntimeError, 'NicoTagVersion first event must be create')
expect(external_tag.nico_tag_versions).to be_empty
end
it 'still rejects a second create event' do
first = record(:create)
expect { record(:create) }
.to raise_error(RuntimeError, 'NicoTagVersion create event already exists')
expect(external_tag.nico_tag_versions).to contain_exactly(first)
end
it 'records sorted linked internal names and later link removal' do
tags = ['z_link', 'a_link'].map { |name| Tag.create!(name:, category: :general) }
tags.each { |tag| NicoTagRelation.create!(nico_tag: external_tag, tag:) }
expect(record(:create).linked_tags).to eq('a_link z_link')
external_tag.linked_tags = [tags.first]
expect(record(:update)).to have_attributes(version_no: 2, linked_tags: 'z_link')
end
end
-13
ファイルの表示
@@ -50,19 +50,6 @@ RSpec.describe PostCreatePlan do
expect(plan[:video_ms]).to eq(60_000) expect(plan[:video_ms]).to eq(60_000)
end end
it 'rejects direct external tag input without creating internal records' do
create(:external_tag, name: 'reserved')
counts = [Tag.count, TagName.count, ExternalTag.count]
expect {
described_class.new(attributes: { tags: 'NiCo:reserved' }).build!
}.to raise_error(ActiveRecord::RecordInvalid) { |error|
expect(error.record.errors[:tags]).to be_present
}
expect([Tag.count, TagName.count, ExternalTag.count]).to eq(counts)
end
it 'validates a new tag name without persisting it' do it 'validates a new tag name without persisting it' do
long_name = 'a' * 256 long_name = 'a' * 256
counts = [TagName.count, Tag.count] counts = [TagName.count, Tag.count]
+16 -158
ファイルの表示
@@ -29,7 +29,7 @@ RSpec.describe 'nico:sync' do
# 追加される linked tag を準備(nico tag に紐付く一般タグ) # 追加される linked tag を準備(nico tag に紐付く一般タグ)
linked = create_tag!('spec_linked', category: 'general') linked = create_tag!('spec_linked', category: 'general')
nico = create_external_tag!('AAA') nico = create_tag!('nico:AAA', category: 'nico')
link_nico_to_tag!(nico, linked) link_nico_to_tag!(nico, linked)
# bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提) # bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提)
@@ -53,7 +53,7 @@ RSpec.describe 'nico:sync' do
active_tag_names = post.tags.joins(:tag_name).pluck('tag_names.name') active_tag_names = post.tags.joins(:tag_name).pluck('tag_names.name')
expect(active_tag_names).to include('spec_kept') expect(active_tag_names).to include('spec_kept')
expect(post.external_tags).to contain_exactly(nico) expect(active_tag_names).to include('nico:AAA')
expect(active_tag_names).to include('spec_linked') expect(active_tag_names).to include('spec_linked')
expect(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z')) expect(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z'))
@@ -115,12 +115,12 @@ RSpec.describe 'nico:sync' do
) )
# 旧nicoタグ(今回の同期結果に含まれない) # 旧nicoタグ(今回の同期結果に含まれない)
old_nico = create_external_tag!('OLD') old_nico = create_tag!('nico:OLD', category: 'nico')
PostExternalTag.create!(post:, external_tag: old_nico) PostTag.create!(post:, tag: old_nico)
create_post_version_for!(post) create_post_version_for!(post)
# 今回は NEW のみ欲しい # 今回は NEW のみ欲しい
new_nico = create_external_tag!('NEW') new_nico = create_tag!('nico:NEW', category: 'nico')
# bot/tagme 念のため # bot/tagme 念のため
Tag.bot Tag.bot
@@ -131,21 +131,23 @@ RSpec.describe 'nico:sync' do
run_rake_task('nico:sync') run_rake_task('nico:sync')
expect(PostExternalTag.exists?(post:, external_tag: old_nico)).to be(false) expect(PostTag.exists?(post:, tag: old_nico)).to be(false)
expect(old_nico.reload.post_count).to eq(0) expect(old_nico.reload.post_count).to eq(0)
expect(new_nico.reload.post_count).to eq(1) expect(new_nico.reload.post_count).to eq(1)
versions = post.post_versions.order(:version_no) versions = post.post_versions.order(:version_no)
expect(versions.first.tags_json.filter_map { |item| item['external_tag_id'] }) expect(versions.first.tags_json.map { |item| item.fetch('id') })
.to include(old_nico.id) .to include(old_nico.id)
expect(versions.last.tags_json.filter_map { |item| item['external_tag_id'] }) expect(versions.last.tags_json.map { |item| item.fetch('id') })
.to include(new_nico.id) .to include(new_nico.id)
expect(versions.last.tags_json.filter_map { |item| item['external_tag_id'] }) expect(versions.last.tags_json.map { |item| item.fetch('id') })
.not_to include(old_nico.id) .not_to include(old_nico.id)
# NEW は active にいる # NEW は active にいる
post.reload post.reload
expect(post.external_tags).to contain_exactly(new_nico) active_names = post.tags.joins(:tag_name).pluck('tag_names.name')
expect(active_names).to include('nico:NEW')
expect(active_names).not_to include('nico:OLD')
end end
def snapshot_tags(post) def snapshot_tags(post)
@@ -211,7 +213,7 @@ RSpec.describe 'nico:sync' do
create_post_version_for!(post) create_post_version_for!(post)
linked = create_tag!('spec_linked', category: 'general') linked = create_tag!('spec_linked', category: 'general')
nico = create_external_tag!('AAA') nico = create_tag!('nico:AAA', category: 'nico')
link_nico_to_tag!(nico, linked) link_nico_to_tag!(nico, linked)
Tag.bot Tag.bot
@@ -239,7 +241,7 @@ RSpec.describe 'nico:sync' do
end end
it '既存 post に差分が無いときは新しい version を作らない' do it '既存 post に差分が無いときは新しい version を作らない' do
nico = create_external_tag!('AAA') nico = create_tag!('nico:AAA', category: 'nico')
no_deerjikist = create_tag!('ニジラー情報不詳', category: 'meta') no_deerjikist = create_tag!('ニジラー情報不詳', category: 'meta')
post = Post.create!( post = Post.create!(
@@ -250,7 +252,7 @@ RSpec.describe 'nico:sync' do
original_created_before: Time.iso8601('2026-01-01T03:35:00Z') original_created_before: Time.iso8601('2026-01-01T03:35:00Z')
) )
PostExternalTag.create!(post:, external_tag: nico) PostTag.create!(post: post, tag: nico)
PostTag.create!(post: post, tag: no_deerjikist) PostTag.create!(post: post, tag: no_deerjikist)
create_post_version_for!(post) create_post_version_for!(post)
@@ -293,7 +295,7 @@ RSpec.describe 'nico:sync' do
run_rake_task('nico:sync') run_rake_task('nico:sync')
}.to change(NicoTagVersion, :count).by(1) }.to change(NicoTagVersion, :count).by(1)
nico_tag = ExternalTag.find_by!(platform: :nico, name: 'AAA') nico_tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'nico:AAA' })
version = nico_tag.nico_tag_versions.order(:version_no).last version = nico_tag.nico_tag_versions.order(:version_no).last
expect(version.version_no).to eq(1) expect(version.version_no).to eq(1)
@@ -376,148 +378,4 @@ RSpec.describe 'nico:sync' do
expect(versions.second.title).to eq('changed title') expect(versions.second.title).to eq('changed title')
expect(versions.second.tags).to eq(snapshot_tags(post.reload)) expect(versions.second.tags).to eq(snapshot_tags(post.reload))
end end
def create_external_tag!(name)
ExternalTag.create!(platform: :nico, name:)
end
def create_nico_sync_post!
post = Post.create!(
title: 't',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil
)
PostTag.create!(post:, tag: Tag.no_deerjikist)
post
end
def run_nico_sync_with_tags! tags
stub_python([{
'code' => 'sm9',
'title' => 't',
'tags' => tags,
'user' => nil
}])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
run_rake_task('nico:sync')
end
it '外部タグだけの変更では bot を付けず,投稿履歴を記録する' do
post = create_nico_sync_post!
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
expect {
run_nico_sync_with_tags!(['raw tag[]', 'raw tag[]'])
}.to change(PostVersion, :count).by(1)
.and change(ExternalTag, :count).by(1)
.and change(PostExternalTag, :count).by(1)
.and change(NicoTagVersion, :count).by(1)
.and change(TagName, :count).by(0)
external = post.external_tags.sole
expect(external.name).to eq('raw tag[]')
expect(post.tags.map(&:name)).not_to include('bot操作')
expect(post.post_versions.order(:version_no).last.tags_json)
.to include('external_tag_id' => external.id)
expect {
run_nico_sync_with_tags!(['raw tag[]'])
}.to change(PostVersion, :count).by(0).and change(NicoTagVersion, :count).by(0)
expect {
run_nico_sync_with_tags!([])
}.to change(PostVersion, :count).by(1)
expect(post.reload.external_tags).to be_empty
expect(post.tags.map(&:name)).not_to include('bot操作')
end
it '外部タグが新規記載されたとき,その時点の連携タグを記載する' do
post = create_nico_sync_post!
external_tag = create_external_tag!('AAA')
linked_tag = create_tag!('spec_linked', category: :general)
link_nico_to_tag!(external_tag, linked_tag)
run_nico_sync_with_tags!(['AAA'])
expect(post.reload.external_tags).to include(external_tag)
expect(post.tags).to include(linked_tag)
end
it '外部タグに差分がない場合,内外マッピングが変はっても連携タグを再評価しない' do
post = create_nico_sync_post!
external_tag = create_external_tag!('AAA')
old_linked_tag = create_tag!('spec_old_linked', category: :general)
new_linked_tag = create_tag!('spec_new_linked', category: :general)
relation = link_nico_to_tag!(external_tag, old_linked_tag)
run_nico_sync_with_tags!(['AAA'])
expect(post.reload.tags).to include(old_linked_tag)
# 人手で連携タグを消除する.
PostTag.find_by!(post:, tag: old_linked_tag).destroy!
# 内外マッピングを変更する.
relation.destroy!
link_nico_to_tag!(external_tag, new_linked_tag)
# 外部タグ自体には差分が無い.
run_nico_sync_with_tags!(['AAA'])
post.reload
expect(post.external_tags).to include(external_tag)
expect(post.tags).not_to include(old_linked_tag)
expect(post.tags).not_to include(new_linked_tag)
end
it '外部タグの消除では連携タグを消除せず,再記載時にその時点の内外マッピングを適用する' do
post = create_nico_sync_post!
external_tag = create_external_tag!('AAA')
old_linked_tag = create_tag!('spec_old_linked', category: :general)
new_linked_tag = create_tag!('spec_new_linked', category: :general)
relation = link_nico_to_tag!(external_tag, old_linked_tag)
# 外部タグを新規記載する.
run_nico_sync_with_tags!(['AAA'])
post.reload
expect(post.external_tags).to include(external_tag)
expect(post.tags).to include(old_linked_tag)
# 外部タグを消除する.
run_nico_sync_with_tags!([])
post.reload
expect(post.external_tags).not_to include(external_tag)
# 外部タグの消除によって連携タグまでは消除されない.
expect(post.tags).to include(old_linked_tag)
# 外部タグが記載されてゐない間に内外マッピングを変更する.
relation.destroy!
link_nico_to_tag!(external_tag, new_linked_tag)
# 同じ外部タグを再記載する.
run_nico_sync_with_tags!(['AAA'])
post.reload
expect(post.external_tags).to include(external_tag)
# 旧連携タグは自動的には消除されない.
expect(post.tags).to include(old_linked_tag)
# 再記載時点の内外マッピングが新たに適用される.
expect(post.tags).to include(new_linked_tag)
end
end end
+2 -27
ファイルの表示
@@ -13,14 +13,11 @@ vi.mock ('@dnd-kit/core', () => dndKit)
const tag = buildTag ({ id: 7, name: 'ドラッグ元', postCount: 3 }) const tag = buildTag ({ id: 7, name: 'ドラッグ元', postCount: 3 })
const renderRow = ( const renderRow = (activeDndId?: string) => {
activeDndId?: string,
renderedTag = tag,
) => {
renderWithProviders ( renderWithProviders (
<DraggableDroppableTagRow <DraggableDroppableTagRow
activeDndId={activeDndId} activeDndId={activeDndId}
tag={renderedTag} tag={tag}
nestLevel={2} nestLevel={2}
pathKey="cat-general-7" pathKey="cat-general-7"
suppressClickRef={{ current: false }}/>, suppressClickRef={{ current: false }}/>,
@@ -75,26 +72,4 @@ describe ('DraggableDroppableTagRow', () => {
renderRow ('tag-node:other') renderRow ('tag-node:other')
expect (tagBody ()).toHaveStyle ({ visibility: 'visible' }) expect (tagBody ()).toHaveStyle ({ visibility: 'visible' })
}) })
it ('disables drag and drop for external tags', () => {
const external = buildTag ({
id: 7,
name: 'nico:external',
category: 'nico',
})
renderRow (undefined, external)
expect (dndKit.useDraggable).toHaveBeenCalledWith (
expect.objectContaining ({
disabled: true,
}),
)
expect (dndKit.useDroppable).toHaveBeenCalledWith (
expect.objectContaining ({
disabled: true,
}),
)
})
}) })
-66
ファイルの表示
@@ -1,66 +0,0 @@
import { screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagDetailSidebar from '@/components/TagDetailSidebar'
import { setClientTagRelationDisplayMode } from '@/lib/settings'
import { buildPost, buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { ReactNode } from 'react'
vi.mock ('@/components/TagSearch', () => ({
default: () => null,
}))
vi.mock ('@/components/DraggableDroppableTagRow', () => ({
default: ({ tag }: { tag: { name: string } }) => (
<span>{tag.name}</span>
),
}))
vi.mock ('@dnd-kit/core', () => ({
DndContext: ({ children }: { children: ReactNode }) => <>{children}</>,
DragOverlay: ({ children }: { children: ReactNode }) => <>{children}</>,
MeasuringStrategy: { Always: 'always' },
MouseSensor: vi.fn (),
TouchSensor: vi.fn (),
pointerWithin: vi.fn (),
useDroppable: vi.fn (() => ({
setNodeRef: vi.fn (),
isOver: false,
})),
useSensor: vi.fn (() => ({ })),
useSensors: vi.fn (() => []),
}))
describe ('TagDetailSidebar', () => {
beforeEach (() => {
localStorage.clear ()
vi.clearAllMocks ()
})
it ('keeps internal and external tags with the same numeric id in flat mode', () => {
setClientTagRelationDisplayMode ('flat')
const internal = buildTag ({
id: 7,
name: 'internal_collision',
category: 'general',
})
const external = buildTag ({
id: 7,
name: 'nico:external_collision',
category: 'nico',
})
renderWithProviders (
<TagDetailSidebar
post={buildPost ({
tags: [internal, external],
})}/>,
)
expect (screen.getByText ('internal_collision')).toBeInTheDocument ()
expect (screen.getByText ('nico:external_collision')).toBeInTheDocument ()
})
})
-27
ファイルの表示
@@ -57,31 +57,4 @@ describe ('TagLink', () => {
expect (screen.getByText ('正式名')).toBeInTheDocument () expect (screen.getByText ('正式名')).toBeInTheDocument ()
expect (screen.queryByRole ('link')).not.toBeInTheDocument () expect (screen.queryByRole ('link')).not.toBeInTheDocument ()
}) })
it ('does not show a missing-information marker for external tags', () => {
renderWithProviders (
<TagLink
tag={buildTag ({
id: 7,
name: 'nico:external',
category: 'nico',
hasWiki: false,
materialId: null,
hasDeerjikists: false,
})}
withCount={false}/>,
)
expect (
screen.getByRole ('link', { name: 'nico:external' }),
).toBeInTheDocument ()
expect (
screen.queryByRole ('link', { name: '!' }),
).not.toBeInTheDocument ()
expect (
screen.queryByTitle ('nico:external Wiki が存在しません.'),
).not.toBeInTheDocument ()
})
}) })
-21
ファイルの表示
@@ -116,25 +116,4 @@ describe ('posts API functions', () => {
{ params: { page: 2, limit: 50 } }, { params: { page: 2, limit: 50 } },
) )
}) })
it ('maps an explicit external tag history filter to external_tag', async () => {
api.apiGet.mockResolvedValueOnce ({ versions: [], count: 0 })
await fetchPostChanges ({
externalTag: '7',
page: 2,
limit: 50,
})
expect (api.apiGet).toHaveBeenCalledWith (
'/posts/versions',
{
params: {
external_tag: '7',
page: 2,
limit: 50,
},
},
)
})
}) })
-15
ファイルの表示
@@ -137,19 +137,4 @@ describe ('prefetchForURL', () => {
expect (tagsApi.fetchTags).not.toHaveBeenCalled () expect (tagsApi.fetchTags).not.toHaveBeenCalled ()
expect (wikiApi.fetchWikiPages).not.toHaveBeenCalled () expect (wikiApi.fetchWikiPages).not.toHaveBeenCalled ()
}) })
it ('prefetches external tag post history without treating it as an internal tag', async () => {
await prefetchForURL (
qc (),
'http://localhost/posts/changes?external_tag=12&page=2&limit=50',
)
expect (postsApi.fetchPostChanges).toHaveBeenCalledWith ({
externalTag: '12',
page: 2,
limit: 50,
})
expect (tagsApi.fetchTag).not.toHaveBeenCalled ()
})
}) })
-44
ファイルの表示
@@ -1,44 +0,0 @@
import { waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import { renderWithProviders } from '@/test/render'
const postsApi = vi.hoisted (() => ({
fetchPostChanges: vi.fn (),
updatePost: vi.fn (),
}))
const tagsApi = vi.hoisted (() => ({
fetchTag: vi.fn (),
}))
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/tags', () => tagsApi)
describe ('PostHistoryPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
postsApi.fetchPostChanges.mockResolvedValue ({
versions: [],
count: 0,
})
})
it ('filters by external_tag without resolving the id as an internal tag', async () => {
renderWithProviders (
<PostHistoryPage/>,
{ route: '/posts/changes?external_tag=7' },
)
await waitFor (() => {
expect (postsApi.fetchPostChanges).toHaveBeenCalledWith ({
externalTag: '7',
page: 1,
limit: 20,
})
})
expect (tagsApi.fetchTag).not.toHaveBeenCalled ()
})
})
+1 -46
ファイルの表示
@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagListPage from '@/pages/tags/TagListPage' import TagListPage from '@/pages/tags/TagListPage'
@@ -127,49 +127,4 @@ describe ('TagListPage', () => {
) )
}) })
}) })
it ('keeps colliding internal and external tags on their own routes', async () => {
const internal = buildTag ({
id: 7,
name: 'internal_collision',
category: 'general',
})
const external = buildTag ({
id: 7,
name: 'nico:external_collision',
category: 'nico',
})
tagsApi.fetchTags.mockResolvedValueOnce ({
tags: [internal, external],
count: 2,
})
renderWithProviders (<TagListPage/>, { route: '/tags' })
const internalLink =
await screen.findByRole ('link', { name: 'internal_collision' })
const externalLink =
screen.getByRole ('link', { name: 'nico:external_collision' })
expect (internalLink).toHaveAttribute ('href', '/tags/7')
expect (externalLink).toHaveAttribute (
'href',
'/tags/nico?name=nico%3Aexternal_collision',
)
const internalRow = internalLink.closest ('tr')
const externalRow = externalLink.closest ('tr')
expect (internalRow).not.toBeNull ()
expect (externalRow).not.toBeNull ()
expect (
within (internalRow!).getByRole ('link', { name: '耕作履歴' }),
).toHaveAttribute ('href', '/posts/changes?tag=7')
expect (
within (externalRow!).getByRole ('link', { name: '耕作履歴' }),
).toHaveAttribute ('href', '/posts/changes?external_tag=7')
})
}) })