コミットを比較

..
24 コミット
作成者 SHA1 メッセージ 日付
みてるぞ a508ea6f8b #55 2026-10-03 01:42:54 +09:00
みてるぞ 927ce90adc #55 2026-10-03 01:30:26 +09:00
みてるぞ 860cd82175 #55 2026-10-01 23:21:03 +09:00
みてるぞ 0b9fa03f6d #55 2026-10-01 22:56:15 +09:00
みてるぞ 9d6de29955 #55 2026-10-01 12:45:10 +09:00
みてるぞ 75cc6ac9a2 #55 2026-09-30 01:14:58 +09:00
みてるぞ 1e398bfb97 #55 2026-09-30 00:30:05 +09:00
みてるぞ 3dd13171fc #55 2026-09-28 01:39:26 +09:00
みてるぞ 56b656109d #55 2026-09-28 01:15:00 +09:00
みてるぞ 28e638e360 #55 2026-09-28 00:30:03 +09:00
みてるぞ 749d49327e #55 2026-09-27 23:55:19 +09:00
みてるぞ ab1b0e9def #55 2026-09-27 17:16:08 +09:00
みてるぞ 56205b7052 #55 2026-09-27 17:13:02 +09:00
みてるぞ c912bebbee #55 2026-09-27 15:07:24 +09:00
みてるぞ 701830b643 #55 2026-09-27 03:47:27 +09:00
みてるぞ 5abb6cca98 #55 2026-09-26 20:23:11 +09:00
みてるぞ c9b6f0ba3d Merge remote-tracking branch 'origin/main' into feature/055 2026-09-25 22:05:34 +09:00
みてるぞ 115a14205b #55 2026-09-22 03:04:21 +09:00
みてるぞ 1b1130ccd9 #55 いったん中断;外部タグ分離優先 2026-09-22 02:56:58 +09:00
みてるぞ f6d3dd1c10 Merge remote-tracking branch 'origin/main' into feature/055 2026-09-21 07:48:26 +09:00
みてるぞ b0df11c500 #55 2026-09-21 01:27:10 +09:00
みてるぞ 886128a7a8 #55 2026-09-20 21:01:23 +09:00
みてるぞ 6d1a8f0444 Merge remote-tracking branch 'origin/main' into feature/055 2026-09-20 03:56:03 +09:00
みてるぞ 6f0442d633 #55 マイグレーション・ファイルのみ 2026-09-20 00:11:19 +09:00
47個のファイルの変更、203行の追加、5318行の削除
-57
ファイルの表示
@@ -1,57 +0,0 @@
class TagDescriptionsController < ApplicationController
def show
description = TagDescription.find_by(tag_id: params[:id], language_code: 'ja')
return head :not_found unless description
render json: representation(description)
end
def show_by_title
tag_name = TagName.find_by(language_code: 'ja', name: params[:name].to_s.strip)
description = tag_name&.tag && TagDescription.find_by(tag: tag_name.tag, language_code: 'ja')
return head :not_found unless description
render json: representation(description)
end
def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
body = params[:body].to_s
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
tag = Tag.find_by(id: params[:id])
return head :not_found unless tag
description = TagDescription.find_by(tag:, language_code: 'ja')
if description
TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user)
description.reload
render json: representation(description)
else
TagDescriptionOperation.create!(tag:, body:, created_by_user: current_user)
description = TagDescription.find_by!(tag:, language_code: 'ja')
render json: representation(description), status: :created
end
rescue ActiveRecord::RecordInvalid => e
render_validation_error e.record
rescue ActiveRecord::RecordNotUnique
render_record_not_unique
end
private
def representation description
description.as_json(only: [
:tag_id,
:language_code,
:body,
:version_no,
:created_by_user_id,
:updated_by_user_id,
:created_at,
:updated_at])
end
end
-39
ファイルの表示
@@ -1,39 +0,0 @@
class TagGroupsController < ApplicationController
def index
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id)
render json: TagGroupRepr.many(groups.to_a)
end
def show
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id])
return head :not_found unless group
render json: TagGroupRepr.one(group)
end
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
group = TagGroupOperation.create!(**write_attributes, actor: current_user)
render json: TagGroupRepr.one(group), status: :created
end
def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
group = TagGroup.find_by(id: params[:id])
return head :not_found unless group
TagGroupOperation.update!(group:, **write_attributes, actor: current_user)
render json: TagGroupRepr.one(group.reload)
end
private
def write_attributes
{ name: params[:name], description: params[:description],
members: params[:members], placements: params[:placements] }
end
end
+20 -3
ファイルの表示
@@ -464,6 +464,7 @@ class TagsController < ApplicationController
old_name = tag.name(locale.language_code) old_name = tag.name(locale.language_code)
name_changed = name != old_name name_changed = name != old_name
wiki_page = tag.tag_name(locale.language_code)&.wiki_page if name_changed
if tag.deprecated? == deprecated if tag.deprecated? == deprecated
tag.update!(category:) tag.update!(category:)
@@ -484,7 +485,9 @@ class TagsController < ApplicationController
tag, tag,
event_type: :update, event_type: :update,
created_by_user: current_user, created_by_user: current_user,
language_code: locale.language_code) language_code: locale.language_code,
name_changed:,
wiki_page:)
end end
render json: TagRepr.base(tag.reload, language_code: locale.language_code) render json: TagRepr.base(tag.reload, language_code: locale.language_code)
@@ -515,6 +518,7 @@ class TagsController < ApplicationController
old_name = tag.name(locale.language_code) old_name = tag.name(locale.language_code)
name_changed = name.present? && name != old_name name_changed = name.present? && name != old_name
wiki_page = tag.tag_name(locale.language_code)&.wiki_page if name_changed
rename_tag_name!(locale, tag, name) if name_changed rename_tag_name!(locale, tag, name) if name_changed
tag.update!(category:) if category.present? tag.update!(category:) if category.present?
@@ -528,7 +532,9 @@ class TagsController < ApplicationController
tag, tag,
event_type: :update, event_type: :update,
created_by_user: current_user, created_by_user: current_user,
language_code: locale.language_code) language_code: locale.language_code,
name_changed:,
wiki_page:)
end end
render json: TagRepr.base(tag.reload, language_code: locale.language_code) render json: TagRepr.base(tag.reload, language_code: locale.language_code)
@@ -716,8 +722,19 @@ class TagsController < ApplicationController
language_code:)) language_code:))
end end
def record_tag_version! tag, event_type:, created_by_user:, language_code: 'ja' def record_tag_version! tag, event_type:, created_by_user:, language_code: 'ja',
name_changed: false, wiki_page: nil
TagVersionRecorder.record!(tag:, event_type:, created_by_user:, language_code:) TagVersionRecorder.record!(tag:, event_type:, created_by_user:, language_code:)
return unless name_changed
wiki_page ||= tag.tag_name('ja')&.wiki_page
return unless wiki_page&.wiki_versions&.exists?
WikiVersionRecorder.record!(
page: wiki_page,
event_type: :update,
created_by_user:)
end end
def validate_tag_rename locale, tag, name def validate_tag_rename locale, tag, name
+74 -358
ファイルの表示
@@ -3,82 +3,46 @@ class WikiPagesController < ApplicationController
def index def index
title = params[:title].to_s.strip title = params[:title].to_s.strip
descriptions = if title.blank?
TagDescription.where(language_code: 'ja') return render json: WikiPageRepr.base(
.includes(tag: :tag_names) WikiPage.joins(:tag_name).includes(tag_name: :tag))
pages = unmigrated_wiki_pages.includes(tag_name: :tag)
tag_groups = TagGroup.all
if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions = descriptions.joins(tag: :tag_names)
.where(tag_names: { language_code: 'ja', primary_flg: true })
.where('tag_names.name LIKE ?', pattern).limit(20)
tag_groups = tag_groups.where(
'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20)
pages = pages.where('tag_names.name LIKE ?', pattern).limit(20)
end end
json = descriptions.map { |description| WikiPageRepr.tag_description(description) } q = WikiPage.joins(:tag_name).includes(tag_name: :tag)
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) }) .where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%")
json.concat(WikiPageRepr.base(pages)) render json: WikiPageRepr.base(q.limit(20))
json = json.first(20) if title.present?
render json:
end end
def show def show
tag_description = TagDescription.find_by(id: params[:id])
return render_wiki_page_by_tag_description tag_description if tag_description
tag_group = TagGroup.find_by(id: params[:id])
return render_tag_group tag_group if tag_group
page = WikiPage.joins(:tag_name) page = WikiPage.joins(:tag_name)
.includes(tag_name: :tag) .includes(tag_name: :tag)
.find_by(id: params[:id]) .find_by(id: params[:id])
render_wiki_page_or_404 page render_wiki_page_or_404 page
end end
def show_by_title def show_by_title
title = params[:title].to_s.strip title = params[:title].to_s.strip
if title.start_with?(TagGroupRepr::PREFIX)
name = title.delete_prefix(TagGroupRepr::PREFIX)
tag_group = TagGroup.find_by(name:)
return render_tag_group tag_group if tag_group
return head :not_found
end
tag_description = tag_description_by_title(title)
return render_wiki_page_by_tag_description tag_description if tag_description
page = WikiPage.joins(:tag_name) page = WikiPage.joins(:tag_name)
.includes(tag_name: :tag) .includes(tag_name: :tag)
.find_by(tag_name: { name: title }) .find_by(tag_name: { name: title })
render_wiki_page_or_404 page render_wiki_page_or_404 page
end end
def exists def exists
return head :no_content if TagDescription.exists?(id: params[:id]) if WikiPage.exists?(id: params[:id])
return head :no_content if TagGroup.exists?(id: params[:id]) head :no_content
else
head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found head :not_found
end
end end
def exists_by_title def exists_by_title
title = params[:title].to_s.strip title = params[:title].to_s.strip
if title.start_with?(TagGroupRepr::PREFIX) if WikiPage.joins(:tag_name).exists?(tag_names: { name: title })
exists = TagGroup.exists?(name: title.delete_prefix(TagGroupRepr::PREFIX)) head :no_content
return head exists ? :no_content : :not_found else
head :not_found
end end
return head :no_content if tag_description_by_title(title)
exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title })
head exists ? :no_content : :not_found
end end
def diff def diff
@@ -88,49 +52,35 @@ class WikiPagesController < ApplicationController
from = params[:from].presence from = params[:from].presence
to = params[:to].presence to = params[:to].presence
description = TagDescription.find_by(id:) page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
if description
versions = description_versions(description)
from_rev = from && versions.find(from)
to_rev = to ? versions.find(to) : versions.find_by!(version_no: description.version_no)
resource = WikiPageRepr.tag_description(description)
else
tag_group = TagGroup.find_by(id:)
if tag_group
from_rev = from && group_version(tag_group, from)
to_rev = to ? group_version(tag_group, to) : current_group_version(tag_group)
return head :not_found if (from && !from_rev) || !to_rev
from_body = from_rev && render_group_version(from_rev) from_rev = from && page.wiki_revisions.find(from)
to_body = render_group_version(to_rev) to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
resource = { 'id' => tag_group.id, if ((from_rev && !(from_rev.content?)) || !(to_rev&.content?))
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }", return render_unprocessable_entity('差分を表示できない版です.')
'deprecated_at' => nil }
else
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
from_rev = from && page.wiki_revisions.find(from)
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
if ((from_rev && !from_rev.content?) || !to_rev&.content?)
return render_unprocessable_entity('差分を表示できない版です.')
end
from_body = from_rev&.body
to_body = to_rev.body
resource = WikiPageRepr.base(page)
end
end end
from_body ||= from_rev&.body diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines)
to_body ||= to_rev.body diff_json = diffs.map { |change|
diff_json = wiki_diff(from_body, to_body) case change.action
when ?=
{ type: 'context', content: change.old_element }
when ?!
[{ type: 'removed', content: change.old_element },
{ type: 'added', content: change.new_element }]
when ?+
{ type: 'added', content: change.new_element }
when ?-
{ type: 'removed', content: change.old_element }
end
}.flatten.compact
render json: { wiki_page_id: resource.fetch('id'), render json: { wiki_page_id: page.id,
title: resource.fetch('title'), title: page.title,
deprecated_at: resource.fetch('deprecated_at'), deprecated_at: page.deprecated_at,
older_revision_id: from_rev&.id, older_revision_id: from_rev&.id,
newer_revision_id: to_rev.id, newer_revision_id: to_rev.id,
diff: diff_json } diff: diff_json }
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
render_unprocessable_entity(error.message)
end end
def create def create
@@ -144,43 +94,24 @@ class WikiPagesController < ApplicationController
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
if title.start_with?(TagGroupRepr::PREFIX) tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name|
name = title.delete_prefix(TagGroupRepr::PREFIX) name.script_code = 'Jpan'
parsed = TagGroupMarkdownParser.parse(body:, group: nil) name.primary_flg = true
group = TagGroupOperation.create!(name:, **parsed, actor: current_user, name.auto_generated = false
require_compatibility: true)
version = current_group_version(group)
return render_group_representation(TagGroupRepr.one(group), version,
body: parsed.fetch(:compatibility_body),
status: :created)
end end
page = if title.include?(':') page =
ApplicationRecord.transaction do Wiki::Commit.create_content!(
tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name| tag_name:,
name.script_code = 'Jpan' body:,
name.primary_flg = true created_by_user: current_user,
name.auto_generated = false message:)
end
Wiki::Commit.create_legacy_content!(
tag_name:, body:, created_by_user: current_user, message:)
end
else
tag_name = TagName.find_by(language_code: 'ja', name: title)
return render_unprocessable_entity field: :title unless tag_name&.tag
TagDescriptionOperation.create!(
tag: tag_name.tag, body:, created_by_user: current_user, message:)
end
render json: WikiPageRepr.base(page), status: :created render json: WikiPageRepr.base(page), status: :created
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
render_validation_error e.record render_validation_error e.record
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
render_record_not_unique render_record_not_unique
rescue TagGroupMarkdownParser::InvalidDocument => error
render_unprocessable_entity(error.message)
end end
def update def update
@@ -193,26 +124,7 @@ class WikiPagesController < ApplicationController
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
group = TagGroup.find_by(id: params[:id]) page = WikiPage.find(params[:id])
if title.start_with?(TagGroupRepr::PREFIX)
return head :not_found unless group
name = title.delete_prefix(TagGroupRepr::PREFIX)
parsed = TagGroupMarkdownParser.parse(body:, group:)
TagGroupOperation.update!(group:, name:, **parsed, actor: current_user,
require_compatibility: true)
return head :ok
end
page = WikiPage.find_by(id: params[:id])
if legacy_group_page?(page)
return render_unprocessable_entity field: :title
end
if group && !legacy_update_identified?(page, title, group)
return render_unprocessable_entity field: :title
end
return head :not_found unless page
base_revision_id = params[:base_revision_id].presence base_revision_id = params[:base_revision_id].presence
ApplicationRecord.transaction do ApplicationRecord.transaction do
@@ -220,7 +132,7 @@ class WikiPagesController < ApplicationController
old_title = page.title old_title = page.title
tag = page.tag_name.tag tag = Tag.find_by(tag_name_id: page.tag_name_id)
if tag && title != old_title if tag && title != old_title
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
@@ -229,17 +141,11 @@ class WikiPagesController < ApplicationController
page.tag_name.update!(name: title) if title != old_title page.tag_name.update!(name: title) if title != old_title
message = params[:message].presence message = params[:message].presence
description = TagDescription.find_by(id: page.id) Wiki::Commit.content!(page:,
description_page = description && ( body:,
tag&.id == description.tag_id || created_user: current_user,
(tag.nil? && !old_title.include?(':'))) message:,
if description && !description_page base_revision_id:)
Wiki::Commit.legacy_content!(page:, body:, created_user: current_user,
message:, base_revision_id:)
else
Wiki::Commit.content!(page:, body:, created_user: current_user,
message:, base_revision_id:)
end
if tag && title != old_title if tag && title != old_title
tag.reload tag.reload
@@ -248,160 +154,35 @@ class WikiPagesController < ApplicationController
end end
head :ok head :ok
rescue ActiveRecord::RecordInvalid => e end
render_validation_error e.record
rescue ActiveRecord::RecordNotUnique def search
render_record_not_unique index
rescue TagGroupMarkdownParser::InvalidDocument => error
render_unprocessable_entity(error.message)
end end
def changes def changes
id = params[:id].presence id = params[:id].presence
description = id && TagDescription.find_by(id:) q = WikiRevision.joins(wiki_page: :tag_name)
if description .includes(:created_user, wiki_page: { tag_name: :tag })
return render json: description_changes(TagDescription.where(id: description.id)) .order(id: :desc)
end q = q.where(wiki_page_id: id) if id
tag_group = id && TagGroup.find_by(id:) render json: q.limit(200).map { |rev|
if tag_group { revision_id: rev.id,
return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id)) pred: rev.base_revision_id,
end succ: nil,
wiki_page: { id: rev.wiki_page_id,
pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages title: rev.wiki_page.title,
revisions = WikiRevision.joins(wiki_page: :tag_name) deprecated_at: rev.wiki_page.deprecated_at },
.where(wiki_page_id: pages.select(:id)) user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
.includes(:created_user, wiki_page: { tag_name: :tag }) kind: rev.kind,
.order(id: :desc).limit(200) message: rev.message,
json = revisions.map { |rev| timestamp: rev.created_at }
{ revision_id: rev.id, }.compact
pred: rev.base_revision_id,
succ: nil,
wiki_page: { id: rev.wiki_page_id,
title: rev.wiki_page.title,
deprecated_at: rev.wiki_page.deprecated_at },
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
kind: rev.kind,
message: rev.message,
timestamp: rev.created_at }
}
unless id
json.concat(tag_group_changes(TagGroupVersion.all))
json.concat(description_changes(TagDescription.where(language_code: 'ja')))
json = json.each_with_index.sort_by do |row, index|
[-row.fetch(:timestamp).to_f, index]
end.map(&:first)
end
render json: json.first(200)
end end
private private
def legacy_group_page? page
return false unless page
return true if page.title.start_with?(TagGroupRepr::PREFIX)
prefix = ApplicationRecord.sanitize_sql_like(TagGroupRepr::PREFIX)
page.wiki_versions.where('title LIKE ?', "#{ prefix }%").exists?
end
def legacy_update_identified? page, title, group
return false unless page
return true if page.title == title
return false if title == group.name
namespace = page.title.split(':', 2)
return true if namespace.size == 2 && title.start_with?("#{ namespace.first }:")
base = params[:base_revision_id].presence
base && page.wiki_revisions.exists?(id: base)
end
def render_tag_group group
version = if params[:version].present?
group_version(group, params[:version])
else
current_group_version(group)
end
return head :not_found unless version
representation = if params[:version].present?
TagGroupRepr.historical(version)
else
TagGroupRepr.one(group)
end
render_group_representation(representation, version)
end
def render_group_version version
representation = TagGroupRepr.historical(version)
TagGroupMarkdownRenderer.render(representation)
end
def render_group_representation representation, version, body: nil, status: :ok
body ||= TagGroupMarkdownRenderer.render(representation)
versions = TagGroupVersion.where(tag_group_id: version.tag_group_id)
pred = versions.find_by(version_no: version.version_no - 1)&.id
succ = versions.find_by(version_no: version.version_no + 1)&.id
updated_at = version.created_at
render json: {
'id' => version.tag_group_id,
'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }",
'deprecated_at' => nil,
'body' => body,
'revision_id' => version.id,
'pred' => pred,
'succ' => succ,
'updated_at' => updated_at }, status:
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
render_unprocessable_entity(error.message)
end
def current_group_version group
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
end
def group_version group, version_id
TagGroupVersion.find_by(tag_group_id: group.id, id: version_id)
end
def wiki_diff from_body, to_body
diffs = Diff::LCS.sdiff(from_body.to_s.lines, to_body.to_s.lines)
diffs.map { |change|
case change.action
when ?=
{ type: 'context', content: change.old_element }
when ?!
[{ type: 'removed', content: change.old_element },
{ type: 'added', content: change.new_element }]
when ?+
{ type: 'added', content: change.new_element }
when ?-
{ type: 'removed', content: change.old_element }
end
}.flatten.compact
end
def tag_group_changes versions
rows = versions.includes(:created_by_user).order(id: :desc).limit(200).to_a
groups = TagGroup.where(id: rows.map(&:tag_group_id).uniq).index_by(&:id)
predecessors = TagGroupVersion.where(tag_group_id: groups.keys,
version_no: rows.map { _1.version_no - 1 })
.pluck(:tag_group_id, :version_no, :id)
.to_h { |group_id, number, version_id| [[group_id, number], version_id] }
rows.map do |version|
group = groups.fetch(version.tag_group_id)
user = version.created_by_user
{ revision_id: version.id,
pred: predecessors[[group.id, version.version_no - 1]],
succ: nil,
wiki_page: { id: group.id, title: "#{ TagGroupRepr::PREFIX }#{ version.name }",
deprecated_at: nil },
user: user && { id: user.id, name: user.name },
kind: 'content', message: nil, timestamp: version.created_at }
end
end
def render_wiki_page_or_404 page def render_wiki_page_or_404 page
return head :not_found unless page return head :not_found unless page
@@ -423,71 +204,6 @@ class WikiPagesController < ApplicationController
render json: WikiPageRepr.base(page).merge(body:, revision_id:, pred:, succ:, updated_at:) render json: WikiPageRepr.base(page).merge(body:, revision_id:, pred:, succ:, updated_at:)
end end
def render_wiki_page_by_tag_description description
versions = description_versions(description)
historical = params[:version].present?
rev = historical ? versions.find_by(id: params[:version]) :
versions.find_by(version_no: description.version_no)
return head :not_found unless rev
pred = versions.find_by(version_no: rev.version_no - 1)&.id
succ = versions.find_by(version_no: rev.version_no + 1)&.id
body = historical ? rev.body : description.body
updated_at = historical ? rev.created_at : description.updated_at
render json: WikiPageRepr.tag_description(description).merge(
body:, revision_id: rev.id, pred:, succ:, updated_at:)
end
def description_versions description
TagDescriptionVersion.where(tag_id: description.tag_id,
language_code: description.language_code)
end
def tag_description_by_title title
TagDescription.joins(tag: :tag_names)
.find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title })
end
def unmigrated_wiki_pages
descriptions = TagDescription.where(language_code: 'ja')
WikiPage
.joins(:tag_name)
.where.not(id: descriptions.select(:id))
.where.not('tag_names.name LIKE ?', "#{ TagGroupRepr::PREFIX }%")
.where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)',
descriptions.select(:tag_id))
end
def description_changes descriptions
versions = TagDescriptionVersion.where(language_code: 'ja',
tag_id: descriptions.select(:tag_id))
.order(id: :desc).limit(200).to_a
return [] if versions.empty?
by_tag = descriptions.where(tag_id: versions.map(&:tag_id))
.includes(tag: :tag_names).index_by(&:tag_id)
users = User.where(id: versions.map(&:created_by_user_id)).index_by(&:id)
predecessors = TagDescriptionVersion.where(
language_code: 'ja', tag_id: by_tag.keys,
version_no: versions.map { |version| version.version_no - 1 })
.pluck(:tag_id, :version_no, :id)
.to_h { |tag_id, version_no, id| [[tag_id, version_no], id] }
versions.map do |version|
description = by_tag.fetch(version.tag_id)
user = users[version.created_by_user_id]
{ revision_id: version.id,
pred: predecessors[[version.tag_id, version.version_no - 1]],
succ: nil,
wiki_page: { id: description.id, title: description.tag.name('ja'),
deprecated_at: description.tag.deprecated_at },
user: user && { id: user.id, name: user.name },
kind: 'content',
message: nil,
timestamp: version.created_at }
end
end
def find_revision page def find_revision page
if params[:version].present? if params[:version].present?
page.wiki_revisions.find_by(id: params[:version]) page.wiki_revisions.find_by(id: params[:version])
-3
ファイルの表示
@@ -1,3 +0,0 @@
class TagDescription < ApplicationRecord
belongs_to :tag
end
-3
ファイルの表示
@@ -1,3 +0,0 @@
class TagDescriptionVersion < ApplicationRecord
;
end
-16
ファイルの表示
@@ -1,16 +0,0 @@
class TagGroup < ApplicationRecord
belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true
has_many :tag_group_tags, dependent: :delete_all
has_many :tags, through: :tag_group_tags
has_many :tag_group_groups, dependent: :delete_all
has_many :child_tag_groups, through: :tag_group_groups
has_many :placements, -> { order(:position) },
class_name: 'TagGroupPlacement', dependent: :delete_all
has_many :versions, -> { order(:version_no) },
class_name: 'TagGroupVersion', dependent: :delete_all
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
validates :version_no, numericality: { only_integer: true, greater_than: 0 }
end
-7
ファイルの表示
@@ -1,7 +0,0 @@
class TagGroupGroup < ApplicationRecord
self.primary_key = [:tag_group_id, :child_tag_group_id]
belongs_to :tag_group
belongs_to :child_tag_group, class_name: 'TagGroup'
belongs_to :created_by_user, class_name: 'User', optional: true
end
-9
ファイルの表示
@@ -1,9 +0,0 @@
class TagGroupPlacement < ApplicationRecord
self.primary_key = [:tag_group_id, :position]
belongs_to :tag_group
belongs_to :tag, optional: true
belongs_to :child_tag_group, class_name: 'TagGroup', optional: true
belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true
end
-7
ファイルの表示
@@ -1,7 +0,0 @@
class TagGroupTag < ApplicationRecord
self.primary_key = [:tag_group_id, :tag_id]
belongs_to :tag_group
belongs_to :tag
belongs_to :created_by_user, class_name: 'User', optional: true
end
-7
ファイルの表示
@@ -1,7 +0,0 @@
class TagGroupVersion < ApplicationRecord
include VersionRecord
belongs_to :tag_group
scope :ordered, -> { order(:version_no) }
end
+4 -7
ファイルの表示
@@ -48,11 +48,8 @@ class WikiPage < ApplicationRecord
page page
end end
def pred_revision_id revision_id def pred_revision_id(revision_id) =
wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id) wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id)
end def succ_revision_id(revision_id) =
wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id)
def succ_revision_id revision_id
wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id)
end
end end
-194
ファイルの表示
@@ -1,194 +0,0 @@
module TagGroupRepr
PREFIX = 'タググループ:'
module_function
def many groups
ids = groups.map(&:id)
return [] if ids.empty?
tag_members = TagGroupTag.where(tag_group_id: ids)
.group_by(&:tag_group_id)
group_members = TagGroupGroup.where(tag_group_id: ids)
.group_by(&:tag_group_id)
placements = TagGroupPlacement.where(tag_group_id: ids).order(:position)
.group_by(&:tag_group_id)
target_ids = tag_members.values.flatten.map(&:tag_id) +
group_members.values.flatten.map(&:child_tag_group_id) +
placements.values.flatten.filter_map(&:tag_id)
child_ids = group_members.values.flatten.map(&:child_tag_group_id) +
placements.values.flatten.filter_map(&:child_tag_group_id)
tags = Tag.select(:id, :category).where(id: target_ids.uniq).index_by(&:id)
names = TagName.where(tag_id: target_ids.uniq, language_code: 'ja', primary_flg: true)
.pluck(:tag_id, :name).to_h
child_groups = TagGroup.where(id: child_ids.uniq).index_by(&:id)
groups.map do |group|
group_json(group,
tag_members.fetch(group.id, []),
group_members.fetch(group.id, []),
placements.fetch(group.id, []), tags, names, child_groups)
end
end
def one group
many([group]).first
end
def tag_group wiki_page
{ 'id' => wiki_page.id,
'title' => "#{ PREFIX }#{ wiki_page.name }",
'deprecated_at' => nil,
'created_at' => wiki_page.created_at,
'updated_at' => wiki_page.updated_at }
end
def current_version group
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
end
def historical version
members = Array(version.members)
placements = Array(version.placements)
tag_references = members.filter_map { |member| member['tag_id'] } +
collect_ids(placements, 'tag_id')
group_references = members.filter_map { |member| member['tag_group_id'] } +
collect_ids(placements, 'tag_group_id')
tag_versions = historical_tag_versions(tag_references)
group_versions = historical_group_versions(group_references)
tag_version_numbers = members.filter_map do |member|
next unless member['tag_id']
[member.fetch('tag_id'), member.fetch('version_no')]
end.to_h
group_version_numbers = members.filter_map do |member|
next unless member['tag_group_id']
[member.fetch('tag_group_id'), member.fetch('version_no')]
end.to_h
historical_members = members.map do |member|
historical_member(member, tag_versions, group_versions)
end
historical_placements = placements.map do |node|
historical_placement(node, tag_versions, group_versions,
tag_version_numbers, group_version_numbers)
end
{ 'id' => version.tag_group_id,
'name' => version.name,
'description' => version.description,
'version_no' => version.version_no,
'members' => historical_members,
'placements' => historical_placements,
'created_at' => version.created_at,
'updated_at' => version.created_at,
'created_by_user' => user_json(version.created_by_user),
'updated_by_user' => user_json(version.created_by_user) }
end
def group_json group, tag_members, group_members, rows, tags, names, child_groups
tree = placement_tree(rows, tags, names, child_groups)
members = tag_members.sort_by(&:tag_id).map do |member|
{ 'tag' => tag_json(tags.fetch(member.tag_id), names.fetch(member.tag_id)) }
end
members.concat(group_members.sort_by(&:child_tag_group_id).map do |member|
{ 'tag_group' => group_json_reference(child_groups.fetch(member.child_tag_group_id)) }
end)
{ 'id' => group.id,
'name' => group.name,
'description' => group.description,
'version_no' => group.version_no,
'members' => members,
'placements' => tree,
'created_at' => group.created_at,
'updated_at' => group.updated_at,
'created_by_user' => user_json(group.created_by_user),
'updated_by_user' => user_json(group.updated_by_user) }
end
def user_json user
UserRepr.base(user) if user
end
def placement_tree rows, tags, names, child_groups
by_parent = rows.group_by(&:parent_position)
build = lambda do |parent|
by_parent.fetch(parent, []).sort_by(&:position).map do |row|
node = if row.tag_id
{ 'tag' => tag_json(tags.fetch(row.tag_id), names.fetch(row.tag_id)) }
elsif row.child_tag_group_id
{ 'tag_group' => group_json_reference(child_groups.fetch(row.child_tag_group_id)) }
else
{ 'section_name' => row.section_name, 'description' => row.description }
end
node.merge('children' => build.call(row.position))
end
end
build.call(nil)
end
def tag_json tag, name
{ 'id' => tag.id, 'name' => name, 'category' => tag.category }
end
def group_json_reference group
{ 'id' => group.id, 'name' => group.name }
end
def collect_ids nodes, key
nodes.flat_map do |node|
[node[key], *collect_ids(Array(node['children']), key)].compact
end
end
def historical_tag_versions ids
TagVersion.where(tag_id: ids.uniq).to_a.index_by do |version|
[version.tag_id, version.version_no]
end
end
def historical_group_versions ids
TagGroupVersion.where(tag_group_id: ids.uniq).to_a.index_by do |version|
[version.tag_group_id, version.version_no]
end
end
def historical_member member, tag_versions, group_versions
if member['tag_id']
id = member.fetch('tag_id')
version = tag_versions.fetch([id, member.fetch('version_no')])
{ 'tag' => { 'id' => id, 'name' => japanese_version_name(version),
'category' => version.category } }
else
id = member.fetch('tag_group_id')
version = group_versions.fetch([id, member.fetch('version_no')])
{ 'tag_group' => { 'id' => id, 'name' => version.name } }
end
end
def historical_placement(node, tag_versions, group_versions,
tag_version_numbers, group_version_numbers)
result = if node['tag_id']
id = node.fetch('tag_id')
version_no = tag_version_numbers.fetch(id)
version = tag_versions.fetch([id, version_no])
{ 'tag' => { 'id' => id, 'name' => japanese_version_name(version),
'category' => version.category } }
elsif node['tag_group_id']
id = node.fetch('tag_group_id')
version_no = group_version_numbers.fetch(id)
version = group_versions.fetch([id, version_no])
{ 'tag_group' => { 'id' => id, 'name' => version.name } }
else
{ 'section_name' => node.fetch('section_name'), 'description' => node['description'] }
end
result.merge('children' => Array(node['children']).map do |child|
historical_placement(child, tag_versions, group_versions,
tag_version_numbers, group_version_numbers)
end)
end
def japanese_version_name version
version.names.fetch('ja').fetch('name')
end
end
-19
ファイルの表示
@@ -10,25 +10,6 @@ module WikiPageRepr
wiki_page.as_json(BASE) wiki_page.as_json(BASE)
end end
def tag_description description
tag = description.tag
{ 'id' => description.id,
'tag_name_id' => tag.tag_name('ja')&.id,
'body' => description.body,
'created_user_id' => description.created_by_user_id,
'updated_user_id' => description.updated_by_user_id,
'created_at' => description.created_at,
'updated_at' => description.updated_at,
'discarded_at' => nil,
'version_no' => description.version_no,
'title' => tag.name('ja'),
'deprecated_at' => tag.deprecated_at }
end
def tag_group tag_group
TagGroupRepr.tag_group(tag_group)
end
def many wiki_pages def many wiki_pages
wiki_pages.map { |p| base(p) } wiki_pages.map { |p| base(p) }
end end
-54
ファイルの表示
@@ -1,54 +0,0 @@
class TagDescriptionOperation
LANGUAGE_CODE = 'ja'
def self.create!(tag:, body:, created_by_user:, message: nil)
ApplicationRecord.transaction do
tag.lock!
raise ActiveRecord::RecordNotUnique if TagDescription.exists?(
tag_id: tag.id, language_code: LANGUAGE_CODE)
page = Wiki::CompatibilityWriter.create!(
tag_name: tag.tag_name(LANGUAGE_CODE), body:, created_by_user:, message:)
revision = page.wiki_revisions.order(id: :desc).first
record_version!(tag:, revision:, body:, user: created_by_user,
description: nil)
page
end
end
def self.update!(tag:, body:, updated_by_user:, message: nil,
base_revision_id: nil)
ApplicationRecord.transaction do
tag.lock!
description = TagDescription.lock.find_by!(
tag_id: tag.id, language_code: LANGUAGE_CODE)
page = WikiPage.lock.find(description.id)
revision = Wiki::CompatibilityWriter.update!(
page:, body:, updated_by_user:, message:,
base_revision_id:)
next unless revision
record_version!(tag:, revision:, body: revision.body,
user: updated_by_user, description:)
revision
end
end
def self.record_version! tag:, revision:, body:, user:, description:
version_no = description ? description.version_no + 1 : 1
TagDescriptionVersion.create!(
id: revision.id, tag_id: tag.id, language_code: LANGUAGE_CODE,
version_no:, event_type: description ? 'update' : 'create', body:,
created_by_user_id: user.id, created_at: revision.created_at)
if description
description.update!(body:, version_no:, updated_by_user_id: user.id)
else
TagDescription.create!(
id: revision.wiki_page_id, tag_id: tag.id,
language_code: LANGUAGE_CODE, version_no:, body:,
created_by_user_id: user.id, updated_by_user_id: user.id)
end
end
private_class_method :record_version!
end
-177
ファイルの表示
@@ -1,177 +0,0 @@
require 'kramdown'
class TagGroupMarkdownParser
PREFIX = TagGroupRepr::PREFIX
class InvalidDocument < StandardError
;
end
def self.parse(body:, group: nil)
new(body:, group:).parse
end
def initialize(body:, group:)
@body = body.to_s.gsub(/\r\n?/, "\n")
@group = group
end
def parse
parse_body = @body.gsub(/([^\n])\n([\x23]{1,6}[ \t]+\S)/, "\\1\n\n\\2")
root = Kramdown::Document.new(parse_body, input: 'GFM', auto_ids: false).root
placements = []
description_parts = []
headings = []
root.children.each do |node|
case node.type
when :blank
next
when :header
level = node.options.fetch(:level)
name = inline_text(node).strip
invalid!('Section 名は必須です.') if name.blank?
headings.pop while headings.any? && headings.last.fetch(:level) >= level
parent = headings.last&.fetch(:node)
section = { 'section_name' => name, 'description' => nil,
'children' => [] }
(parent ? parent.fetch('children') : placements) << section
headings << { level:, node: section }
when :p
text = inline_markdown(node).strip
next if text.empty?
if headings.any?
section = headings.last.fetch(:node)
section['description'] = [section['description'], text].compact.join("\n\n")
else
description_parts << text
end
when :ul
invalid!('未対応のリスト項目があります.') if node.attr.any?
target = headings.any? ? headings.last.fetch(:node).fetch('children') : placements
target.concat(node.children.map { parse_list_item(_1) })
else
invalid!("未対応の Markdown 構文 #{ node.type.inspect } です.")
end
end
description = description_parts.presence&.join("\n\n")
{ description:, placements:, members: build_members(placements),
compatibility_body: @body.sub(/\n+\z/, '') }
rescue Kramdown::Error => error
invalid!("Markdown を解析できません:#{ error.message }")
end
private
def parse_list_item(item)
invalid!('リスト項目ではありません.') unless item.type == :li
invalid!('未対応のリスト項目があります.') if item.attr.any?
nested, own = item.children.partition { |child| child.type == :ul }
invalid!('順序付きリストは使用できません.') if own.any? { _1.type == :ol }
own.reject! { _1.type == :blank }
invalid!('未対応のリスト項目があります.') unless own.all? do |child|
[:p, :text].include?(child.type)
end
invalid!('リスト項目は一つの段落で指定してください.') if own.size != 1
label = own.map { inline_text(_1) }.join(' ').strip
invalid!('空のリスト項目は使用できません.') if label.blank?
node = if label.start_with?(PREFIX)
group_name = label.delete_prefix(PREFIX).strip
child_group = TagGroup.find_by(name: group_name)
invalid!("Tag Group #{ group_name.inspect } を解決できません.") unless child_group
invalid!('自分自身を含めることはできません.') if child_group.id == @group&.id
{ 'tag_group_id' => child_group.id }
elsif label.end_with?(':', ':')
section_name = label.sub(/[::]\z/, '').strip
invalid!('Section 名は必須です.') if section_name.blank?
{ 'section_name' => section_name, 'description' => nil }
else
{ 'tag_id' => resolve_tag(label) }
end
node['children'] = nested.flat_map do |list|
invalid!('未対応の入れ子リストです.') unless list.type == :ul
list.children.map { parse_list_item(_1) }
end
node
end
def resolve_tag(name)
names = TagName.where(language_code: 'ja', name:).to_a
tag_ids = names.filter_map(&:tag_id).uniq
invalid!("Tag #{ name.inspect } を解決できません.") if tag_ids.empty?
invalid!("Tag 名 #{ name.inspect } が曖昧です.") if tag_ids.many?
tag_ids.first
end
def build_members(nodes)
references = []
visit = lambda do |items|
items.each do |node|
references << node.slice('tag_id', 'tag_group_id') if
node.key?('tag_id') || node.key?('tag_group_id')
visit.call(node.fetch('children'))
end
end
visit.call(nodes)
references.uniq
end
def inline_text(node)
if node.attr.any?
invalid!("未対応のインライン構文 #{ node.type.inspect } です.")
end
case node.type
when :text
node.value.to_s
when :p, :header
node.children.map { inline_text(_1) }.join
else
invalid!("未対応のインライン構文 #{ node.type.inspect } です.")
end
end
def inline_markdown(node)
case node.type
when :text
node.value.to_s
when :codespan
text = node.value.to_s
longest = text.scan(/`+/).map(&:length).max || 0
delimiter = '`' * (longest + 1)
padded = text.start_with?('`', ' ') || text.end_with?('`', ' ')
content = padded ? " #{ text } " : text
"#{ delimiter }#{ content }#{ delimiter }"
when :softbreak
"\n"
when :br
" \n"
when :p
node.children.map { inline_markdown(_1) }.join
when :a
href = node.attr.fetch('href')
label = node.children.map { inline_markdown(_1) }.join
title = node.attr['title']&.gsub('"', '\\"')
suffix = title ? " \"#{ title }\"" : ''
"[#{ label }](#{ href }#{ suffix })"
when :em
"*#{ node.children.map { inline_markdown(_1) }.join }*"
when :strong
"**#{ node.children.map { inline_markdown(_1) }.join }**"
else
invalid!("未対応の説明文構文 #{ node.type.inspect } です.")
end
end
def invalid! message
raise InvalidDocument, message
end
end
-114
ファイルの表示
@@ -1,114 +0,0 @@
class TagGroupMarkdownRenderer
class UnsupportedStructure < StandardError
;
end
def self.render group
new(group).render
end
def initialize group
@group = group
end
def render
placements = @group.fetch('placements')
validate_membership!(placements)
blocks = []
blocks << clean_text(@group['description']) if @group['description'].present?
sections = placements.select { _1.key?('section_name') }
if sections.any? && sections.size != placements.size
raise UnsupportedStructure,
'最上位の見出し Section と Placement の混在は Markdown にできません.'
end
if placements.all? { _1.key?('section_name') }
blocks.concat(placements.map { render_heading(_1, 1) })
else
blocks << render_list(placements, 0)
end
blocks.compact.reject(&:empty?).join("\n").sub(/\n+\z/, '')
end
private
def validate_membership! placements
placed = placement_members(placements).uniq.sort
members = @group.fetch('members').map do |member|
if member['tag']
['tag', member.fetch('tag').fetch('id')]
else
['tag_group', member.fetch('tag_group').fetch('id')]
end
end.uniq.sort
missing = members - placed
raise UnsupportedStructure, '掲載位置のない membership は Markdown にできません.' if missing.any?
end
def placement_members nodes
nodes.flat_map do |node|
own = if node['tag']
[['tag', node.fetch('tag').fetch('id')]]
elsif node['tag_group']
[['tag_group', node.fetch('tag_group').fetch('id')]]
else
[]
end
own + placement_members(node.fetch('children'))
end
end
def render_heading section, level
header = "#{ '#' * level } #{ clean_text(section.fetch('section_name')) }"
blocks = [header]
blocks << clean_text(section['description']) if section['description'].present?
children = section.fetch('children')
section_indexes = children.each_index.select { |index| children[index].key?('section_name') }
if section_indexes.any?
first_section = section_indexes.first
if children[first_section..].any? { !_1.key?('section_name') }
raise UnsupportedStructure,
'見出し Section と兄弟 Placement の交互配置は Markdown にできません.'
end
list_nodes = children[0...first_section]
blocks << render_list(list_nodes, 0) if list_nodes.any?
blocks.concat(children[first_section..].map { render_heading(_1, level + 1) })
elsif children.any?
blocks << render_list(children, 0)
end
blocks.join("\n")
end
def render_list nodes, depth
nodes.map do |node|
indent = ' ' * depth
if node.key?('section_name')
raise UnsupportedStructure,
'リスト内 Section の説明文は Markdown にできません.' if node['description'].present?
children = node.fetch('children')
head = "#{ indent }- #{ clean_text(node.fetch('section_name')) }:"
tail = render_list(children, depth + 1) if children.any?
[head, tail].compact.join("\n")
else
label = if node['tag']
node.fetch('tag').fetch('name')
else
"#{ TagGroupRepr::PREFIX }#{ node.fetch('tag_group').fetch('name') }"
end
raise UnsupportedStructure, '名称のない Placement は Markdown にできません.' if label.blank?
head = "#{ indent }- #{ clean_text(label) }"
children = node.fetch('children')
tail = render_list(children, depth + 1) if children.any?
[head, tail].compact.join("\n")
end
end.join("\n")
end
def clean_text value
value.to_s.gsub(/\r\n?/, "\n").lines.map(&:rstrip).join("\n").strip
end
end
-365
ファイルの表示
@@ -1,365 +0,0 @@
class TagGroupOperation
MAX_PLACEMENT_DEPTH = 5
PREFIX = TagGroupRepr::PREFIX
def self.create!(name:, description:, members:, placements:, actor:,
require_compatibility: false, compatibility_body: nil)
group, sync_issue = ApplicationRecord.transaction do
group = TagGroup.new(version_no: 1, created_by_user: actor,
updated_by_user: actor)
attributes = prepare!(group:, name:, description:, members:, placements:)
group.assign_attributes(name: attributes.fetch(:name),
description: attributes.fetch(:description))
group.save!
persist_structure!(group:, attributes:, actor:)
persist_version!(group:, attributes:, actor:, event_type: 'create')
issue = sync_compatibility!(group:, actor:, old_name: nil,
require_compatibility:, compatibility_body:)
[group, issue]
end
report_sync_issue(group, actor, sync_issue)
group
end
def self.update!(group:, name:, description:, members:, placements:, actor:,
require_compatibility: false, compatibility_body: nil)
group, sync_issue = ApplicationRecord.transaction do
group.lock!
old_name = group.name
attributes = prepare!(group:, name:, description:, members:, placements:)
group.update!(name: attributes.fetch(:name),
description: attributes.fetch(:description),
version_no: group.version_no + 1,
updated_by_user: actor)
persist_structure!(group:, attributes:, actor:)
persist_version!(group:, attributes:, actor:, event_type: 'update')
issue = sync_compatibility!(group:, actor:, old_name:,
require_compatibility:, compatibility_body:)
[group, issue]
end
report_sync_issue(group, actor, sync_issue)
group
end
def self.prepare!(group:, name:, description:, members:, placements:)
record = group
normalized_name = name.to_s.strip
invalid!(record, :name, 'グループ名は必須です.') if normalized_name.blank?
if normalized_name.length > 255
invalid!(record, :name, 'グループ名は 255 文字以内で入力してください.')
end
invalid!(record, :description, '説明文は文字列で入力してください.') unless
description.nil? || description.is_a?(String)
record.name = normalized_name
record.description = description
unless record.valid?
raise ActiveRecord::RecordInvalid, record
end
member_rows = normalize_members(record, members)
placement_tree = normalize_placements(record, placements)
validate_references!(record, member_rows, placement_tree)
validate_containment!(record, member_rows)
{ name: normalized_name, description:, members: member_rows,
placements: placement_tree }
end
private_class_method :prepare!
def self.normalize_members(record, members)
invalid!(record, :members, 'members は配列で指定してください.') unless members.is_a?(Array)
result = members.map do |member|
member = stringify_keys(member)
unless member.is_a?(Hash) && member.keys.one? &&
(member.key?('tag_id') || member.key?('tag_group_id'))
invalid!(record, :members, '各 member は tag_id または tag_group_id を一つ指定してください.')
end
key = member.keys.first
id = integer_id(record, member.fetch(key), :members)
{ key => id }
end
invalid!(record, :members, 'membership に重複があります.') unless result.uniq == result
result
end
private_class_method :normalize_members
def self.normalize_placements(record, placements)
invalid!(record, :placements, 'placements は配列で指定してください.') unless
placements.is_a?(Array)
normalize_placement_nodes(record, placements, 1)
end
private_class_method :normalize_placements
def self.normalize_placement_nodes(record, nodes, depth)
if depth > MAX_PLACEMENT_DEPTH && nodes.any?
invalid!(record, :placements, '配置の深さは 5 階層までです.')
end
nodes.map do |raw_node|
node = stringify_keys(raw_node)
unless node.is_a?(Hash) && node.key?('children')
invalid!(record, :placements, '各 Placement に children 配列が必要です.')
end
children = node.delete('children')
unless children.is_a?(Array)
invalid!(record, :placements, 'children は配列で指定してください.')
end
kinds = ['tag_id', 'tag_group_id', 'section_name'].select { node.key?(_1) }
invalid!(record, :placements, 'Placement の種類を一つ指定してください.') unless
kinds.one?
kind = kinds.first
allowed = [kind, 'description']
invalid!(record, :placements, '不明な Placement 項目があります.') unless
(node.keys - allowed).empty?
case kind
when 'tag_id', 'tag_group_id'
node[kind] = integer_id(record, node.fetch(kind), :placements)
if node['description'].present?
invalid!(record, :placements, 'Tag / Tag Group Placement に説明文は指定できません.')
end
node.delete('description')
when 'section_name'
section_name = node.fetch(kind)
unless section_name.is_a?(String) && section_name.strip.present? &&
section_name.length <= 255
invalid!(record, :placements, 'Section 名は 1〜255 文字で入力してください.')
end
node[kind] = section_name.strip
unless node['description'].nil? || node['description'].is_a?(String)
invalid!(record, :placements, 'Section の説明文は文字列で入力してください.')
end
end
node['children'] = normalize_placement_nodes(record, children, depth + 1)
node
end
end
private_class_method :normalize_placement_nodes
def self.validate_references!(record, members, placements)
membership = members.to_set
placed = placement_references(placements).uniq
missing = placed - membership.to_a
invalid!(record, :placements, 'Placement の対象は membership に含めてください.') if
missing.any?
tag_ids = members.filter_map { _1['tag_id'] }
group_ids = members.filter_map { _1['tag_group_id'] }
tags = Tag.where(id: tag_ids).index_by(&:id)
groups = TagGroup.where(id: group_ids).index_by(&:id)
invalid!(record, :members, '存在しない Tag が含まれています.') if
tags.size != tag_ids.uniq.size
invalid!(record, :members, '存在しない Tag Group が含まれています.') if
groups.size != group_ids.uniq.size
tag_versions = TagVersion.where(tag_id: tag_ids).pluck(:tag_id, :version_no).to_set
group_versions = TagGroupVersion.where(tag_group_id: group_ids)
.pluck(:tag_group_id, :version_no).to_set
tag_names = TagName.where(tag_id: tag_ids, language_code: 'ja', primary_flg: true)
.pluck(:tag_id).uniq
members.each do |member|
if member.key?('tag_id')
id = member.fetch('tag_id')
invalid!(record, :members, "Tag ##{ id } に日本語の正規名がありません.") unless
tag_names.include?(id)
version_no = tags.fetch(id).version_no
unless tag_versions.include?([id, version_no])
invalid!(record, :members, "Tag ##{ id } の Version がありません.")
end
member['version_no'] = version_no
else
id = member.fetch('tag_group_id')
version_no = groups.fetch(id).version_no
unless group_versions.include?([id, version_no])
invalid!(record, :members, "Tag Group ##{ id } の Version がありません.")
end
member['version_no'] = version_no
end
end
end
private_class_method :validate_references!
def self.validate_containment!(record, members)
target_id = record.id
return unless target_id
children = members.filter_map { _1['tag_group_id'] }
edges = TagGroupGroup.pluck(:tag_group_id, :child_tag_group_id)
.group_by(&:first).transform_values { _1.map(&:last) }
edges[target_id] = children
visiting = Set.new
visited = Set.new
visit = lambda do |group_id|
invalid!(record, :members, 'Tag Group の循環包含はできません.') if
group_id == target_id && visiting.include?(group_id)
return if visited.include?(group_id)
visiting.add(group_id)
edges.fetch(group_id, []).each do |child_id|
invalid!(record, :members, 'Tag Group の循環包含はできません.') if
child_id == target_id
visit.call(child_id)
end
visiting.delete(group_id)
visited.add(group_id)
end
children.each { visit.call(_1) }
end
private_class_method :validate_containment!
def self.persist_structure!(group:, attributes:, actor:)
tag_members = TagGroupTag.where(tag_group_id: group.id).index_by(&:tag_id)
group_members = TagGroupGroup.where(tag_group_id: group.id).index_by(&:child_tag_group_id)
attributes.fetch(:members).each do |member|
if member.key?('tag_id')
next if tag_members.delete(member.fetch('tag_id'))
TagGroupTag.create!(tag_group_id: group.id, tag_id: member.fetch('tag_id'),
created_by_user: actor)
else
next if group_members.delete(member.fetch('tag_group_id'))
TagGroupGroup.create!(tag_group_id: group.id,
child_tag_group_id: member.fetch('tag_group_id'),
created_by_user: actor)
end
end
existing = TagGroupPlacement.where(tag_group_id: group.id).index_by(&:position)
persist_placements!(group:, nodes: attributes.fetch(:placements), actor:, existing:)
existing.values.sort_by(&:position).reverse_each(&:delete)
tag_members.each_value(&:delete)
group_members.each_value(&:delete)
end
private_class_method :persist_structure!
def self.persist_placements!(group:, nodes:, actor:, existing:, parent_position: nil,
next_position: [0])
nodes.each do |node|
next_position[0] += 1
position = next_position[0]
row = existing.delete(position) || TagGroupPlacement.new(
tag_group_id: group.id, position:, created_by_user: actor)
row.assign_attributes(
parent_position:,
section_name: node['section_name'], description: node['description'],
tag_id: node['tag_id'], child_tag_group_id: node['tag_group_id'])
if row.new_record? || row.changed?
row.updated_by_user = actor
row.save!
end
persist_placements!(group:, nodes: node.fetch('children'), actor:, existing:,
parent_position: position, next_position:)
end
end
private_class_method :persist_placements!
def self.persist_version!(group:, attributes:, actor:, event_type:)
TagGroupVersion.create!(tag_group: group, version_no: group.version_no,
event_type:, name: group.name,
description: group.description,
members: attributes.fetch(:members),
placements: attributes.fetch(:placements),
created_by_user: actor)
end
private_class_method :persist_version!
def self.sync_compatibility!(group:, actor:, old_name:,
require_compatibility:, compatibility_body:)
representation = TagGroupRepr.one(group)
body = begin
TagGroupMarkdownRenderer.render(representation)
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
if compatibility_body.present?
compatibility_body
elsif require_compatibility
invalid!(group, :base, error.message)
else
return error.message
end
end
invalid!(group, :base, '本文は必須です.') if body.blank?
title = "#{ PREFIX }#{ group.name }"
old_title = old_name && "#{ PREFIX }#{ old_name }"
page = if old_title
WikiPage.joins(:tag_name).find_by(tag_names: { name: old_title })
else
nil
end
page ||= WikiPage.joins(:tag_name).find_by(tag_names: { name: title })
if page
tag_name = page.tag_name
tag_name.update!(name: title) if tag_name.name != title
Wiki::Commit.legacy_content!(page:, body:, created_user: actor, message: nil,
base_revision_id: nil)
else
tag_name = TagName.find_or_initialize_by(language_code: 'ja', name: title)
tag_name.script_code = 'Jpan'
tag_name.primary_flg = true
tag_name.auto_generated = false
tag_name.save!
Wiki::Commit.create_legacy_content!(tag_name:, body:, created_by_user: actor)
end
nil
end
private_class_method :sync_compatibility!
def self.report_sync_issue(group, actor, reason)
return unless reason
payload = { status: 'unsupported_structure', tag_group_id: group.id,
version_no: group.version_no, user_id: actor&.id, reason: }
Rails.logger.warn("tag_group_wiki_sync_skipped #{ payload.to_json }")
end
private_class_method :report_sync_issue
def self.placement_references(nodes)
nodes.flat_map do |node|
own = if node.key?('tag_id')
[{ 'tag_id' => node.fetch('tag_id') }]
elsif node.key?('tag_group_id')
[{ 'tag_group_id' => node.fetch('tag_group_id') }]
else
[]
end
own + placement_references(node.fetch('children'))
end
end
private_class_method :placement_references
def self.stringify_keys(value)
case value
when ActionController::Parameters
value.to_unsafe_h.deep_stringify_keys
when Hash
value.deep_stringify_keys
else
value
end
end
private_class_method :stringify_keys
def self.integer_id(record, value, attribute)
integer = if value.is_a?(Integer)
value
elsif value.is_a?(String) && value.match?(/\A[0-9]+\z/)
value.to_i
end
invalid!(record, attribute, 'ID は正の整数で指定してください.') unless integer
invalid!(record, attribute, 'ID は正の整数で指定してください.') unless integer.positive?
integer
end
private_class_method :integer_id
def self.invalid!(record, attribute, message)
record.errors.add(attribute, message)
raise ActiveRecord::RecordInvalid, record
end
private_class_method :invalid!
end
+4 -23
ファイルの表示
@@ -8,15 +8,6 @@ module Wiki
end end
def self.create_content! tag_name:, body:, created_by_user:, message: nil def self.create_content! tag_name:, body:, created_by_user:, message: nil
if tag_name.tag && tag_name.language_code == 'ja'
return TagDescriptionOperation.create!(
tag: tag_name.tag, body:, created_by_user:, message:)
end
create_legacy_content!(tag_name:, body:, created_by_user:, message:)
end
def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil
normalised = normalise_body(body) normalised = normalise_body(body)
page = WikiPage.new(tag_name:, page = WikiPage.new(tag_name:,
@@ -42,18 +33,6 @@ module Wiki
end end
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
description = TagDescription.find_by(id: page.id)
if description
return TagDescriptionOperation.update!(
tag: description.tag, body:, updated_by_user: created_user,
message:, base_revision_id:)
end
legacy_content!(page:, body:, created_user:, message:, base_revision_id:)
end
def self.legacy_content! page:, body:, created_user:, message: nil,
base_revision_id: nil
new(page:, created_user:).content!(body:, message:, base_revision_id:) new(page:, created_user:).content!(body:, message:, base_revision_id:)
end end
@@ -78,6 +57,9 @@ module Wiki
line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) } line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) }
tree_sha = Digest::SHA256.hexdigest(line_shas.join(',')) tree_sha = Digest::SHA256.hexdigest(line_shas.join(','))
line_id_by_sha = upsert_lines!(lines, line_shas)
line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) }
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@page.lock! @page.lock!
@@ -90,8 +72,6 @@ module Wiki
end end
end end
line_id_by_sha = upsert_lines!(lines, line_shas)
line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) }
@page.update!(body: normalised) @page.update!(body: normalised)
WikiVersionRecorder.record!( WikiVersionRecorder.record!(
@@ -114,6 +94,7 @@ module Wiki
{ wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos } { wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos }
end end
WikiRevisionLine.insert_all!(rows) if rows.any? WikiRevisionLine.insert_all!(rows) if rows.any?
rev rev
end end
end end
-33
ファイルの表示
@@ -1,33 +0,0 @@
module Wiki
class CompatibilityWriter
def self.create! tag_name:, body:, created_by_user:, message: nil
Commit.create_legacy_content!(
tag_name:, body:, created_by_user:, message:)
end
def self.update! page:, body:, updated_by_user:, message: nil,
base_revision_id: nil
normalised = Commit.normalise_body(body)
if normalised.blank?
return Commit.legacy_content!(
page:, body: normalised, created_user: updated_by_user, message:,
base_revision_id:)
end
if base_revision_id.present?
current_id = page.wiki_revisions.maximum(:id)
if current_id && current_id != base_revision_id.to_i
raise Commit::Conflict,
"競合が発生してゐます" +
"(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
end
end
return if Commit.normalise_body(page.body) == normalised
Commit.legacy_content!(
page:, body: normalised, created_user: updated_by_user, message:,
base_revision_id:)
end
end
end
+1 -7
ファイルの表示
@@ -1,6 +1,4 @@
Rails.application.routes.draw do Rails.application.routes.draw do
resources :tag_groups, only: [:index, :show, :create, :update]
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update] resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
scope 'tags/:parent_id/children', controller: :tag_children do scope 'tags/:parent_id/children', controller: :tag_children do
@@ -16,7 +14,6 @@ Rails.application.routes.draw do
scope :name do scope :name do
get ':name/deerjikists', action: :deerjikists_by_name get ':name/deerjikists', action: :deerjikists_by_name
get ':name/description', to: 'tag_descriptions#show_by_title'
get ':name/materials', action: :materials_by_name get ':name/materials', action: :materials_by_name
get ':name', action: :show_by_name get ':name', action: :show_by_name
end end
@@ -28,9 +25,6 @@ Rails.application.routes.draw do
get :deerjikists get :deerjikists
put :deerjikists, action: :update_deerjikists put :deerjikists, action: :update_deerjikists
get :description, to: 'tag_descriptions#show'
put :description, to: 'tag_descriptions#update'
end end
end end
@@ -41,7 +35,7 @@ Rails.application.routes.draw do
resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do
collection do collection do
get :search, action: :index get :search
get :changes get :changes
scope :title do scope :title do
-63
ファイルの表示
@@ -1,63 +0,0 @@
class CreateTagDescriptions < ActiveRecord::Migration[8.0]
def change
create_table :tag_description_versions do |t|
t.references :tag, null: false, foreign_key: false
t.string :language_code, limit: 16, null: false
t.integer :version_no, null: false
t.string :event_type, null: false
t.text :body, null: false
t.string :source_language_code, limit: 16
t.integer :source_version_no
t.references :created_by_user
t.datetime :created_at, null: false
t.index [:tag_id, :language_code, :version_no], unique: true
t.foreign_key :tag_description_versions,
column: [:tag_id, :source_language_code, :source_version_no],
primary_key: [:tag_id, :language_code, :version_no]
t.check_constraint 'version_no > 0',
name: 'chk_tag_description_versions_version_no_positive'
t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')",
name: 'tag_description_versions_event_type_valid'
t.check_constraint ('(source_language_code IS NULL AND source_version_no IS NULL) OR ' +
'(source_language_code IS NOT NULL AND source_version_no IS NOT NULL)'),
name: 'chk_tag_description_versions_source_pair'
t.check_constraint 'source_language_code IS NULL OR source_language_code <> language_code',
name: 'chk_tag_description_versions_source_language_code_different'
end
create_table :tag_descriptions do |t|
t.references :tag, null: false, foreign_key: true
t.string :language_code, limit: 16, null: false, index: true
t.integer :version_no, null: false
t.text :body, null: false
t.string :source_language_code, limit: 16
t.integer :source_version_no
t.references :created_by_user, foreign_key: { to_table: :users }
t.references :updated_by_user, foreign_key: { to_table: :users }
t.timestamps
t.foreign_key :languages, column: :language_code, primary_key: :code
t.foreign_key :tag_description_versions,
column: [:tag_id, :source_language_code, :source_version_no],
primary_key: [:tag_id, :language_code, :version_no]
t.index [:tag_id, :language_code], unique: true
t.index [:source_language_code, :source_version_no]
t.check_constraint 'version_no > 0',
name: 'chk_tag_descriptions_version_no_positive'
t.check_constraint ('(source_language_code IS NULL AND source_version_no IS NULL) OR ' +
'(source_language_code IS NOT NULL AND source_version_no IS NOT NULL)'),
name: 'chk_tag_descriptions_source_pair'
t.check_constraint 'source_language_code IS NULL OR source_language_code <> language_code',
name: 'chk_tag_descriptions_source_language_code_different'
end
end
end
-104
ファイルの表示
@@ -1,104 +0,0 @@
class MigrateTagDescriptionsFromWikiPages < ActiveRecord::Migration[8.0]
class WikiPage < ActiveRecord::Base
self.table_name = 'wiki_pages'
end
class WikiRevision < ActiveRecord::Base
self.table_name = 'wiki_revisions'
end
class WikiRevisionLine < ActiveRecord::Base
self.table_name = 'wiki_revision_lines'
end
class TagName < ActiveRecord::Base
self.table_name = 'tag_names'
end
class TagDescription < ActiveRecord::Base
self.table_name = 'tag_descriptions'
end
class TagDescriptionVersion < ActiveRecord::Base
self.table_name = 'tag_description_versions'
end
def up
WikiPage.find_each do |page|
tag_name = TagName.find_by(id: page.tag_name_id, language_code: 'ja')
next unless tag_name&.tag_id
revisions = WikiRevision.where(wiki_page_id: page.id).order(:id).to_a
revisions.each_with_index do |revision, index|
version_no = index + 1
tag_id = tag_name[:tag_id]
unless tag_id
raise (
'tag_id is nil: ' +
"wiki_page_id=#{ page.id }, " +
"tag_name_id=#{ tag_name.id }, " +
"tag_name=#{ tag_name.attributes.inspect}")
end
revision_id = revision.id
say (
"wiki_page_id=#{ page.id }, " +
"tag_name_id=#{ tag_name.id }, " +
"tag_id=#{ tag_id }, " +
"revision_id=#{ revision_id }")
version = TagDescriptionVersion.new(
id: revision_id,
tag_id:,
language_code: 'ja',
version_no:,
event_type: version_no == 1 ? 'create' : 'update',
body: revision_body(revision_id),
source_language_code: nil,
source_version_no: nil,
created_by_user_id: revision.created_user_id,
created_at: revision.created_at)
say version.attributes.inspect
version.save!
end
next if page.discarded_at.present?
next if revisions.empty?
TagDescription.create!(
id: page.id,
tag_id: tag_name.tag_id,
language_code: 'ja',
version_no: revisions.length,
body: page.body,
source_language_code: nil,
source_version_no: nil,
created_by_user_id: page.created_user_id,
updated_by_user_id: page.updated_user_id,
created_at: page.created_at,
updated_at: page.updated_at)
end
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
private
def revision_body revision_id
WikiRevisionLine
.joins('INNER JOIN wiki_lines ' +
'ON wiki_lines.id = wiki_revision_lines.wiki_line_id')
.where(wiki_revision_id: revision_id)
.order(:position)
.pluck('wiki_lines.body')
.join("\n")
end
end
-53
ファイルの表示
@@ -1,53 +0,0 @@
class RepairWikiUpdatedAt < ActiveRecord::Migration[8.0]
class WikiPage < ActiveRecord::Base
self.table_name = 'wiki_pages'
end
class WikiVersion < ActiveRecord::Base
self.table_name = 'wiki_versions'
end
class TagDescription < ActiveRecord::Base
self.table_name = 'tag_descriptions'
end
class TagDescriptionVersion < ActiveRecord::Base
self.table_name = 'tag_description_versions'
end
def up
say_with_time 'Repairing wiki_pages.updated_at from wiki_versions' do
WikiPage.find_each do |page|
version =
WikiVersion
.where(wiki_page_id: page.id)
.order(version_no: :desc)
.first
next unless version
page.update_columns(updated_at: version.created_at)
end
end
say_with_time 'Repairing tag_descriptions.updated_at from tag_description_versions' do
TagDescription.find_each do |description|
version =
TagDescriptionVersion
.where(
tag_id: description.tag_id,
language_code: description.language_code)
.order(version_no: :desc)
.first
next unless version
description.update_columns(updated_at: version.created_at)
end
end
end
def down
;
end
end
-149
ファイルの表示
@@ -1,149 +0,0 @@
class CreateTagGroups < ActiveRecord::Migration[8.0]
def change
# 翻訳系は上流の Translation リソースが未完のため保留
create_table :tag_groups do |t|
t.integer :version_no, null: false
t.string :name, limit: 255, null: false
t.text :description
t.references :created_by_user, foreign_key: { to_table: :users }
t.references :updated_by_user, foreign_key: { to_table: :users }
t.timestamps
t.check_constraint 'version_no > 0', name: 'chk_tag_groups_version_no_positive'
t.index :name, unique: true
end
create_table :tag_group_tags, primary_key: [:tag_group_id, :tag_id] do |t|
t.references :tag_group, null: false, foreign_key: true, index: false
t.references :tag, null: false, foreign_key: true
t.references :created_by_user, foreign_key: { to_table: :users }
t.datetime :created_at, null: false
end
create_table :tag_group_groups, primary_key: [:tag_group_id, :child_tag_group_id] do |t|
t.references :tag_group, null: false, foreign_key: true, index: false
t.references :child_tag_group,
null: false, foreign_key: { to_table: :tag_groups }
t.references :created_by_user, foreign_key: { to_table: :users }
t.datetime :created_at, null: false
t.check_constraint 'tag_group_id <> child_tag_group_id',
name: 'chk_tag_group_groups_tag_group_id_not_child_tag_group_id'
end
create_table :tag_group_placements, primary_key: [:tag_group_id, :position] do |t|
t.references :tag_group, null: false, foreign_key: true, index: false
t.integer :position, null: false
t.integer :parent_position
t.string :section_name, limit: 255
t.text :description
t.references :tag
t.references :child_tag_group
t.references :created_by_user, foreign_key: { to_table: :users }
t.references :updated_by_user, foreign_key: { to_table: :users }
t.timestamps
t.foreign_key :tag_group_placements,
column: [:tag_group_id, :parent_position],
primary_key: [:tag_group_id, :position]
t.foreign_key :tag_group_tags,
column: [:tag_group_id, :tag_id],
primary_key: [:tag_group_id, :tag_id]
t.foreign_key :tag_group_groups,
column: [:tag_group_id, :child_tag_group_id],
primary_key: [:tag_group_id, :child_tag_group_id]
t.check_constraint 'position > parent_position',
name: 'chk_tag_group_placements_position_gt_parent_position'
t.check_constraint ('((section_name IS NOT NULL AND tag_id IS NULL AND child_tag_group_id IS NULL) OR ' +
'(section_name IS NULL AND tag_id IS NOT NULL AND child_tag_group_id IS NULL) OR ' +
'(section_name IS NULL AND tag_id IS NULL AND child_tag_group_id IS NOT NULL)) AND ' +
'(section_name IS NOT NULL OR description IS NULL)'),
name: 'chk_tag_group_placements_references_exclusive'
end
create_table :tag_group_versions do |t|
t.references :tag_group, null: false
t.integer :version_no, null: false
t.string :event_type, null: false
t.string :name, limit: 255, null: false
t.text :description
t.json :members, null: false
t.json :placements, null: false
t.references :created_by_user
t.datetime :created_at, null: false
t.check_constraint 'version_no > 0',
name: 'chk_tag_group_versions_version_no_positive'
t.index [:tag_group_id, :version_no], unique: true
add_members_constraint!(t)
add_placements_constraint!(t)
end
end
private
def add_members_constraint! t
schema = { type: 'array',
uniqueItems: true,
items: member_schema }
quoted_schema = connection.quote(JSON.generate(schema))
t.check_constraint "JSON_SCHEMA_VALID(#{ quoted_schema }, members)",
name: 'chk_tag_group_versions_members_schema'
end
def member_schema
{ oneOf: [tag_member_schema, group_member_schema] }
end
def tag_member_schema
{ type: 'object',
required: ['tag_id', 'version_no'],
properties: { tag_id: { type: 'integer', minimum: 1 },
version_no: { type: 'integer', minimum: 1 } },
additionalProperties: false }
end
def group_member_schema
{ type: 'object',
required: ['tag_group_id', 'version_no'],
properties: { tag_group_id: { type: 'integer', minimum: 1 },
version_no: { type: 'integer', minimum: 1 } },
additionalProperties: false }
end
def add_placements_constraint! t
schema = { type: 'array', items: placement_schema(5) }
quoted_schema = connection.quote(JSON.generate(schema))
t.check_constraint "JSON_SCHEMA_VALID(#{ quoted_schema }, placements)",
name: 'chk_tag_group_versions_placements_schema'
end
def placement_schema rest
{ type: 'object',
required: ['children'],
properties: { section_name: { type: 'string' },
description: { type: ['string', 'null'] },
tag_id: { type: 'integer', minimum: 1 },
tag_group_id: { type: 'integer', minimum: 1 },
children: if rest > 1
{ type: 'array', items: placement_schema(rest - 1) }
else
{ type: 'array', maxItems: 0 }
end },
oneOf: [{ required: ['section_name'] },
{ required: ['tag_id'] },
{ required: ['tag_group_id'] }],
dependencies: { description: ['section_name'] },
additionalProperties: false }
end
end
-518
ファイルの表示
@@ -1,518 +0,0 @@
class MigrateTagGroupsFromWikiPages < ActiveRecord::Migration[8.0]
PREFIX = 'タググループ:'
def up
require 'json'
require 'time'
require 'kramdown'
require 'kramdown-parser-gfm'
assert_empty_destination!
pages = connection.select_all(<<~SQL).to_a
SELECT
wp.id
, wp.created_user_id
, wp.updated_user_id
, wp.created_at
, wp.updated_at
, wp.discarded_at
, tn.name
FROM
wiki_pages wp
INNER JOIN
tag_names tn
ON
tn.id = wp.tag_name_id
WHERE
tn.language_code = 'ja'
AND tn.name LIKE #{ quote("#{ PREFIX }%") }
ORDER BY
wp.id
SQL
return if pages.empty?
pages.each do |page|
if page['discarded_at']
say "Wiki ##{ page.fetch('id') }: 破棄済みですが,Revision の移行を継続します."
end
end
tag_ids = load_tag_ids
page_ids = pages.map { |page| page.fetch('id').to_i }
revisions = connection.select_all(<<~SQL).to_a
SELECT
wr.id
, wr.wiki_page_id
, wr.kind
, wr.redirect_page_id
, wr.created_user_id
, wr.created_at
FROM
wiki_revisions wr
WHERE
wr.wiki_page_id IN (#{ page_ids.join(', ') })
ORDER BY
wr.wiki_page_id
, wr.id
SQL
revisions_by_page = revisions.group_by { |row| row.fetch('wiki_page_id').to_i }
pages = pages.select do |page|
page_id = page.fetch('id').to_i
if revisions_by_page.fetch(page_id, []).empty?
# tag_groups.version_no > 0 のため,Revision 0 件の Wiki は作成しない.
say "Wiki ##{ page_id }: Revision が 0 件のため移行対象から除外します."
false
else
true
end
end
return if pages.empty?
group_ids = pages.to_h { |page| [page.fetch('name'), page.fetch('id').to_i] }
revision_history = pages.to_h do |page|
page_id = page.fetch('id').to_i
history = revisions_by_page.fetch(page_id).each_with_index.map do |revision, index|
{ 'id' => revision.fetch('id').to_i,
'version_no' => index + 1,
'created_at' => revision.fetch('created_at') }
end
[page_id, history]
end
tag_history = connection.select_all(<<~SQL).to_a.group_by { |row| row.fetch('tag_id').to_i }
SELECT
tag_id
, version_no
, created_at
FROM
tag_versions
ORDER BY
tag_id
, created_at
, version_no
SQL
prepared = { }
pages.each do |page|
page_id = page.fetch('id').to_i
history = revisions_by_page.fetch(page_id)
prepared[page_id] = history.each_with_index.map do |revision, index|
context = "Wiki ##{ page_id } / Revision ##{ revision.fetch('id') }"
body = revision_body(revision.fetch('id').to_i)
if revision.fetch('kind').to_i == 1
destination = revision['redirect_page_id']
description = "旧 Wiki のリダイレクト先: Wiki ##{ destination }"
placements = []
members = []
say "#{ context }: リダイレクトを説明文として移行します."
else
begin
description, placements = parse_wiki(body, tag_ids, group_ids, context)
validate_tree!(placements, context)
members = build_members(placements, revision.fetch('created_at'),
tag_history, revision_history, context)
rescue StandardError => error
# 解析できない版も捨てず,当時の Markdown を説明文として保存する.
say "#{ context }: 構造化を保留し,本文を保存します (#{ error.message })."
description = body
placements = []
members = []
end
end
{ revision:, version_no: index + 1, description:, members:, placements:, body: }
end
end
# 自己包含は禁止されてゐるため,当該版は原文の保存を優先する.
prepared.each do |group_id, versions|
current = versions.last
next unless current.fetch(:members).any? { |member| member['tag_group_id'] == group_id }
say "Tag Group ##{ group_id }: 直接の自己参照を検出したため,現行構造化を保留します."
current[:description] = current.fetch(:body)
current[:members] = []
current[:placements] = []
end
begin
validate_current_groups!(prepared)
rescue StandardError => error
say "Tag Group の包含関係に問題がありますが,移行を継続します (#{ error.message })."
end
# 移行先の書込みはすべて検証の後.DDL は含めない.
connection.transaction do
pages.each do |page|
page_id = page.fetch('id').to_i
current = prepared.fetch(page_id).last
insert_row('tag_groups', {
id: page_id,
# page.version_no ではなく,この Wiki に紐付く Revision の総数.
version_no: revisions_by_page.fetch(page_id).size,
name: page.fetch('name').delete_prefix(PREFIX),
description: current.fetch(:description),
created_by_user_id: page.fetch('created_user_id'),
updated_by_user_id: page.fetch('updated_user_id'),
created_at: page.fetch('created_at'),
updated_at: page.fetch('updated_at') })
end
pages.each do |page|
page_id = page.fetch('id').to_i
name = page.fetch('name').delete_prefix(PREFIX)
prepared.fetch(page_id).each do |item|
revision = item.fetch(:revision)
insert_row('tag_group_versions', {
id: revision.fetch('id'),
tag_group_id: page_id,
version_no: item.fetch(:version_no),
event_type: item.fetch(:version_no) == 1 ? 'create' : 'update',
name:,
description: item.fetch(:description),
members: JSON.generate(item.fetch(:members)),
placements: JSON.generate(item.fetch(:placements)),
created_by_user_id: revision.fetch('created_user_id'),
created_at: revision.fetch('created_at') })
end
end
pages.each do |page|
page_id = page.fetch('id').to_i
current = prepared.fetch(page_id).last
revision = current.fetch(:revision)
current.fetch(:members).each do |member|
if member.key?('tag_id')
insert_row('tag_group_tags', {
tag_group_id: page_id,
tag_id: member.fetch('tag_id'),
created_by_user_id: revision.fetch('created_user_id'),
created_at: revision.fetch('created_at') })
else
insert_row('tag_group_groups', {
tag_group_id: page_id,
child_tag_group_id: member.fetch('tag_group_id'),
created_by_user_id: revision.fetch('created_user_id'),
created_at: revision.fetch('created_at') })
end
end
end
pages.each do |page|
page_id = page.fetch('id').to_i
current = prepared.fetch(page_id).last
revision = current.fetch(:revision)
position = 0
append = -> nodes, parent_position do
nodes.each do |node|
position += 1
own_position = position
insert_row('tag_group_placements', {
tag_group_id: page_id,
position: own_position,
parent_position:,
section_name: node['section_name'],
description: node['description'],
tag_id: node['tag_id'],
child_tag_group_id: node['tag_group_id'],
created_by_user_id: revision.fetch('created_user_id'),
updated_by_user_id: revision.fetch('created_user_id'),
created_at: revision.fetch('created_at'),
updated_at: revision.fetch('created_at') })
append.(node.fetch('children'), own_position)
end
end
append.(current.fetch(:placements), nil)
end
end
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
private
def assert_empty_destination!
['tag_groups', 'tag_group_versions', 'tag_group_tags',
'tag_group_groups', 'tag_group_placements'].each do |table|
count = connection.select_value("SELECT COUNT(*) FROM #{ table }").to_i
raise "#{ table } が空ではありません." if count.positive?
end
end
# 解決は tag_names.name -> tag_names.tag_id のみ.
# tags.tag_name_id は読まない.
def load_tag_ids
rows = connection.select_all(<<~SQL).to_a
SELECT
name
, tag_id
FROM
tag_names
WHERE
language_code = 'ja'
AND tag_id IS NOT NULL
SQL
rows.each_with_object({ }) do |row, by_name|
name = row.fetch('name')
id = row.fetch('tag_id').to_i
by_name[name] ||= []
by_name[name] << id unless by_name[name].include?(id)
end
end
def revision_body revision_id
connection.select_rows(<<~SQL).flatten.join("\n")
SELECT
wl.body
FROM
wiki_revision_lines wrl
INNER JOIN wiki_lines wl ON wl.id = wrl.wiki_line_id
WHERE
wrl.wiki_revision_id = #{ Integer(revision_id) }
ORDER BY
wrl.position
SQL
end
def parse_wiki markdown, tag_ids, group_ids, context
root = Kramdown::Document.new(markdown, input: 'GFM').root
placements = []
description_parts = []
headings = []
root.children.each_with_index do |node, index|
case node.type
when :blank
next
when :header
level = node.options.fetch(:level)
name = inline_text(node).strip
raise "#{ context }: 名前のない見出しがあります." if name.empty?
headings.pop while headings.any? && headings.last.first >= level
parent = headings.last&.last
section = { 'section_name' => name, 'description' => nil, 'children' => [] }
(parent ? parent.fetch('children') : placements) << section
headings << [level, section]
when :p
text = inline_markdown(node).strip
next if text.empty?
if headings.any?
heading = headings.last.last
heading['description'] = [heading['description'], text].compact.join("\n\n")
else
description_parts << text
end
when :ul, :ol
if headings.any? && see_also_heading?(headings.last.last.fetch('section_name'))
heading = headings.last.last
raw = original_block(markdown, root.children, index)
heading['description'] = [heading['description'], raw].compact.join("\n\n")
else
target = headings.any? ? headings.last.last.fetch('children') : placements
target.concat(node.children.map { |item| parse_list_item(item, tag_ids, group_ids, context) })
end
else
raise "#{ context }: 未対応の Markdown 構文 #{ node.type.inspect }"
end
end
[description_parts.empty? ? nil : description_parts.join("\n\n"), placements]
end
def see_also_heading? name
['see also', '関連項目'].include?(name.strip.downcase.sub(/[::]\z/, ''))
end
def original_block markdown, siblings, index
current = siblings.fetch(index)
start_line = current.options[:location]
raise 'Markdown block の位置が不明です.' unless start_line
following = siblings[(index + 1)..].find do |node|
node.options[:location] && node.options[:location] > start_line
end
end_line = following ? following.options.fetch(:location) - 1 : markdown.lines.length
markdown.lines[(start_line - 1)...end_line].join.strip
end
def parse_list_item item, tag_ids, group_ids, context
raise "#{ context }: リスト項目ではありません." if item.type != :li
nested, own = item.children.partition { |child| [:ul, :ol].include?(child.type) }
own.reject! { |child| child.type == :blank }
unless own.all? { |child| [:p, :text, :a, :codespan].include?(child.type) }
raise "#{ context }: 未対応のリスト項目があります."
end
label = own.map { |child| inline_text(child) }.join(' ').strip
raise "#{ context }: 空のリスト項目があります." if label.empty?
node =
if group_ids.key?(label)
{ 'tag_group_id' => group_ids.fetch(label) }
elsif tag_ids.key?(label)
ids = tag_ids.fetch(label)
unless ids.one?
raise "#{ context }: タグ名 #{ label.inspect } が複数の ID を指します:#{ ids.inspect }"
end
{ 'tag_id' => ids.first }
elsif label.end_with?(':', ':')
name = label.sub(/[::]\z/, '')
raise "#{ context }: 空の区分名があります." if name.empty?
if see_also_heading?(name) && nested.any?
raise "#{ context }: リスト内の See also は説明文への保管処理が未実装です."
end
{ 'section_name' => name, 'description' => nil }
else
raise "#{ context }: 解決不能なリスト項目 #{ label.inspect }"
end
node['children'] = nested.flat_map do |list|
list.children.map { |child| parse_list_item(child, tag_ids, group_ids, context) }
end
node
end
def inline_text node
case node.type
when :text, :codespan
node.value.to_s
when :softbreak, :br
' '
when :a, :p, :header, :em, :strong, :span, :li
node.children.map { |child| inline_text(child) }.join
else
raise "未対応のインライン構文 #{ node.type.inspect }"
end
end
def inline_markdown node
case node.type
when :text
node.value.to_s
when :codespan
"`#{ node.value }`"
when :softbreak
"\n"
when :br
" \n"
when :p
node.children.map { |child| inline_markdown(child) }.join
when :a
href = node.attr.fetch('href')
"[#{ node.children.map { |child| inline_markdown(child) }.join }](#{ href })"
when :em
"*#{ node.children.map { |child| inline_markdown(child) }.join }*"
when :strong
"**#{ node.children.map { |child| inline_markdown(child) }.join }**"
else
raise "未対応のインライン構文 #{ node.type.inspect }"
end
end
def validate_tree! nodes, context, depth = 1
raise "#{ context }: 配置の深さが 5 を超えてゐます." if depth > 5 && nodes.any?
nodes.each do |node|
name = node['section_name']
if name && name.length > 255
raise "#{ context }: 区分名が 255 文字を超えてゐます."
end
validate_tree!(node.fetch('children'), context, depth + 1)
end
end
def build_members nodes, date, tag_history, group_history, context
ids_of_tags = []
ids_of_groups = []
visit = -> items do
items.each do |node|
ids_of_tags << node['tag_id'] if node.key?('tag_id')
ids_of_groups << node['tag_group_id'] if node.key?('tag_group_id')
visit.(node.fetch('children'))
end
end
visit.(nodes)
tags = ids_of_tags.uniq.map do |id|
{ 'tag_id' => id,
'version_no' => version_at(tag_history[id], date, context, "Tag ##{ id }") }
end
groups = ids_of_groups.uniq.map do |id|
{ 'tag_group_id' => id,
'version_no' => version_at(group_history[id], date, context, "TagGroup ##{ id }") }
end
tags + groups
end
def version_at history, date, context, target
raise "#{ context }: #{ target } の履歴が存在しません." unless history&.any?
timestamp = Time.parse(date.to_s)
candidates = history.select do |row|
Time.parse(row.fetch('created_at').to_s) <= timestamp
end
if candidates.empty?
raise "#{ context }: #{ target } は #{ date } 時点で存在しません."
end
candidates.max_by { |row|
[Time.parse(row.fetch('created_at').to_s), row.fetch('version_no').to_i]
}.fetch('version_no').to_i
end
def validate_current_groups! prepared
edges = { }
prepared.each do |group_id, versions|
edges[group_id] = versions.last.fetch(:members)
.filter_map { |member| member['tag_group_id'] }
end
visiting = { }
visited = { }
visit = -> group_id do
raise "Tag Group の循環参照を検出しました:#{ group_id }" if visiting[group_id]
return if visited[group_id]
visiting[group_id] = true
edges.fetch(group_id, []).each { |child_id| visit.(child_id) }
visiting.delete(group_id)
visited[group_id] = true
end
edges.keys.each { |group_id| visit.(group_id) }
end
def quote(value) = connection.quote(value)
def insert_row table, values
columns = values.keys.join(', ')
payload = values.values.map { |value| quote(value) }.join(', ')
execute "INSERT INTO #{ table } (#{ columns }) VALUES (#{ payload })"
end
end
生成ファイル
+17 -138
ファイルの表示
@@ -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_10_08_010000) do ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) 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
@@ -440,123 +440,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
t.index ["user_id"], name: "index_settings_on_user_id", unique: true t.index ["user_id"], name: "index_settings_on_user_id", unique: true
end end
create_table "tag_description_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.string "language_code", limit: 16, null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.text "body", null: false
t.string "source_language_code", limit: 16
t.integer "source_version_no"
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.index ["created_by_user_id"], name: "index_tag_description_versions_on_created_by_user_id"
t.index ["tag_id", "language_code", "version_no"], name: "idx_on_tag_id_language_code_version_no_ced77e57e6", unique: true
t.index ["tag_id", "source_language_code", "source_version_no"], name: "fk_rails_4f13094dbc"
t.index ["tag_id"], name: "index_tag_description_versions_on_tag_id"
t.check_constraint "((`source_language_code` is null) and (`source_version_no` is null)) or ((`source_language_code` is not null) and (`source_version_no` is not null))", name: "chk_tag_description_versions_source_pair"
t.check_constraint "(`source_language_code` is null) or (`source_language_code` <> `language_code`)", name: "chk_tag_description_versions_source_language_code_different"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "tag_description_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "chk_tag_description_versions_version_no_positive"
end
create_table "tag_descriptions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.string "language_code", limit: 16, null: false
t.integer "version_no", null: false
t.text "body", null: false
t.string "source_language_code", limit: 16
t.integer "source_version_no"
t.bigint "created_by_user_id"
t.bigint "updated_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_tag_descriptions_on_created_by_user_id"
t.index ["language_code"], name: "index_tag_descriptions_on_language_code"
t.index ["source_language_code", "source_version_no"], name: "idx_on_source_language_code_source_version_no_00117fe02a"
t.index ["tag_id", "language_code"], name: "index_tag_descriptions_on_tag_id_and_language_code", unique: true
t.index ["tag_id", "source_language_code", "source_version_no"], name: "fk_rails_d1be0a7696"
t.index ["tag_id"], name: "index_tag_descriptions_on_tag_id"
t.index ["updated_by_user_id"], name: "index_tag_descriptions_on_updated_by_user_id"
t.check_constraint "((`source_language_code` is null) and (`source_version_no` is null)) or ((`source_language_code` is not null) and (`source_version_no` is not null))", name: "chk_tag_descriptions_source_pair"
t.check_constraint "(`source_language_code` is null) or (`source_language_code` <> `language_code`)", name: "chk_tag_descriptions_source_language_code_different"
t.check_constraint "`version_no` > 0", name: "chk_tag_descriptions_version_no_positive"
end
create_table "tag_group_groups", primary_key: ["tag_group_id", "child_tag_group_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_group_id", null: false
t.bigint "child_tag_group_id", null: false
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.index ["child_tag_group_id"], name: "index_tag_group_groups_on_child_tag_group_id"
t.index ["created_by_user_id"], name: "index_tag_group_groups_on_created_by_user_id"
t.check_constraint "`tag_group_id` <> `child_tag_group_id`", name: "chk_tag_group_groups_tag_group_id_not_child_tag_group_id"
end
create_table "tag_group_placements", primary_key: ["tag_group_id", "position"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_group_id", null: false
t.integer "position", null: false
t.integer "parent_position"
t.string "section_name"
t.text "description"
t.bigint "tag_id"
t.bigint "child_tag_group_id"
t.bigint "created_by_user_id"
t.bigint "updated_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["child_tag_group_id"], name: "index_tag_group_placements_on_child_tag_group_id"
t.index ["created_by_user_id"], name: "index_tag_group_placements_on_created_by_user_id"
t.index ["tag_group_id", "child_tag_group_id"], name: "fk_rails_7342b54ced"
t.index ["tag_group_id", "parent_position"], name: "fk_rails_3af6ee71ca"
t.index ["tag_group_id", "tag_id"], name: "fk_rails_365dc60759"
t.index ["tag_id"], name: "index_tag_group_placements_on_tag_id"
t.index ["updated_by_user_id"], name: "index_tag_group_placements_on_updated_by_user_id"
t.check_constraint "(((`section_name` is not null) and (`tag_id` is null) and (`child_tag_group_id` is null)) or ((`section_name` is null) and (`tag_id` is not null) and (`child_tag_group_id` is null)) or ((`section_name` is null) and (`tag_id` is null) and (`child_tag_group_id` is not null))) and ((`section_name` is not null) or (`description` is null))", name: "chk_tag_group_placements_references_exclusive"
t.check_constraint "`position` > `parent_position`", name: "chk_tag_group_placements_position_gt_parent_position"
end
create_table "tag_group_tags", primary_key: ["tag_group_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_group_id", null: false
t.bigint "tag_id", null: false
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.index ["created_by_user_id"], name: "index_tag_group_tags_on_created_by_user_id"
t.index ["tag_id"], name: "index_tag_group_tags_on_tag_id"
end
create_table "tag_group_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_group_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "name", null: false
t.text "description"
t.json "members", null: false
t.json "placements", null: false
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.index ["created_by_user_id"], name: "index_tag_group_versions_on_created_by_user_id"
t.index ["tag_group_id", "version_no"], name: "index_tag_group_versions_on_tag_group_id_and_version_no", unique: true
t.index ["tag_group_id"], name: "index_tag_group_versions_on_tag_group_id"
t.check_constraint "`version_no` > 0", name: "chk_tag_group_versions_version_no_positive"
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"maxItems\":0}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}',`placements`)", name: "chk_tag_group_versions_placements_schema"
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"uniqueItems\":true,\"items\":{\"oneOf\":[{\"type\":\"object\",\"required\":[\"tag_id\",\"version_no\"],\"properties\":{\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1}},\"additionalProperties\":false},{\"type\":\"object\",\"required\":[\"tag_group_id\",\"version_no\"],\"properties\":{\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1}},\"additionalProperties\":false}]}}',`members`)", name: "chk_tag_group_versions_members_schema"
end
create_table "tag_groups", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.integer "version_no", null: false
t.string "name", null: false
t.text "description"
t.bigint "created_by_user_id"
t.bigint "updated_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_tag_groups_on_created_by_user_id"
t.index ["name"], name: "index_tag_groups_on_name", unique: true
t.index ["updated_by_user_id"], name: "index_tag_groups_on_updated_by_user_id"
t.check_constraint "`version_no` > 0", name: "chk_tag_groups_version_no_positive"
end
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
t.bigint "parent_tag_id", null: false t.bigint "parent_tag_id", null: false
@@ -773,6 +656,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
t.index ["banned_at"], name: "index_users_on_banned_at" t.index ["banned_at"], name: "index_users_on_banned_at"
end end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false t.string "sha256", limit: 64, null: false
t.text "body", null: false t.text "body", null: false
@@ -789,6 +685,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.datetime "discarded_at" t.datetime "discarded_at"
t.integer "next_asset_no", default: 1, null: false
t.integer "version_no", null: false t.integer "version_no", null: false
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
@@ -891,26 +788,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
add_foreign_key "post_versions", "users", column: "created_by_user_id" add_foreign_key "post_versions", "users", column: "created_by_user_id"
add_foreign_key "posts", "users", column: "uploaded_user_id" add_foreign_key "posts", "users", column: "uploaded_user_id"
add_foreign_key "settings", "users" add_foreign_key "settings", "users"
add_foreign_key "tag_description_versions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"]
add_foreign_key "tag_descriptions", "languages", column: "language_code", primary_key: "code"
add_foreign_key "tag_descriptions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"]
add_foreign_key "tag_descriptions", "tags"
add_foreign_key "tag_descriptions", "users", column: "created_by_user_id"
add_foreign_key "tag_descriptions", "users", column: "updated_by_user_id"
add_foreign_key "tag_group_groups", "tag_groups"
add_foreign_key "tag_group_groups", "tag_groups", column: "child_tag_group_id"
add_foreign_key "tag_group_groups", "users", column: "created_by_user_id"
add_foreign_key "tag_group_placements", "tag_group_groups", column: ["tag_group_id", "child_tag_group_id"], primary_key: ["tag_group_id", "child_tag_group_id"]
add_foreign_key "tag_group_placements", "tag_group_placements", column: ["tag_group_id", "parent_position"], primary_key: ["tag_group_id", "position"]
add_foreign_key "tag_group_placements", "tag_group_tags", column: ["tag_group_id", "tag_id"], primary_key: ["tag_group_id", "tag_id"]
add_foreign_key "tag_group_placements", "tag_groups"
add_foreign_key "tag_group_placements", "users", column: "created_by_user_id"
add_foreign_key "tag_group_placements", "users", column: "updated_by_user_id"
add_foreign_key "tag_group_tags", "tag_groups"
add_foreign_key "tag_group_tags", "tags"
add_foreign_key "tag_group_tags", "users", column: "created_by_user_id"
add_foreign_key "tag_groups", "users", column: "created_by_user_id"
add_foreign_key "tag_groups", "users", column: "updated_by_user_id"
add_foreign_key "tag_implications", "tags" add_foreign_key "tag_implications", "tags"
add_foreign_key "tag_implications", "tags", column: "parent_tag_id" add_foreign_key "tag_implications", "tags", column: "parent_tag_id"
add_foreign_key "tag_names", "languages", column: "language_code", primary_key: "code" add_foreign_key "tag_names", "languages", column: "language_code", primary_key: "code"
@@ -945,6 +822,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "posts"
add_foreign_key "user_post_views", "users" add_foreign_key "user_post_views", "users"
add_foreign_key "user_theme_slots", "users" add_foreign_key "user_theme_slots", "users"
add_foreign_key "wiki_assets", "users", column: "created_by_user_id"
add_foreign_key "wiki_assets", "wiki_pages"
add_foreign_key "wiki_pages", "tag_names" add_foreign_key "wiki_pages", "tag_names"
add_foreign_key "wiki_pages", "users", column: "created_user_id" add_foreign_key "wiki_pages", "users", column: "created_user_id"
add_foreign_key "wiki_pages", "users", column: "updated_user_id" add_foreign_key "wiki_pages", "users", column: "updated_user_id"
+3 -3
ファイルの表示
@@ -3,8 +3,8 @@ namespace :nico do
task export: :environment do task export: :environment do
require 'open3' require 'open3'
mysql_user = ENV.fetch('NIZIKA_NICO_MYSQL_USER') mysql_user = ENV.fetch('MYSQL_USER')
mysql_pass = ENV.fetch('NIZIKA_NICO_MYSQL_PASS') mysql_pass = ENV.fetch('MYSQL_PASS')
nizika_nico_path = ENV.fetch('NIZIKA_NICO_PATH') nizika_nico_path = ENV.fetch('NIZIKA_NICO_PATH')
videos = Post.where('url LIKE ?', '%nicovideo.jp/watch/%').pluck(:url).filter_map { videos = Post.where('url LIKE ?', '%nicovideo.jp/watch/%').pluck(:url).filter_map {
@@ -14,7 +14,7 @@ namespace :nico do
next if videos.empty? next if videos.empty?
_, stderr, status = Open3.capture3( _, stderr, status = Open3.capture3(
{ 'NIZIKA_NICO_MYSQL_USER' => mysql_user, 'NIZIKA_NICO_MYSQL_PASS' => mysql_pass }, { 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass },
'python3', '-m', 'tracked_videos.put_bulk_upsert', *videos, 'python3', '-m', 'tracked_videos.put_bulk_upsert', *videos,
chdir: nizika_nico_path) chdir: nizika_nico_path)
+3 -3
ファイルの表示
@@ -54,11 +54,11 @@ namespace :nico do
.find_each(&:destroy!) .find_each(&:destroy!)
end end
mysql_user = ENV['NIZIKA_NICO_MYSQL_USER'] mysql_user = ENV['MYSQL_USER']
mysql_pass = ENV['NIZIKA_NICO_MYSQL_PASS'] mysql_pass = ENV['MYSQL_PASS']
nizika_nico_path = ENV['NIZIKA_NICO_PATH'] nizika_nico_path = ENV['NIZIKA_NICO_PATH']
stdout, stderr, status = Open3.capture3( stdout, stderr, status = Open3.capture3(
{ 'NIZIKA_NICO_MYSQL_USER' => mysql_user, 'NIZIKA_NICO_MYSQL_PASS' => mysql_pass }, { 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass },
'python3', "#{ nizika_nico_path }/get_videos.py") 'python3', "#{ nizika_nico_path }/get_videos.py")
unless status.success? unless status.success?
-34
ファイルの表示
@@ -1,34 +0,0 @@
require 'rails_helper'
require_relative '../../db/migrate/20261003010000_migrate_tag_descriptions_from_wiki_pages'
RSpec.describe 'Tag description migration identity' do
it 'preserves every revision ID and repeated body in an independent sequence' do
user = create_member_user!
tag_name = create(:tag_name, name: 'description_migration')
page = Wiki::Commit.create_content!(
tag_name:, body: 'A', created_by_user: user)
revisions = [page.current_revision]
['A', 'B', 'B', 'A'].each do |body|
revisions << Wiki::Commit.content!(
page:, body:, created_user: user, base_revision_id: revisions.last.id)
end
tag = create(:tag, primary_tag_name: tag_name)
legacy_state = [page.reload.attributes, revisions.map(&:attributes)]
expect(page.wiki_versions.count).to eq(3)
migration = MigrateTagDescriptionsFromWikiPages.new
migration.suppress_messages { migration.up }
versions = TagDescriptionVersion.where(tag_id: tag.id, language_code: 'ja')
.order(:version_no)
expect(versions.pluck(:id)).to eq(revisions.map(&:id))
expect(versions.pluck(:version_no)).to eq([1, 2, 3, 4, 5])
expect(versions.pluck(:body)).to eq(['A', 'A', 'B', 'B', 'A'])
expect(versions.pluck(:created_by_user_id)).to eq([user.id] * 5)
expect(versions.pluck(:created_at)).to eq(revisions.map(&:created_at))
expect(TagDescription.find(page.id)).to have_attributes(
tag_id: tag.id, language_code: 'ja', version_no: 5, body: 'A')
expect([page.reload.attributes, revisions.map { |revision| revision.reload.attributes }])
.to eq(legacy_state)
end
end
-110
ファイルの表示
@@ -1,110 +0,0 @@
require 'rails_helper'
RSpec.describe 'Tag group persistence constraints' do
include TagGroupRecords
let!(:user) { create_member_user! }
let!(:tag) { group_tag('制約確認') }
let!(:group_id) do
seed_group(
name: '制約確認',
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }])
end
def placement attributes
insert_group_row('tag_group_placements', {
tag_group_id: group_id, position: 1,
created_at: Time.current, updated_at: Time.current }.merge(attributes))
end
def reject_statement
before_state = group_state
expect { yield }.to raise_error(ActiveRecord::StatementInvalid)
expect(group_state).to eq(before_state)
end
it 'allows membership without placement' do
expect(group_rows('tag_group_tags', tag_group_id: group_id).size).to eq(1)
expect(group_rows('tag_group_placements', tag_group_id: group_id)).to be_empty
end
it 'allows repeated Tag placements with unique membership' do
placement(tag_id: tag.id)
placement(tag_id: tag.id, position: 2)
expect(group_rows('tag_group_tags', tag_group_id: group_id).size).to eq(1)
expect(group_rows('tag_group_placements', tag_group_id: group_id).size).to eq(2)
end
it 'rejects duplicate Tag membership' do
reject_statement do
insert_group_row('tag_group_tags', {
tag_group_id: group_id, tag_id: tag.id, created_at: Time.current })
end
end
it 'rejects duplicate child membership' do
child_id = seed_group(name: '子')
attributes = { tag_group_id: group_id, child_tag_group_id: child_id,
created_at: Time.current }
insert_group_row('tag_group_groups', attributes)
reject_statement { insert_group_row('tag_group_groups', attributes) }
end
it 'rejects direct self membership' do
reject_statement do
insert_group_row('tag_group_groups', {
tag_group_id: group_id, child_tag_group_id: group_id, created_at: Time.current })
end
end
it 'rejects a placement without a kind' do
reject_statement { placement({ }) }
end
it 'rejects simultaneous Section and Tag kinds' do
reject_statement { placement(section_name: '区分', tag_id: tag.id) }
end
it 'rejects a description attached to a Tag placement' do
reject_statement { placement(tag_id: tag.id, description: '不正') }
end
it 'allows an optional description on a Section' do
placement(section_name: '区分', description: '説明')
expect(group_rows('tag_group_placements', tag_group_id: group_id).sole)
.to include('section_name' => '区分', 'description' => '説明')
end
it 'rejects a Tag placement absent from membership' do
other = group_tag('非メンバー')
reject_statement { placement(tag_id: other.id) }
end
it 'rejects a missing parent placement' do
reject_statement { placement(section_name: '子', parent_position: 9) }
end
it 'rejects a child ordered before its parent' do
placement(section_name: '親', position: 2)
reject_statement { placement(section_name: '子', position: 1, parent_position: 2) }
end
it 'rejects duplicate group names' do
reject_statement { seed_group(name: '制約確認') }
end
it 'rejects a missing mandatory group name' do
reject_statement { seed_group(name: nil) }
end
it 'rejects a six-level snapshot' do
nodes = []
6.times { nodes = [{ 'tag_id' => tag.id, 'children' => nodes }] }
reject_statement do
insert_group_row('tag_group_versions', {
tag_group_id: group_id, version_no: 2, event_type: 'update', name: '制約確認',
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }].to_json,
placements: nodes.to_json, created_at: Time.current })
end
end
end
-119
ファイルの表示
@@ -1,119 +0,0 @@
require 'rails_helper'
RSpec.describe 'Tag group migration identity and structure' do
include TagGroupRecords
let!(:user) { create_member_user! }
before { I18n.locale = :ja }
it 'preserves page and revision IDs, timestamps and repeated historical structures' do
tag = group_tag('洗澡鹿')
body = "説明文\n\n- 洗澡鹿\n- 洗澡鹿"
page = legacy_group('伊地知ニジカ亜種', body)
revisions = [page.current_revision]
[body, '# 空の区分', body].each do |text|
revisions << Wiki::Commit.legacy_content!(page:, body: text, created_user: user)
end
before_state = group_state.except(*TagGroupRecords::GROUP_TABLES)
migrate_groups
versions = group_rows('tag_group_versions', tag_group_id: page.id)
.sort_by { _1.fetch('version_no') }
expect(versions.map { _1.fetch('id') }).to eq(revisions.map(&:id))
expect(versions.map { _1.fetch('version_no') }).to eq([1, 2, 3, 4])
expect(versions.map { _1.fetch('created_at') }).to eq(revisions.map(&:created_at))
expect(versions.map { _1.fetch('created_by_user_id') }).to eq([user.id] * 4)
expect(versions.map { _1.fetch('event_type') }).to eq(['create', 'update', 'update', 'update'])
expect(group_version(page.id, 1).fetch('placements')).to eq(
group_version(page.id, 2).fetch('placements'))
expect(group_version(page.id, 3).fetch('placements')).to eq([
{ 'section_name' => '空の区分', 'description' => nil, 'children' => [] }])
expect_group_snapshot(
page.id, number: 4, description: '説明文',
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }],
placements: [{ 'tag_id' => tag.id, 'children' => [] }] * 2)
expect(group_rows('tag_groups', id: page.id).sole).to include(
'created_at' => page.created_at, 'updated_at' => page.reload.updated_at)
expect(group_state.except(*TagGroupRecords::GROUP_TABLES)).to eq(before_state)
end
it 'migrates nested list Sections and child groups as different placement kinds' do
series = group_tag('ぼざろクリーチャーシリーズ')
deer = group_tag('洗澡鹿')
nijika = group_tag('伊地知ニジカ')
child = legacy_group('伊地知ニジカ亜種', <<~MARKDOWN)
- ぼざろクリーチャーシリーズ
- 伊地知ニジカ亜種:
- 洗澡鹿
MARKDOWN
parent = legacy_group('ぼざろクリーチャーシリーズのキャラクター', <<~MARKDOWN)
- ぼざろクリーチャーシリーズ
- 伊地知ニジカ
- タググループ:伊地知ニジカ亜種
MARKDOWN
migrate_groups
expect_group_snapshot(
child.id, number: 1,
members: [series, deer].map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } },
placements: [{ 'tag_id' => series.id, 'children' => [
{ 'section_name' => '伊地知ニジカ亜種', 'description' => nil,
'children' => [{ 'tag_id' => deer.id, 'children' => [] }] }] }])
expect_group_snapshot(
parent.id, number: 1,
members: [
{ 'tag_id' => series.id, 'version_no' => series.version_no },
{ 'tag_id' => nijika.id, 'version_no' => nijika.version_no },
{ 'tag_group_id' => child.id, 'version_no' => 1 }],
placements: [{ 'tag_id' => series.id, 'children' => [
{ 'tag_id' => nijika.id, 'children' => [
{ 'tag_group_id' => child.id, 'children' => [] }] }] }])
end
it 'preserves heading order, group paragraphs and Section descriptions' do
names = ['タグ希望', 'キャラクター名希望', '楽曲情報希望', 'bot操作', 'ニジラー情報不詳']
tags = names.map { group_tag(_1) }
markdown = <<~MARKDOWN
全体の説明
# 要求
要求の説明
- タグ希望
- キャラクター名希望
- 楽曲情報希望
# 情報
- bot操作
- ニジラー情報不詳
MARKDOWN
page = legacy_group('メタタグ', markdown)
expect(page.current_revision.body).to eq(markdown.chomp)
migrate_groups
leaves = tags.map { { 'tag_id' => _1.id, 'children' => [] } }
leaves.first['children'] = leaves[1..2]
expect_group_snapshot(
page.id, number: 1, description: '全体の説明',
members: tags.map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } },
placements: [
{ 'section_name' => '要求', 'description' => '要求の説明',
'children' => [leaves.first] },
{ 'section_name' => '情報', 'description' => nil,
'children' => leaves[3..4] }])
end
it 'leaves Help in the legacy domain' do
page = Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:移行対象外'),
body: 'Help 本文', created_by_user: user)
before_state = group_state
migrate_groups
expect(group_state).to eq(before_state)
expect(page.reload.body).to eq('Help 本文')
end
end
-276
ファイルの表示
@@ -1,276 +0,0 @@
require 'rails_helper'
RSpec.describe 'Tag description Wiki compatibility', type: :request do
let!(:user) { create_member_user! }
let!(:tag_name) { create(:tag_name, name: 'description_contract') }
let!(:page) do
Wiki::Commit.create_content!(
tag_name:, body: 'old-revision', created_by_user: user)
end
let!(:revisions) do
first = page.current_revision
second = Wiki::Commit.content!(page:, body: 'old-second', created_user: user)
third = Wiki::Commit.content!(page:, body: 'old-third', created_user: user)
[first, second, third]
end
let!(:tag) { create(:tag, primary_tag_name: tag_name) }
let!(:versions) do
revisions.each_with_index.map do |revision, index|
TagDescriptionVersion.create!(
id: revision.id, tag_id: tag.id, language_code: 'ja',
version_no: index + 1, event_type: index.zero? ? 'create' : 'update',
body: index == 2 ? 'new-version' : 'A',
created_by_user_id: user.id, created_at: revision.created_at)
end
end
let!(:description) do
TagDescription.create!(
id: page.id, tag:, language_code: 'ja', version_no: 3, body: 'new',
created_by_user_id: user.id, updated_by_user_id: user.id)
end
before { page.update_columns(body: 'old') }
# Observe real SELECTs, including cached queries, only during the request.
def without_legacy_reads
queries = []
subscriber = lambda do |*args|
sql = args.last.fetch(:sql)
if sql.match?(/\bSELECT\b/i) && sql.match?(/\bwiki_(pages|revisions|versions)\b/i)
queries << sql
end
end
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
expect(queries).to be_empty
end
describe 'show' do
it 'uses current content and revision identity without legacy reads' do
without_legacy_reads { get "/wiki/#{ description.id }" }
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'new', 'revision_id' => versions.last.id,
'pred' => versions.second.id, 'succ' => nil)
end
[0, 1, 2].each do |index|
it "navigates displayed version #{ index + 1 }, preserving repeated bodies" do
without_legacy_reads do
get "/wiki/#{ description.id }", params: { version: versions[index].id }
end
expect(response).to have_http_status(:ok)
expect(json).to include(
'body' => versions[index].body,
'revision_id' => versions[index].id,
'pred' => index.zero? ? nil : versions[index - 1].id,
'succ' => index == 2 ? nil : versions[index + 1].id)
end
end
it 'does not fall back to a revision absent from new history' do
versions.first.destroy!
without_legacy_reads do
get "/wiki/#{ description.id }", params: { version: revisions.first.id }
end
expect(response).to have_http_status(:not_found)
end
it 'does not fall back when the new current revision is missing' do
versions.last.destroy!
without_legacy_reads { get "/wiki/#{ description.id }" }
expect(response).to have_http_status(:not_found)
end
end
describe 'revision resource scope' do
let!(:other_tag) { create(:tag, primary_name: 'other_description_contract') }
let!(:other_version) do
TagDescriptionVersion.create!(
tag_id: other_tag.id, language_code: 'ja', version_no: 1,
event_type: 'create', body: 'other tag history',
created_by_user_id: user.id, created_at: Time.current)
end
let!(:other_description) do
TagDescription.create!(
tag: other_tag, language_code: 'ja', version_no: 1, body: 'other tag history',
created_by_user_id: user.id, updated_by_user_id: user.id)
end
it 'returns 404 when show specifies another tag description version' do
get "/wiki/#{ description.id }", params: { version: other_version.id }
expect(response).to have_http_status(:not_found)
expect(response.body).not_to include(other_version.body)
end
it 'returns 404 when diff from belongs to another tag description' do
get "/wiki/#{ description.id }/diff",
params: { from: other_version.id, to: versions.last.id }
expect(response).to have_http_status(:not_found)
expect(response.body).not_to include(other_version.body)
end
it 'returns 404 when diff to belongs to another tag description' do
get "/wiki/#{ description.id }/diff",
params: { from: versions.first.id, to: other_version.id }
expect(response).to have_http_status(:not_found)
expect(response.body).not_to include(other_version.body)
end
end
describe 'show_by_title' do
it 'resolves TagName and reads current content from new tables' do
without_legacy_reads { get "/wiki/title/#{ tag_name.name }" }
expect(response).to have_http_status(:ok)
expect(json).to include('id' => description.id, 'body' => 'new',
'revision_id' => versions.last.id)
end
it 'uses historical content and navigation from the new history' do
get "/wiki/title/#{ tag_name.name }", params: { version: versions.second.id }
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'A', 'revision_id' => versions.second.id,
'pred' => versions.first.id, 'succ' => versions.last.id)
end
it 'ignores changes to same-title legacy content and revision metadata' do
get "/wiki/title/#{ tag_name.name }"
original = json
page.update_columns(body: 'changed legacy body')
revisions.last.update_columns(message: 'legacy message', created_at: 1.day.ago)
revisions.last.wiki_lines.update_all(body: 'changed legacy revision')
get "/wiki/title/#{ tag_name.name }"
expect(json).to eq(original)
expect(json.fetch('body')).to eq('new')
end
end
describe 'exists' do
it 'recognises an ID without reading legacy tables' do
without_legacy_reads { get "/wiki/#{ description.id }/exists" }
expect(response).to have_http_status(:no_content)
end
it 'recognises a title without reading legacy tables' do
without_legacy_reads { get "/wiki/title/#{ tag_name.name }/exists" }
expect(response).to have_http_status(:no_content)
end
context 'without a legacy page' do
before do
WikiVersion.where(wiki_page_id: page.id).delete_all
page.destroy!
end
it 'recognises the description ID alone' do
get "/wiki/#{ description.id }/exists"
expect(response).to have_http_status(:no_content)
end
it 'recognises the TagName alone' do
get "/wiki/title/#{ tag_name.name }/exists"
expect(response).to have_http_status(:no_content)
end
it 'shows the description by title' do
get "/wiki/title/#{ tag_name.name }"
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'new', 'revision_id' => versions.last.id)
end
end
end
describe 'diff' do
[false, true].each do |current|
it "uses new bodies with #{ current ? 'implicit current' : 'explicit to' }" do
params = { from: versions.first.id }
params[:to] = versions.last.id unless current
get "/wiki/#{ description.id }/diff", params: params
expect(response).to have_http_status(:ok)
expect(json).to include(
'wiki_page_id' => description.id,
'older_revision_id' => versions.first.id,
'newer_revision_id' => versions.last.id,
'diff' => [{ 'type' => 'removed', 'content' => 'A' },
{ 'type' => 'added', 'content' => 'new-version' }])
end
end
it 'does not query legacy tables for diff' do
without_legacy_reads do
get "/wiki/#{ description.id }/diff",
params: { from: versions.first.id, to: versions.last.id }
end
expect(response).to have_http_status(:ok)
end
it 'keeps distinct revision IDs for identical consecutive bodies' do
get "/wiki/#{ description.id }/diff",
params: { from: versions.first.id, to: versions.second.id }
expect(response).to have_http_status(:ok)
expect(json).to include(
'older_revision_id' => versions.first.id,
'newer_revision_id' => versions.second.id,
'diff' => [{ 'type' => 'context', 'content' => 'A' }])
end
end
describe 'changes' do
it 'lists every new version including consecutive identical bodies' do
without_legacy_reads { get '/wiki/changes', params: { id: description.id } }
expect(response).to have_http_status(:ok)
expect(json.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id))
expect(json.first.keys).to match_array(
['revision_id', 'pred', 'succ', 'wiki_page', 'user', 'kind', 'message', 'timestamp'])
expect(json.first).to include(
'pred' => versions.second.id, 'succ' => nil, 'kind' => 'content',
'timestamp' => versions.last.created_at.iso8601(3),
'user' => { 'id' => user.id, 'name' => user.name })
expect(json.first.fetch('wiki_page')).to include('id' => description.id,
'title' => tag_name.name)
end
it 'ignores legacy-only revisions and metadata changes' do
get '/wiki/changes', params: { id: description.id }
original = json
revisions.first.update_columns(message: 'legacy changed', created_at: 1.day.ago)
WikiRevision.create!(
wiki_page: page, created_user: user, kind: :content,
tree_sha256: Digest::SHA256.hexdigest('legacy only'), lines_count: 0)
get '/wiki/changes', params: { id: description.id }
expect(json).to eq(original)
expect(json.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id))
end
end
describe 'mixed changes' do
it 'includes unmigrated history and ignores migrated legacy-only revisions' do
legacy_page = Wiki::Commit.create_content!(
tag_name: create(:tag_name, name: 'Help_mixed'),
body: 'help', created_by_user: user)
legacy_revision = legacy_page.current_revision
extra = WikiRevision.create!(
wiki_page: page, created_user: user, kind: :content,
tree_sha256: Digest::SHA256.hexdigest('legacy only'), lines_count: 0)
get '/wiki/changes'
expect(response).to have_http_status(:ok)
migrated = json.select { |row| row.fetch('wiki_page').fetch('id') == description.id }
expect(migrated.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id))
expect(json.map { |row| row.fetch('revision_id') }).not_to include(extra.id)
expect(json).to include(include(
'revision_id' => legacy_revision.id,
'wiki_page' => include('id' => legacy_page.id, 'title' => 'Help_mixed')))
end
end
describe 'index' do
[nil, 'description_contract'].each do |title|
it "returns new content once in #{ title ? 'filtered' : 'unfiltered' } index" do
get '/wiki', params: { title: }
expect(response).to have_http_status(:ok)
rows = json.select { |row| row.fetch('id') == description.id }
expect(rows.size).to eq(1)
expect(rows.first).to include('body' => 'new', 'title' => tag_name.name)
end
end
end
end
-274
ファイルの表示
@@ -1,274 +0,0 @@
require 'rails_helper'
RSpec.describe 'Tag description Wiki dual writes', type: :request do
let!(:user) { create_member_user! }
let!(:tag_name) { create(:tag_name, name: 'description_write') }
let(:tag) { create(:tag, primary_tag_name: tag_name) }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
def stored_state
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion,
TagName, TagVersion].map do |model|
model.order(:id).map(&:attributes)
end
end
# Fail after a real INSERT/UPDATE, exercising rollback rather than a mock write.
def fail_save model
failure = Class.new(StandardError)
callback = ->(_record) { raise failure, 'injected persistence failure' }
model.set_callback(:save, :after, callback)
expect { yield }.to raise_error(failure)
ensure
model.skip_callback(:save, :after, callback)
end
describe 'POST /wiki' do
before { tag }
def create_description
post '/wiki', params: { title: tag_name.name, body: 'initial', message: 'create' },
headers: headers
end
it 'creates both current records and histories with a shared revision ID' do
expect { create_description }
.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change(TagDescription, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
version = TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja')
expect(description).to have_attributes(id: page.id, body: 'initial', version_no: 1)
expect(version).to have_attributes(
id: page.current_revision.id, body: 'initial', version_no: 1,
event_type: 'create', created_by_user_id: user.id)
expect(page.body).to eq(description.body)
expect(page.current_revision.body).to eq(version.body)
expect(page.wiki_versions.last.body).to eq(version.body)
end
['missing_description', 'orphan_description'].each do |title|
it "rejects the bare title #{ title } without writing any resource" do
create(:tag_name, name: title) if title == 'orphan_description'
before_state = stored_state
post '/wiki', params: { title:, body: 'initial' }, headers: headers
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include('type' => 'validation_error')
expect(stored_state).to eq(before_state)
end
end
it 'creates a namespaced legacy Wiki resource without a Tag' do
expect do
post '/wiki', params: { title: 'Help:description_write', body: 'legacy' },
headers: headers
end.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change(TagDescription, :count).by(0)
.and change(TagDescriptionVersion, :count).by(0)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
expect(page.tag_name.tag).to be_nil
expect(page.body).to eq('legacy')
expect(page.current_revision.body).to eq('legacy')
end
it 'creates only legacy Wiki for a namespaced title with an existing Tag' do
namespaced_name = create(:tag_name, name: 'help:foo')
namespaced_tag = create(:tag, primary_tag_name: namespaced_name)
expect do
post '/wiki', params: { title: namespaced_name.name, body: 'legacy' },
headers: headers
end.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change(TagName, :count).by(0)
.and change(TagDescription, :count).by(0)
.and change(TagDescriptionVersion, :count).by(0)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
expect(page).to have_attributes(tag_name_id: namespaced_name.id, body: 'legacy')
expect(page.tag_name.tag).to eq(namespaced_tag)
expect(page.current_revision.body).to eq('legacy')
expect(page.wiki_versions.last.body).to eq('legacy')
end
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
it "rolls back both systems when #{ model.name } persistence fails" do
tag_name
before_state = stored_state
aggregate_failures do
fail_save(model) { create_description }
expect(stored_state).to eq(before_state)
end
end
end
end
describe 'PUT /wiki/:id' do
let!(:page) do
tag
Wiki::Commit.create_content!(
tag_name:, body: 'before', created_by_user: user)
end
let!(:first_revision) { page.current_revision }
let!(:description) do
TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
end
def update_description body: 'after', title: tag_name.name, base: first_revision.id
put "/wiki/#{ page.id }",
params: { title:, body:, message: 'edit', base_revision_id: base },
headers: headers
end
it 'increments the independent description sequence and shares revision identity' do
expect(page.reload.version_no).to eq(1)
expect(description.version_no).to eq(1)
expect { update_description }
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:ok)
revision = page.reload.current_revision
version = TagDescriptionVersion.find(revision.id)
expect(description.reload).to have_attributes(body: 'after', version_no: 2)
expect(version).to have_attributes(
body: 'after', version_no: 2, event_type: 'update', created_by_user_id: user.id)
expect(page).to have_attributes(body: 'after', version_no: 2)
expect(revision.body).to eq(version.body)
expect(page.wiki_versions.order(:version_no).last.body).to eq(version.body)
end
it 'uses description identity even when its Wiki TagName no longer owns the Tag' do
tag_name.update_columns(tag_id: nil)
expect { update_description }
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(description.reload).to have_attributes(body: 'after', version_no: 2)
expect(TagDescriptionVersion.find(page.reload.current_revision.id))
.to have_attributes(tag_id: tag.id, body: 'after', version_no: 2)
end
it 'does not add body history for a title-only update' do
revision_count = WikiRevision.count
wiki_version_count = WikiVersion.count
description_version_count = TagDescriptionVersion.count
wiki_version_no = page.reload.version_no
description_version_no = description.version_no
current_revision_id = page.current_revision.id
page_state = page.attributes
description_state = description.attributes
update_description(body: 'before', title: 'Help:description_renamed')
expect(response).to have_http_status(:ok)
expect(tag_name.reload.name).to eq('Help:description_renamed')
expect(WikiRevision.count).to eq(revision_count)
expect(WikiVersion.count).to eq(wiki_version_count)
expect(TagDescriptionVersion.count).to eq(description_version_count)
expect(page.reload).to have_attributes(body: 'before', version_no: wiki_version_no)
expect(page.current_revision.id).to eq(current_revision_id)
expect(page.reload.attributes).to eq(page_state)
expect(description.reload).to have_attributes(
body: 'before', version_no: description_version_no)
expect(description.reload.attributes).to eq(description_state)
end
it 'does not add body history for a TagName rename through tags' do
revision_count = WikiRevision.count
wiki_version_count = WikiVersion.count
description_version_count = TagDescriptionVersion.count
page_version_no = page.reload.version_no
current_revision_id = page.current_revision.id
patch "/tags/#{ tag.id }",
params: { name: 'description_tag_renamed' },
headers: headers
expect(response).to have_http_status(:ok)
expect(tag_name.reload.name).to eq('description_tag_renamed')
expect(WikiRevision.count).to eq(revision_count)
expect(WikiVersion.count).to eq(wiki_version_count)
expect(TagDescriptionVersion.count).to eq(description_version_count)
expect(page.reload.version_no).to eq(page_version_no)
expect(page.current_revision.id).to eq(current_revision_id)
expect(description.reload).to have_attributes(
body: 'before', version_no: 1)
end
it 'keeps both systems and the title unchanged on a stale revision conflict' do
update_description(body: 'current body', base: first_revision.id)
expect(response).to have_http_status(:ok)
stale_revision_id = first_revision.id
before_state = stored_state
update_description(
body: 'conflicting body',
title: 'conflicting_rename',
base: stale_revision_id)
expect(response).to have_http_status(:conflict)
expect(stored_state).to eq(before_state)
expect(tag_name.reload.name).not_to eq('conflicting_rename')
end
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
it "rolls back both systems when #{ model.name } persistence fails" do
before_state = stored_state
aggregate_failures do
fail_save(model) do
update_description(body: 'rollback body', title: 'rollback_rename')
end
expect(stored_state).to eq(before_state)
end
end
end
[['identical body', 'before'],
['normalised-equivalent body', "before\n\n"]].each do |label, body|
it "does not write histories for #{ label }" do
before_state = stored_state
page_state = page.reload.attributes
description_state = description.reload.attributes
current_revision_id = page.current_revision.id
wiki_lines = WikiLine.order(:id).map(&:attributes)
revision_lines = WikiRevisionLine.order(:id).map(&:attributes)
page_version_no = page.version_no
description_version_no = description.version_no
update_description(body:)
expect(response).to have_http_status(:ok)
expect(WikiRevision.count).to eq(before_state[1].length)
expect(WikiVersion.count).to eq(before_state[2].length)
expect(TagDescriptionVersion.count).to eq(before_state[4].length)
expect(page.reload.version_no).to eq(page_version_no)
expect(description.reload.version_no).to eq(description_version_no)
expect(page.current_revision.id).to eq(current_revision_id)
expect(page.reload.attributes).to eq(page_state)
expect(description.reload.attributes).to eq(description_state)
expect(WikiLine.order(:id).map(&:attributes)).to eq(wiki_lines)
expect(WikiRevisionLine.order(:id).map(&:attributes)).to eq(revision_lines)
end
end
end
end
-359
ファイルの表示
@@ -1,359 +0,0 @@
require 'rails_helper'
RSpec.describe 'Tag description native API', type: :request do
let!(:author) { create_member_user! }
let(:editor) { create(:user, :member, name: 'description editor') }
let(:tag_name) { create(:tag_name, name: 'native_description') }
let(:tag) { create(:tag, primary_tag_name: tag_name) }
let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } }
let(:endpoint) { "/tags/#{ tag.id }/description" }
def stored_state
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion,
WikiLine, WikiRevisionLine, TagName, TagVersion].map do |model|
model.order(:id).map(&:attributes)
end
end
def fail_save model
failure = Class.new(StandardError)
callback = ->(_record) { raise failure, 'injected persistence failure' }
model.set_callback(:save, :after, callback)
expect { yield }.to raise_error(failure)
ensure
model.skip_callback(:save, :after, callback)
end
def without_legacy_reads
queries = []
subscriber = lambda do |*args|
sql = args.last.fetch(:sql)
if sql.match?(/\bSELECT\b/i) && sql.match?(/\bwiki_(pages|revisions|versions)\b/i)
queries << sql
end
end
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
expect(queries).to be_empty
end
def native_get path, action: 'show'
get path
# A missing route must not make the resource-not-found examples pass.
expect(request.path_parameters).to include(controller: 'tag_descriptions', action:)
end
def native_write body:, request_headers: headers
put endpoint, params: { body: }, headers: request_headers
expect(request.path_parameters).to include(
controller: 'tag_descriptions', action: 'update')
end
def expect_current description
expect(json).to include(
'tag_id' => description.tag_id,
'language_code' => 'ja', 'body' => description.body,
'version_no' => description.version_no,
'created_by_user_id' => description.created_by_user_id,
'updated_by_user_id' => description.updated_by_user_id,
'created_at' => description.created_at.iso8601(3),
'updated_at' => description.updated_at.iso8601(3))
expect(json.keys).not_to include('title', 'name', 'wiki_page', 'tag_name_id')
end
describe 'routing', type: :routing do
it 'routes GET by Tag ID to show' do
expect(get: '/tags/123/description').to route_to(
controller: 'tag_descriptions', action: 'show', id: '123')
end
it 'routes GET by TagName to show_by_title' do
expect(get: '/tags/name/native_description/description').to route_to(
controller: 'tag_descriptions', action: 'show_by_title', name: 'native_description')
end
it 'does not route POST by Tag ID' do
expect(post: '/tags/123/description').not_to be_routable
end
it 'routes PUT by Tag ID to update' do
expect(put: '/tags/123/description').to route_to(
controller: 'tag_descriptions', action: 'update', id: '123')
end
end
describe 'native reads' do
let!(:description) do
TagDescription.create!(
id: tag.id + 1_000_000, tag:, language_code: 'ja', body: 'native current',
version_no: 1, created_by_user_id: author.id, updated_by_user_id: editor.id)
end
let!(:version) do
TagDescriptionVersion.create!(
tag_id: tag.id, language_code: 'ja', body: 'native version', version_no: 1,
event_type: 'create', created_by_user_id: author.id, created_at: Time.current)
end
let(:name_endpoint) { "/tags/name/#{ tag_name.name }/description" }
it 'reads the current resource by Tag ID without a WikiPage' do
expect(description.id).not_to eq(tag.id)
expect(WikiPage.exists?(id: description.id)).to be(false)
without_legacy_reads { native_get(endpoint) }
expect(response).to have_http_status(:ok)
expect_current(description)
end
it 'does not interpret a compatibility handle as a Tag ID' do
other_tag = create(:tag, id: description.id, primary_name: 'handle_collision')
other = TagDescription.create!(
tag: other_tag, language_code: 'ja', body: 'other current', version_no: 1,
created_by_user_id: author.id, updated_by_user_id: author.id)
without_legacy_reads { native_get("/tags/#{ other_tag.id }/description") }
expect(response).to have_http_status(:ok)
expect_current(other)
end
context 'with another language for the same Tag' do
before do
# Seed only the reference row; V1 does not enable an English locale.
Language.connection.execute(<<~SQL)
INSERT IGNORE INTO languages (code, name, created_at)
VALUES ('en', 'English', CURRENT_TIMESTAMP)
SQL
end
it 'selects the Japanese resource' do
TagDescription.create!(
tag:, language_code: 'en', body: 'English current', version_no: 4,
created_by_user_id: author.id, updated_by_user_id: author.id)
without_legacy_reads { native_get(endpoint) }
expect(response).to have_http_status(:ok)
expect_current(description)
end
it 'returns 404 if only the English description exists' do
description.update!(language_code: 'en')
without_legacy_reads { native_get(endpoint) }
expect(response).to have_http_status(:not_found)
end
end
it 'resolves the primary Japanese TagName without a WikiPage' do
without_legacy_reads { native_get(name_endpoint, action: 'show_by_title') }
expect(response).to have_http_status(:ok)
expect_current(description)
end
it 'resolves an alias through the existing TagName canonicalisation semantics' do
alias_name = create(:tag_name, :alias, tag:, name: 'native_alias')
expect(TagName.canonicalise(Locale.nipponese, [alias_name.name])).to eq([tag_name.name])
without_legacy_reads do
native_get("/tags/name/#{ alias_name.name }/description", action: 'show_by_title')
end
expect(response).to have_http_status(:ok)
expect_current(description)
end
[:id, :name].each do |lookup|
it "ignores divergent legacy body and history for #{ lookup } lookup" do
page = WikiPage.create!(
id: description.id, tag_name:, body: 'old current',
created_user: author, updated_user: author)
revision = WikiRevision.create!(
wiki_page: page, created_user: author, kind: :content,
lines_count: 1, tree_sha256: Digest::SHA256.hexdigest('old revision'))
line = WikiLine.create!(body: 'old revision', sha256: revision.tree_sha256)
WikiRevisionLine.create!(wiki_revision: revision, wiki_line: line, position: 0)
WikiVersionRecorder.record!(page:, event_type: :create, created_by_user: author)
path = lookup == :id ? endpoint : name_endpoint
action = lookup == :id ? 'show' : 'show_by_title'
without_legacy_reads { native_get(path, action:) }
expect(response).to have_http_status(:ok)
expect_current(description)
original = json
page.update_columns(body: 'changed old current')
line.update_columns(body: 'changed old revision')
revision.update_columns(message: 'changed old history', created_at: 1.day.ago)
without_legacy_reads { native_get(path, action:) }
expect(response).to have_http_status(:ok)
expect(json).to eq(original)
end
end
end
describe 'missing native resources' do
it 'returns 404 for a Tag without a description' do
without_legacy_reads { native_get(endpoint) }
expect(response).to have_http_status(:not_found)
end
it 'returns 404 for a primary name without a description' do
tag
without_legacy_reads do
native_get("/tags/name/#{ tag_name.name }/description", action: 'show_by_title')
end
expect(response).to have_http_status(:not_found)
end
it 'returns 404 for a missing Tag' do
missing_id = Tag.maximum(:id).to_i + 1
without_legacy_reads { native_get("/tags/#{ missing_id }/description") }
expect(response).to have_http_status(:not_found)
end
end
describe 'PUT /tags/:id/description' do
context 'without an existing description' do
before { tag }
it 'creates both current resources and histories with matching compatibility IDs' do
expect { native_write(body: 'created natively') }
.to change(TagDescription, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
.and change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:created)
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
page = WikiPage.find(description.id)
revision = page.current_revision
version = TagDescriptionVersion.find(revision.id)
expect(description).to have_attributes(
body: 'created natively', version_no: 1,
created_by_user_id: editor.id, updated_by_user_id: editor.id)
expect(version).to have_attributes(
tag_id: tag.id, language_code: 'ja', body: description.body,
version_no: 1, event_type: 'create', created_by_user_id: editor.id)
expect(page).to have_attributes(tag_name_id: tag_name.id, body: description.body)
expect(revision.body).to eq(version.body)
expect(page.wiki_versions.last.body).to eq(version.body)
expect_current(description)
get "/wiki/#{ description.id }"
expect(response).to have_http_status(:ok)
expect(json).to include('body' => description.body, 'revision_id' => version.id)
end
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
it "rolls back native create when #{ model.name } persistence fails" do
headers
before_state = stored_state
aggregate_failures do
fail_save(model) { native_write(body: 'rollback create') }
expect(stored_state).to eq(before_state)
end
end
end
end
context 'with an existing description' do
let!(:page) do
tag
Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: author)
end
let!(:description) do
TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
end
it 'writes matching histories when the body changes' do
expect(page.reload.version_no).to eq(1)
expect(description.version_no).to eq(1)
expect { native_write(body: 'after') }
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(description.reload).to have_attributes(
body: 'after', version_no: 2,
created_by_user_id: author.id, updated_by_user_id: editor.id)
expect(page.reload).to have_attributes(body: 'after', version_no: 2)
revision = page.current_revision
version = TagDescriptionVersion.find(revision.id)
expect(version).to have_attributes(
tag_id: tag.id, language_code: 'ja', version_no: 2, event_type: 'update',
body: 'after', created_by_user_id: editor.id)
expect(revision.body).to eq(version.body)
expect(page.wiki_versions.order(:version_no).last.body).to eq(version.body)
get "/wiki/#{ description.id }"
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'after', 'revision_id' => version.id)
end
['before', "before\n\n"].each do |body|
it 'does not write histories when the normalised body is unchanged' do
before_state = stored_state
page_state = page.reload.attributes
description_state = description.reload.attributes
current_revision_id = page.current_revision.id
native_write(body:)
expect(response).to have_http_status(:ok)
expect(stored_state).to eq(before_state)
expect(page.reload.attributes).to eq(page_state)
expect(description.reload.attributes).to eq(description_state)
expect(page.current_revision.id).to eq(current_revision_id)
end
end
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
it "rolls back native update when #{ model.name } persistence fails" do
headers
before_state = stored_state
aggregate_failures do
fail_save(model) { native_write(body: 'rollback update') }
expect(stored_state).to eq(before_state)
end
end
end
end
end
describe 'write access and validation' do
before { tag }
it 'requires authentication without changing either system' do
before_state = stored_state
native_write(body: 'unauthorised', request_headers: { })
expect(response).to have_http_status(:unauthorized)
expect(stored_state).to eq(before_state)
end
it 'rejects a guest without changing either system' do
guest = create(:user, :guest)
before_state = stored_state
native_write(body: 'forbidden',
request_headers: { 'X-Transfer-Code' => guest.inheritance_code })
expect(response).to have_http_status(:forbidden)
expect(stored_state).to eq(before_state)
end
it 'rejects a banned member without changing either system' do
banned = create(:user, :member, :banned)
before_state = stored_state
native_write(body: 'forbidden',
request_headers: { 'X-Transfer-Code' => banned.inheritance_code })
expect(response).to have_http_status(:forbidden)
expect(stored_state).to eq(before_state)
end
it 'rejects blank body using the existing validation error format' do
headers
before_state = stored_state
native_write(body: '')
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include('type' => 'validation_error',
'errors' => include('body' => a_kind_of(Array)))
expect(stored_state).to eq(before_state)
end
it 'returns 404 when PUT targets a missing Tag' do
missing_id = Tag.maximum(:id).to_i + 1
put "/tags/#{ missing_id }/description",
params: { body: 'missing' },
headers: headers
expect(response).to have_http_status(:not_found)
end
end
end
-139
ファイルの表示
@@ -1,139 +0,0 @@
require 'rails_helper'
RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :request do
include TagGroupRecords
let!(:user) { create_member_user! }
let!(:series) { group_tag('ぼざろクリーチャーシリーズ') }
let!(:deer) { group_tag('洗澡鹿') }
let!(:nijika) { group_tag('伊地知ニジカ') }
let!(:wanted) { group_tag('タグ希望') }
let!(:character) { group_tag('キャラクター名希望') }
let!(:music) { group_tag('楽曲情報希望') }
let!(:bot) { group_tag('bot操作') }
let!(:unknown) { group_tag('ニジラー情報不詳') }
let!(:child_id) { seed_group(name: '伊地知ニジカ亜種', description: '子の説明') }
before { I18n.locale = :ja }
def tag_node tag, children = []
{ 'tag_id' => tag.id, 'children' => children }
end
def tag_member tag
{ 'tag_id' => tag.id, 'version_no' => tag.version_no }
end
def check_round_trip placements:, members:, markdown:, description: nil
id = seed_group(name: '変換元', placements:, members:, description:)
without_group_legacy_reads { get group_title_path('変換元') }
expect(response).to have_http_status(:ok)
expect(json).to include('id' => id)
body = json.fetch('body')
expect(body).not_to include("\r")
expect(body.lines.map(&:chomp)).to all(satisfy { |line| line == line.rstrip })
# A trailing LF or a blank line between blocks is semantically irrelevant.
expect(body.lines.map(&:chomp).reject(&:empty?)).to eq(
markdown.lines.map(&:chomp).reject(&:empty?))
post '/wiki', params: { title: 'タググループ:再解析', body: },
headers: { 'X-Transfer-Code' => user.inheritance_code }
expect(response).to have_http_status(:created)
parsed = group_rows('tag_groups', name: '再解析').sole
expect_group_snapshot(parsed.fetch('id'), number: 1, placements:, members:, description:)
end
it 'renders a list Section under a Tag with two-space indentation' do
check_round_trip(
members: [series, deer].map { tag_member(_1) },
placements: [tag_node(series, [
{ 'section_name' => '伊地知ニジカ亜種', 'description' => nil,
'children' => [tag_node(deer)] }])],
markdown: <<~MARKDOWN)
- ぼざろクリーチャーシリーズ
- 伊地知ニジカ亜種:
- 洗澡鹿
MARKDOWN
end
it 'renders a child Tag Group distinctly from its parent Tags' do
check_round_trip(
members: [tag_member(series), tag_member(nijika),
{ 'tag_group_id' => child_id, 'version_no' => 1 }],
placements: [tag_node(series, [tag_node(nijika, [
{ 'tag_group_id' => child_id, 'children' => [] }])])],
markdown: <<~MARKDOWN)
- ぼざろクリーチャーシリーズ
- 伊地知ニジカ
- タググループ:伊地知ニジカ亜種
MARKDOWN
end
it 'renders top-level heading Sections, descriptions and ordered nested Tags' do
check_round_trip(
description: '全体の説明',
members: [wanted, character, music, bot, unknown].map { tag_member(_1) },
placements: [
{ 'section_name' => '要求', 'description' => '要求の説明',
'children' => [tag_node(wanted, [tag_node(character), tag_node(music)])] },
{ 'section_name' => '情報', 'description' => nil,
'children' => [tag_node(bot), tag_node(unknown)] }],
markdown: <<~MARKDOWN)
全体の説明
# 要求
要求の説明
- タグ希望
- キャラクター名希望
- 楽曲情報希望
# 情報
- bot操作
- ニジラー情報不詳
MARKDOWN
end
it 'renders nested heading Sections and all repeated Tag placements' do
check_round_trip(
members: [tag_member(deer)],
placements: [
{ 'section_name' => '親', 'description' => '親の説明', 'children' => [
tag_node(deer),
{ 'section_name' => '子', 'description' => '子の説明',
'children' => [tag_node(deer)] }] }],
markdown: <<~MARKDOWN)
# 親
親の説明
- 洗澡鹿
## 子
子の説明
- 洗澡鹿
MARKDOWN
end
it 'rejects mixed top-level heading Sections and Tags without losing structure' do
id = seed_group(
name: '混在構造', members: [tag_member(series)],
placements: [
{ 'section_name' => '区分', 'description' => nil, 'children' => [] },
tag_node(series)])
get group_title_path('混在構造')
expect(response).to have_http_status(:unprocessable_entity)
expect(group_rows('tag_groups', id:).sole.fetch('id')).to eq(id)
end
it 'rejects membership without a placement instead of omitting it' do
id = seed_group(name: '未配置構造', members: [tag_member(series)])
get group_title_path('未配置構造')
expect(response).to have_http_status(:unprocessable_entity)
expect(group_rows('tag_groups', id:).sole.fetch('id')).to eq(id)
end
end
-231
ファイルの表示
@@ -1,231 +0,0 @@
require 'rails_helper'
RSpec.describe 'Tag group Wiki facade reads', type: :request do
include TagGroupRecords
let!(:user) { create_member_user! }
let!(:old_tag) { group_tag('旧タグ') }
let!(:new_tag) { group_tag('新タグ') }
let!(:page) { legacy_group('読取り契約', '- 旧タグ') }
let!(:first_revision) { page.current_revision }
let!(:last_revision) do
Wiki::Commit.legacy_content!(page:, body: '- 新タグ', created_user: user)
end
before do
I18n.locale = :ja
migrate_groups
page.update_columns(body: 'legacy stale current')
page.wiki_revisions.each do |revision|
revision.wiki_lines.update_all(body: 'legacy stale revision')
end
page.wiki_versions.update_all(body: 'legacy stale version')
end
[:id, :title].each do |lookup|
context "with #{ lookup } lookup" do
let(:path) { lookup == :id ? "/wiki/#{ page.id }" : group_title_path('読取り契約') }
it 'reads current structure without consulting migrated Wiki tables' do
without_group_legacy_reads { get path }
expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => page.id, 'title' => 'タググループ:読取り契約',
'revision_id' => last_revision.id, 'pred' => first_revision.id, 'succ' => nil)
expect(json.fetch('body').strip).to eq('- 新タグ')
end
it 'reads the old snapshot rather than current membership or placement' do
without_group_legacy_reads { get path, params: { version: first_revision.id } }
expect(response).to have_http_status(:ok)
expect(json.fetch('body').strip).to eq('- 旧タグ')
expect(json).to include(
'revision_id' => first_revision.id, 'pred' => nil, 'succ' => last_revision.id,
'updated_at' => first_revision.created_at.iso8601(3))
end
it 'checks existence without consulting migrated Wiki tables' do
without_group_legacy_reads { get "#{ path }/exists" }
expect(response).to have_http_status(:no_content)
end
it 'does not fall back to legacy history when a group version is missing' do
group_connection.execute(
"DELETE FROM tag_group_versions WHERE id = #{ first_revision.id }")
without_group_legacy_reads { get path, params: { version: first_revision.id } }
expect(response).to have_http_status(:not_found)
end
end
end
['/wiki', '/wiki/search'].each do |path|
it "lists migrated groups once with current titles at #{ path }" do
get path, params: { title: 'タググループ:読取り' }
expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('title') == 'タググループ:読取り契約' }
expect(rows.size).to eq(1)
expect(rows.sole).to include('id' => page.id)
expect(response.body).not_to include('legacy stale')
end
end
it 'combines Tag Groups, Help and Tag Descriptions in the unfiltered list' do
help = Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:混在'), body: 'help', created_by_user: user)
TagDescriptionOperation.create!(tag: new_tag, body: 'tag description', created_by_user: user)
get '/wiki'
expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('title') }).to include(
'タググループ:読取り契約', help.title, '新タグ')
end
it 'keeps Help readable when its ID collides with a new group' do
help = Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:ID重複'), body: 'help body',
created_by_user: user)
seed_group(name: '別 ID重複', id: help.id, description: 'group body')
get '/wiki'
expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('title') }).to include(help.title, 'タググループ:別 ID重複')
get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
expect(response).to have_http_status(:ok)
expect(json.fetch('body')).to eq('help body')
get '/wiki/changes'
expect(response).to have_http_status(:ok)
expect(json.map { _1.dig('wiki_page', 'title') }).to include(
help.title, 'タググループ:別 ID重複')
end
it 'finds groups by a partial prefix of their displayed title' do
get '/wiki', params: { title: 'タググル' }
expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('title') }).to include('タググループ:読取り契約')
expect(json.find { _1.fetch('title') == 'タググループ:読取り契約' })
.to include('updated_at' => be_present)
end
it 'does not restore a stale namespaced Wiki page after a group rename' do
old_page = legacy_group('旧名称', '- 旧本文')
seed_group(name: '新名称', id: old_page.id, description: '正本')
get group_title_path('旧名称')
expect(response).to have_http_status(:not_found)
get "#{ group_title_path('旧名称') }/exists"
expect(response).to have_http_status(:not_found)
get group_title_path('新名称')
expect(response).to have_http_status(:ok)
expect(json.fetch('body').strip).to eq('正本')
end
it 'orders mixed resource history by timestamp rather than independent IDs' do
help = Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:時系列'), body: 'help',
created_by_user: user)
TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user)
TagGroupVersion.update_all(created_at: 3.days.ago)
WikiRevision.where(wiki_page_id: help.id).update_all(created_at: 2.days.ago)
TagDescriptionVersion.update_all(created_at: 1.day.ago)
get '/wiki/changes'
expect(response).to have_http_status(:ok)
timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) }
expect(timestamps).to eq(timestamps.sort.reverse)
titles = json.map { _1.dig('wiki_page', 'title') }
expect(titles.index('新タグ')).to be < titles.index(help.title)
expect(titles.index(help.title)).to be < titles.index('タググループ:読取り契約')
end
it 'renders nullable group editors without raising' do
group_connection.execute(
"UPDATE tag_groups SET created_by_user_id = NULL, updated_by_user_id = NULL " +
"WHERE id = #{ page.id }")
group_connection.execute(
"UPDATE tag_group_versions SET created_by_user_id = NULL " +
"WHERE tag_group_id = #{ page.id }")
get "/tag_groups/#{ page.id }"
expect(response).to have_http_status(:ok)
expect(json).to include('created_by_user' => nil, 'updated_by_user' => nil)
get "/wiki/title/#{ CGI.escapeURIComponent('タググループ:読取り契約') }"
expect(response).to have_http_status(:ok)
end
it 'reads group version IDs, editor and timestamps in scoped changes' do
without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } }
expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id])
expect(json.first).to include(
'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content',
'timestamp' => last_revision.created_at.iso8601(3),
'user' => { 'id' => user.id, 'name' => user.name },
'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約'))
end
it 'includes Help and group history but excludes legacy-only migrated revisions' do
extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user)
help = Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:変更一覧'), body: 'help', created_by_user: user)
get '/wiki/changes'
expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('wiki_page').fetch('title') == 'タググループ:読取り契約' }
expect(rows.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id])
expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id)
expect(json).to include(include(
'revision_id' => help.current_revision.id,
'wiki_page' => include('title' => help.title)))
end
[false, true].each do |explicit|
it "diffs structured snapshots with #{ explicit ? 'explicit' : 'current' } target" do
params = { from: first_revision.id }
params[:to] = last_revision.id if explicit
without_group_legacy_reads { get "/wiki/#{ page.id }/diff", params: }
expect(response).to have_http_status(:ok)
expect(json).to include(
'wiki_page_id' => page.id, 'older_revision_id' => first_revision.id,
'newer_revision_id' => last_revision.id)
differences = json.fetch('diff').map { [_1.fetch('type'), _1.fetch('content').strip] }
expect(differences).to include(['removed', '- 旧タグ'], ['added', '- 新タグ'])
end
end
it 'does not use a version belonging to another group' do
other_id = seed_group(name: '別グループ', description: '別本文')
other_version = group_version(other_id, 1)
get group_title_path('読取り契約'), params: { version: other_version.fetch('id') }
expect(response).to have_http_status(:not_found)
get "/wiki/#{ page.id }/diff",
params: { from: other_version.fetch('id'), to: last_revision.id }
expect(response).to have_http_status(:not_found)
end
it 'resolves a group without any compatibility Wiki page' do
id = seed_group(name: '独立グループ', description: '独立した説明')
without_group_legacy_reads { get group_title_path('独立グループ') }
expect(response).to have_http_status(:ok)
expect(json).to include('id' => id, 'title' => 'タググループ:独立グループ')
expect(json.fetch('body').strip).to eq('独立した説明')
end
it 'resolves colliding group, Help and description IDs by title' do
help = Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user)
seed_group(name: 'ID衝突', id: help.id, description: 'group')
TagDescription.create!(
id: help.id, tag: new_tag, language_code: 'ja', body: 'description', version_no: 1)
TagDescriptionVersion.create!(
tag_id: new_tag.id, language_code: 'ja', version_no: 1,
event_type: 'create', body: 'description', created_at: Time.current)
{ 'タググループ:ID衝突' => 'group', 'Help:ID衝突' => 'help', '新タグ' => 'description' }
.each do |title, body|
get "/wiki/title/#{ CGI.escapeURIComponent(title) }"
expect(response).to have_http_status(:ok)
expect(json.fetch('body').strip).to eq(body)
end
end
end
-245
ファイルの表示
@@ -1,245 +0,0 @@
require 'rails_helper'
RSpec.describe 'Tag group Wiki facade writes', type: :request do
include TagGroupRecords
include ActiveSupport::Testing::TimeHelpers
let!(:user) { create_member_user! }
let!(:tag) { group_tag('洗澡鹿') }
let!(:other_tag) { group_tag('伊地知ニジカ') }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
before { I18n.locale = :ja }
def create_group title: 'タググループ:新規', body: '- 洗澡鹿', request_headers: headers
post '/wiki', params: { title:, body: }, headers: request_headers
end
def expect_rejected_without_writes
before_state = group_state
yield
aggregate_failures do
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
expect(response.media_type).to eq('application/json')
expect(response.body).not_to be_empty
next if response.body.empty?
expect(json).to include(
'type' => 'validation_error', 'errors' => a_kind_of(Hash),
'base_errors' => a_kind_of(Array))
end
end
it 'creates the group, first snapshot and compatibility Wiki history' do
create_group
expect(response).to have_http_status(:created)
group = group_rows('tag_groups', name: '新規').sole
expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規')
expect_group_snapshot(group.fetch('id'), number: 1, members:, placements:)
version = group_version(group.fetch('id'), 1)
expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id)
expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id)
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:新規' })
expect(page.body.strip).to eq('- 洗澡鹿')
expect(page.current_revision.body).to eq(page.body)
expect(page.wiki_versions.sole.body).to eq(page.body)
# New IDs are independently allocated; no global identity assertion here.
end
it 'keeps repeated placements but only one membership and one snapshot member' do
create_group(body: "- 洗澡鹿\n# 再掲\n- 洗澡鹿")
expect(response).to have_http_status(:created)
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
expect_group_snapshot(
id, number: 1, members:,
placements: placements + [
{ 'section_name' => '再掲', 'description' => nil, 'children' => placements }])
end
it 'resolves a Japanese alias through tag_names.tag_id' do
create(:tag_name, :alias, tag:, name: '鹿の別名')
create_group(body: '- 鹿の別名')
expect(response).to have_http_status(:created)
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
expect_group_snapshot(id, number: 1, members:, placements:)
get group_title_path('新規')
expect(response).to have_http_status(:ok)
expect(json.fetch('body').strip).to eq('- 洗澡鹿')
end
{
'unknown Tag' => '- 解決不能',
'unknown Tag Group' => '- タググループ:存在しない',
'empty Section' => '- :',
'unsupported placement syntax' => "```\n洗澡鹿\n```",
'six levels' => "- 洗澡鹿\n - 洗澡鹿\n - 洗澡鹿\n - 洗澡鹿\n" +
" - 洗澡鹿\n - 洗澡鹿",
'blank body' => '',
'normalised blank body' => "\r\n\r\n" }.each do |label, body|
it "rejects #{ label } without fallback or partial create" do
expect_rejected_without_writes { create_group(body:) }
end
end
['', 'タググループ:', "タググループ:#{ '長' * 256 }"].each do |title|
it "rejects an invalid required title of length #{ title.length }" do
expect_rejected_without_writes { create_group(title:) }
end
end
it 'rejects a duplicate group name even without a matching legacy Wiki' do
seed_group(name: '新規', description: '既存')
expect_rejected_without_writes { create_group }
end
it 'accepts the existing five-level placement depth boundary' do
body = (0...5).map { "#{ ' ' * _1 }- 洗澡鹿" }.join("\n")
create_group(body:)
expect(response).to have_http_status(:created)
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
tree = []
5.times { tree = [{ 'tag_id' => tag.id, 'children' => tree }] }
expect_group_snapshot(id, number: 1, members:, placements: tree)
end
context 'updating a migrated group' do
let!(:page) { legacy_group('更新', '- 洗澡鹿') }
let!(:old_revision) { page.current_revision }
before { migrate_groups }
def update_group body: '- 伊地知ニジカ', title: 'タググループ:更新', request_headers: headers
put "/wiki/#{ page.id }", params: { title:, body: }, headers: request_headers
end
it 'replaces current membership and placement, retaining the old snapshot' do
old = group_version(page.id, 1)
update_group
expect(response).to have_http_status(:ok)
expect_group_snapshot(
page.id, number: 2,
members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }],
placements: [{ 'tag_id' => other_tag.id, 'children' => [] }])
expect(group_version(page.id, 1)).to eq(old)
expect(group_version(page.id, 2)).to include(
'event_type' => 'update', 'created_by_user_id' => user.id)
expect(page.reload.body.strip).to eq('- 伊地知ニジカ')
expect(page.wiki_revisions.count).to eq(2)
expect(page.wiki_versions.count).to eq(2)
expect(page.current_revision.body).to eq(page.body)
expect(page.wiki_versions.order(:version_no).last.body).to eq(page.body)
end
it 'accepts the current base_revision_id used by the existing frontend' do
put "/wiki/#{ page.id }",
params: { title: page.title, body: '- 伊地知ニジカ',
base_revision_id: old_revision.id }, headers: headers
expect(response).to have_http_status(:ok)
expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(2)
end
it 'ignores a stale base_revision_id without rejecting the update' do
update_group
expect(response).to have_http_status(:ok)
put "/wiki/#{ page.id }",
params: { title: page.title, body: '- 洗澡鹿',
base_revision_id: old_revision.id }, headers: headers
expect(response).to have_http_status(:ok)
expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(3)
expect(page.reload.body.strip).to eq('- 洗澡鹿')
end
it 'records the editor and update time' do
editor = create(:user, :member, name: '編輯者')
travel_to(Time.current + 1.hour) do
update_group(request_headers: { 'X-Transfer-Code' => editor.inheritance_code })
expect(response).to have_http_status(:ok)
current = group_rows('tag_groups', id: page.id).sole
version = group_version(page.id, 2)
expect(current).to include('updated_by_user_id' => editor.id)
expect(version).to include('created_by_user_id' => editor.id)
expect(current.fetch('updated_at')).to be_within(1.second).of(Time.current)
expect(version.fetch('created_at')).to be_within(1.second).of(Time.current)
end
end
['- 不明なタグ', '- タググループ:不明', '', '- :'].each do |body|
it "rejects invalid update #{ body.inspect } atomically" do
expect_rejected_without_writes { update_group(body:) }
end
end
it 'rejects direct self inclusion' do
expect_rejected_without_writes { update_group(body: '- タググループ:更新') }
end
it 'rejects indirect cyclic membership' do
seed_group(
name: '子', members: [{ 'tag_group_id' => page.id, 'version_no' => 1 }],
placements: [{ 'tag_group_id' => page.id, 'children' => [] }])
expect_rejected_without_writes { update_group(body: '- タググループ:子') }
end
it 'rejects duplicate rename without retaining a partial title or version' do
seed_group(name: '既存', description: '既存')
expect_rejected_without_writes { update_group(title: 'タググループ:既存') }
end
end
[:create, :update].each do |operation|
context "#{ operation } access and atomicity" do
let!(:page) do
next unless operation == :update
legacy = legacy_group('更新', '- 洗澡鹿')
seed_group(name: '更新', id: legacy.id, members:, placements:)
legacy
end
let!(:child_id) { seed_group(name: '子グループ', description: '子') }
def write_group operation, request_headers
body = "- 伊地知ニジカ\n- タググループ:子グループ"
if operation == :create
create_group(body:, request_headers:)
else
put "/wiki/#{ page.id }",
params: { title: 'タググループ:更新', body: }, headers: request_headers
end
end
[nil, :guest, :banned].each do |role|
it "rejects #{ role || 'anonymous' } without writes" do
actor = case role
when :guest then create(:user, :guest)
when :banned then create(:user, :member, :banned)
end
request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
before_state = group_state
write_group(operation, request_headers)
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
expect(group_state).to eq(before_state)
end
end
it 'rejects a banned IP even for a member' do
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
before_state = group_state
write_group(operation, headers)
expect(response).to have_http_status(:forbidden)
expect(group_state).to eq(before_state)
end
(TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions'])
.each do |table|
it "rolls back all writes after a real #{ table } write fails" do
expect_atomic_group_write(table) { write_group(operation, headers) }
end
end
end
end
end
-329
ファイルの表示
@@ -1,329 +0,0 @@
require 'rails_helper'
RSpec.describe 'Tag group write integrity', type: :request do
include TagGroupRecords
include ActiveSupport::Testing::TimeHelpers
let!(:user) { create_member_user! }
let!(:tag) { group_tag('洗澡鹿') }
let!(:other_tag) { group_tag('伊地知ニジカ') }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
before { I18n.locale = :ja }
def native_payload name: '更新', nodes: placements, member_rows: members
{ name:, description: nil, members: member_rows.map do |member|
member.except('version_no')
end, placements: nodes }
end
def put_wiki id, title:, body: '- 伊地知ニジカ', base: nil
put "/wiki/#{ id }", params: { title:, body:, base_revision_id: base }, headers: headers
end
def create_help body
Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user)
end
def expect_no_writes
before_state = group_state
yield
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
context 'resource identity' do
it 'rejects removing the prefix from a migrated group atomically' do
page = legacy_group('更新', '- 洗澡鹿')
seed_group(name: '更新', id: page.id, members:, placements:)
expect_no_writes do
put_wiki(page.id, title: '更新', base: page.current_revision.id)
end
end
it 'rejects an unprefixed update to an independent group' do
id = seed_group(name: '独立', members:, placements:)
expect_no_writes { put_wiki(id, title: '独立') }
end
it 'does not revive legacy writes when the compatibility title is stale' do
page = legacy_group('更新', '- 洗澡鹿')
seed_group(name: '更新', id: page.id, members:, placements:)
page.tag_name.update!(name: '古い別名')
expect_no_writes { put_wiki(page.id, title: '古い別名') }
end
it 'renames the group and its compatibility page through the operation' do
page = legacy_group('更新', '- 洗澡鹿')
seed_group(name: '更新', id: page.id, members:, placements:)
put_wiki(page.id, title: 'タググループ:改名')
expect(response).to have_http_status(:ok)
expect(TagGroup.find(page.id)).to have_attributes(name: '改名', version_no: 2)
expect(page.reload.title).to eq('タググループ:改名')
expect(page.body).to eq('- 伊地知ニジカ')
end
it 'updates Help with a colliding group ID without altering the group' do
page = create_help('Help body')
id = seed_group(name: '別グループ', id: page.id, members:, placements:)
group_before = TagGroup.find(id).attributes
version_before = group_version(id, 1)
put_wiki(id, title: 'Help:改名', body: 'Help after')
expect(response).to have_http_status(:ok)
expect(page.reload.body).to eq('Help after')
expect(page.title).to eq('Help:改名')
expect(TagGroup.find(id).attributes).to eq(group_before)
expect(group_version(id, 1)).to eq(version_before)
end
it 'rejects an ambiguous unprefixed rename when Help and group IDs collide' do
page = create_help('Help body')
seed_group(name: '更新', id: page.id, members:, placements:)
expect_no_writes { put_wiki(page.id, title: '更新') }
expect_no_writes do
put_wiki(page.id, title: '更新', base: page.current_revision.id)
end
end
it 'keeps a description update separate from a colliding group' do
page = Wiki::Commit.create_content!(
tag_name: tag.tag_name('ja'), body: 'description before', created_by_user: user)
id = seed_group(name: '更新', id: page.id, members:, placements:)
group_before = TagGroup.find(id).attributes
put_wiki(id, title: '洗澡鹿', body: 'description after')
expect(response).to have_http_status(:ok)
expect(TagDescription.find(id).body).to eq('description after')
expect(page.reload.body).to eq('description after')
expect(TagGroup.find(id).attributes).to eq(group_before)
end
it 'does not route a colliding Help write into an unrelated description' do
page = create_help('Help before')
id = seed_group(name: '更新', id: page.id, members:, placements:)
description = TagDescription.create!(
id:, tag:, language_code: 'ja', version_no: 1, body: 'description')
put_wiki(id, title: 'Help:既存', body: 'Help after')
expect(response).to have_http_status(:ok)
expect(page.reload.body).to eq('Help after')
expect(description.reload.body).to eq('description')
expect(TagGroup.find(id).version_no).to eq(1)
expect_no_writes { put_wiki(id, title: '洗澡鹿', body: 'description after') }
put_wiki(id, title: 'タググループ:更新')
expect(response).to have_http_status(:ok)
expect(TagGroup.find(id).version_no).to eq(2)
expect(page.reload.body).to eq('Help after')
expect(description.reload.body).to eq('description')
end
end
context 'compatibility synchronisation' do
it 'synchronises a native rename without modifying an ID-colliding Help page' do
help = create_help('Help before')
id = seed_group(name: '更新', id: help.id, members:, placements:)
help_before = help.attributes
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
expect(response).to have_http_status(:ok)
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:改名' })
expect(page.id).not_to eq(help.id)
expect(page.body).to eq('- 洗澡鹿')
expect(page.current_revision.body).to eq(page.body)
expect(page.wiki_versions.sole.body).to eq(page.body)
expect(help.reload.attributes).to eq(help_before)
end
['wiki_lines', 'wiki_revision_lines'].each do |table|
it "rolls back native and compatibility state on #{ table } failure" do
expect_atomic_group_write(table) do
post '/tag_groups', params: native_payload, headers:, as: :json
end
end
end
it 'rolls back a renderer failure instead of treating it as unsupported structure' do
allow(TagGroupMarkdownRenderer).to receive(:render).and_raise('renderer failure')
before_state = group_state
expect do
post '/tag_groups', params: native_payload, headers:, as: :json
end.to raise_error(RuntimeError, 'renderer failure')
expect(group_state).to eq(before_state)
end
it 'preserves unplaced membership and reports the skipped sync with its version' do
page = legacy_group('更新', '- 洗澡鹿')
id = seed_group(name: '更新', id: page.id, members:, placements:)
wiki_before = group_state.slice(*TagGroupRecords::WRITE_TABLES.drop(5))
warnings = []
allow(Rails.logger).to receive(:warn) { warnings << _1 }
put "/tag_groups/#{ id }",
params: native_payload(member_rows: members + [{ 'tag_id' => other_tag.id }]),
headers:, as: :json
expect(response).to have_http_status(:ok)
expect(json.fetch('members').map { _1.fetch('tag').fetch('id') })
.to contain_exactly(tag.id, other_tag.id)
expect(group_version(id, 2).fetch('members')).to include(
{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no })
expect(group_state.slice(*TagGroupRecords::WRITE_TABLES.drop(5))).to eq(wiki_before)
payload = JSON.parse(warnings.grep(/tag_group_wiki_sync_skipped/).sole.split(' ', 2).last)
expect(payload).to include(
'status' => 'unsupported_structure', 'tag_group_id' => id,
'version_no' => 2, 'user_id' => user.id,
'reason' => '掲載位置のない membership は Markdown にできません.')
end
it 'preserves a list Section description and records why no Wiki was created' do
nodes = [{ 'tag_id' => tag.id, 'children' => [
{ 'section_name' => '区分', 'description' => '構造化の説明', 'children' => [] }] }]
warnings = []
allow(Rails.logger).to receive(:warn) { warnings << _1 }
before_count = WikiPage.count
post '/tag_groups', params: native_payload(nodes:), headers:, as: :json
expect(response).to have_http_status(:created)
id = json.fetch('id')
expect(group_version(id, 1).fetch('placements')).to eq(nodes)
expect(json.fetch('placements').sole.fetch('children').sole)
.to include('description' => '構造化の説明')
expect(WikiPage.count).to eq(before_count)
expect(warnings.grep(/tag_group_wiki_sync_skipped/).sole)
.to include('リスト内 Section の説明文は Markdown にできません.')
end
end
context 'identifier Markdown' do
['[洗澡鹿](https://example.com)', '*洗澡鹿*', '**洗澡鹿**', '`洗澡鹿`',
'![洗澡鹿](https://example.com/image.png)',
'[タググループ:子](https://example.com)', '*区分*:',
'[区分](https://example.com):'].each do |label|
[:create, :update].each do |operation|
it "rejects decorated #{ label } on #{ operation } without writes" do
seed_group(name: '子', description: '子')
id = seed_group(name: '更新', members:, placements:)
expect_no_writes do
if operation == :create
post '/wiki', params: { title: 'タググループ:新規', body: "- #{ label }" },
headers: headers
else
put_wiki(id, title: 'タググループ:更新', body: "- #{ label }")
end
end
end
end
end
['# [区分](https://example.com)', '# *区分*', '# `区分`', '# 区分 {#custom}',
'# ![区分](https://example.com/image.png)'].each do |heading|
it "rejects a decorated heading #{ heading } without writes" do
expect_no_writes do
post '/wiki', params: { title: 'タググループ:新規', body: "#{ heading }\n- 洗澡鹿" },
headers: headers
end
end
end
it 'preserves allowed markup in group and heading descriptions' do
description = '[参照](https://example.com "リンクの説明") と *強調* と ``a`b``'
body = "#{ description }\n\n# 区分\n#{ description }\n\n- 洗澡鹿"
post '/wiki', params: { title: 'タググループ:説明構文', body: }, headers: headers
expect(response).to have_http_status(:created)
id = json.fetch('id')
version = group_version(id, 1)
expect(version.fetch('description')).to eq(description)
expect(version.fetch('placements').sole.fetch('description')).to eq(description)
end
end
context 'structure audit information' do
let!(:child_id) { seed_group(name: '子', description: '子') }
let(:members) do
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
{ 'tag_group_id' => child_id, 'version_no' => 1 }]
end
let(:placements) do
[{ 'tag_id' => tag.id, 'children' => [
{ 'tag_group_id' => child_id, 'children' => [] }] }]
end
let!(:id) { seed_group(name: '更新', members:, placements:) }
let!(:editor) { create(:user, :member) }
let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } }
it 'keeps all membership and placement audit fields on a name-only edit' do
before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements')
travel_to(1.hour.from_now) do
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
end
expect(response).to have_http_status(:ok)
expect(group_state.slice(*before_rows.keys)).to eq(before_rows)
expect(group_version(id, 2)).to include('name' => '改名', 'members' => members)
end
it 'keeps membership and placement audit fields on a description-only edit' do
before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements')
travel_to(1.hour.from_now) do
put "/tag_groups/#{ id }",
params: native_payload.merge(description: '説明を追加'), headers:, as: :json
end
expect(response).to have_http_status(:ok)
expect(group_state.slice(*before_rows.keys)).to eq(before_rows)
expect(group_version(id, 2)).to include('description' => '説明を追加', 'members' => members)
end
it 'rolls back membership removal and placement edits on a late sync failure' do
new_members = [{ 'tag_id' => tag.id }]
new_nodes = [{ 'tag_id' => tag.id, 'children' => [] }]
expect_atomic_group_write('wiki_versions') do
put "/tag_groups/#{ id }",
params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json
end
end
it 'changes only added or removed membership and keeps placement creation audit' do
tag_row = TagGroupTag.find([id, tag.id]).attributes
placement_row = TagGroupPlacement.find([id, 1]).attributes
new_members = [{ 'tag_id' => tag.id }, { 'tag_id' => other_tag.id }]
new_nodes = [{ 'tag_id' => tag.id, 'children' => [
{ 'tag_id' => other_tag.id, 'children' => [] }] }]
child_row = TagGroupPlacement.find([id, 2]).attributes
travel_to(1.hour.from_now) do
put "/tag_groups/#{ id }",
params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json
end
expect(response).to have_http_status(:ok)
expect(TagGroupTag.find([id, tag.id]).attributes).to eq(tag_row)
expect(TagGroupGroup.exists?(tag_group_id: id, child_tag_group_id: child_id)).to be(false)
expect(TagGroupTag.find([id, other_tag.id]).created_by_user_id).to eq(editor.id)
expect(TagGroupPlacement.find([id, 1]).attributes).to eq(placement_row)
changed = TagGroupPlacement.find([id, 2])
expect(changed).to have_attributes(
tag_id: other_tag.id, child_tag_group_id: nil, updated_by_user_id: editor.id,
created_at: child_row.fetch('created_at'),
created_by_user_id: child_row.fetch('created_by_user_id'))
expect(changed.updated_at).to be > child_row.fetch('updated_at')
expect(group_version(id, 1).fetch('members')).to eq(members)
end
end
end
-308
ファイルの表示
@@ -1,308 +0,0 @@
require 'rails_helper'
RSpec.describe 'Tag group native API', type: :request do
include TagGroupRecords
let!(:user) { create_member_user! }
let!(:group_id) { seed_group(name: '専用API', description: '説明') }
before { I18n.locale = :ja }
def expect_native_group value
expect(value).to include(
'id' => group_id, 'name' => '専用API', 'description' => '説明',
'version_no' => 1, 'members' => [], 'placements' => [],
'created_by_user' => UserRepr.base(user), 'updated_by_user' => UserRepr.base(user))
current = group_rows('tag_groups', id: group_id).sole
expect(value).to include(
'created_at' => current.fetch('created_at').iso8601(3),
'updated_at' => current.fetch('updated_at').iso8601(3))
end
it 'serves the collection as JSON without reading Wiki tables' do
without_group_legacy_reads { get '/tag_groups' }
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq('application/json')
expect(json).to be_a(Array)
expect_native_group(json.find { _1.fetch('id') == group_id })
end
it 'serves an independent group as JSON without reading Wiki tables' do
without_group_legacy_reads { get "/tag_groups/#{ group_id }" }
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq('application/json')
expect_native_group(json)
end
it 'provides the confirmed PUT update route' do
route = Rails.application.routes.recognize_path("/tag_groups/#{ group_id }", method: :put)
expect(route).to include(action: 'update', id: group_id.to_s)
end
[:create, :update].each do |operation|
[nil, :guest, :banned].each do |role|
it "rejects #{ role || 'anonymous' } native #{ operation } without writes" do
actor = case role
when :guest then create(:user, :guest)
when :banned then create(:user, :member, :banned)
end
headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
before_state = group_state
if operation == :create
post '/tag_groups', params: { }, headers:, as: :json
else
put "/tag_groups/#{ group_id }", params: { }, headers:, as: :json
end
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
expect(group_state).to eq(before_state)
end
end
it "rejects a banned IP for native #{ operation }" do
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
headers = { 'X-Transfer-Code' => user.inheritance_code }
before_state = group_state
if operation == :create
post '/tag_groups', params: { }, headers:, as: :json
else
put "/tag_groups/#{ group_id }", params: { }, headers:, as: :json
end
expect(response).to have_http_status(:forbidden)
expect(group_state).to eq(before_state)
end
end
context 'structured membership and placements' do
let!(:tag) { group_tag('掲載タグ') }
let!(:unplaced) { group_tag('未掲載タグ') }
let!(:child_id) { seed_group(name: '子', description: '子の説明') }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
let(:members) do
[{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }, { 'tag_id' => unplaced.id }]
end
let(:snapshot_members) do
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
{ 'tag_group_id' => child_id, 'version_no' => 1 },
{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }]
end
let(:placements) do
[{ 'section_name' => '区分', 'description' => '区分の説明', 'children' => [
{ 'tag_id' => tag.id, 'children' => [
{ 'tag_group_id' => child_id, 'children' => [] }] },
{ 'tag_id' => tag.id, 'children' => [] }] }]
end
let(:payload) { { name: '構造化', description: '全体の説明', members:, placements: } }
def native_write operation, attributes = payload
if operation == :create
post '/tag_groups', params: attributes, headers:, as: :json
else
put "/tag_groups/#{ group_id }", params: attributes, headers:, as: :json
end
end
def expect_structured_response
tag_repr = { 'id' => tag.id, 'name' => '掲載タグ', 'category' => tag.category }
unplaced_repr = { 'id' => unplaced.id, 'name' => '未掲載タグ',
'category' => unplaced.category }
expect(json.fetch('members')).to contain_exactly(
include('tag' => include(tag_repr)),
include('tag' => include(unplaced_repr)),
include('tag_group' => include('id' => child_id, 'name' => '子')))
expect(json.fetch('placements')).to match([
include('section_name' => '区分', 'description' => '区分の説明', 'children' => [
include('tag' => include(tag_repr), 'children' => [
include('tag_group' => include('id' => child_id, 'name' => '子'),
'children' => [])]),
include('tag' => include(tag_repr), 'children' => [])])])
end
[:create, :update].each do |operation|
it "#{ operation }s structured data and keeps unplaced membership" do
previous = group_version(group_id, 1)
native_write(operation)
expect(response).to have_http_status(operation == :create ? :created : :ok)
id = operation == :create ? json.fetch('id') : group_id
number = operation == :create ? 1 : 2
expect_group_snapshot(
id, number:, members: snapshot_members, placements:, description: '全体の説明')
expect(group_version(group_id, 1)).to eq(previous)
get "/tag_groups/#{ id }"
expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => id, 'name' => '構造化', 'description' => '全体の説明', 'version_no' => number,
'created_by_user' => UserRepr.base(user), 'updated_by_user' => UserRepr.base(user))
expect_structured_response
end
{
'blank name' => { name: '' },
'missing name' => { name: nil },
'oversized name' => { name: '長' * 256 },
'duplicate name' => { name: '子' },
'placement without a kind' => { placements: [{ children: [] }] },
'empty Section name' => { placements: [{ section_name: '', children: [] }] } }
.each do |label, invalid|
it "rejects #{ label } on #{ operation } without partial writes" do
before_state = group_state
native_write(operation, payload.merge(invalid))
aggregate_failures do
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
end
end
it "rejects multiple placement kinds on #{ operation }" do
before_state = group_state
invalid = [{ tag_id: tag.id, section_name: '不正', children: [] }]
native_write(operation, payload.merge(placements: invalid))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
it "rejects a missing referenced Tag on #{ operation }" do
missing = Tag.maximum(:id).to_i + 1
before_state = group_state
native_write(operation, payload.merge(
members: [{ tag_id: missing }], placements: [{ tag_id: missing, children: [] }]))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
it "rejects a missing referenced group on #{ operation }" do
missing = group_rows('tag_groups').map { _1.fetch('id') }.max + 1
before_state = group_state
native_write(operation, payload.merge(
members: [{ tag_group_id: missing }],
placements: [{ tag_group_id: missing, children: [] }]))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
it "rejects six descendant placement levels on #{ operation }" do
nodes = []
6.times { nodes = [{ tag_id: tag.id, children: nodes }] }
before_state = group_state
native_write(operation, payload.merge(placements: nodes))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
end
it 'preserves membership-only data in structured reads without inventing placements' do
id = seed_group(name: '未掲載のみ', members: [snapshot_members.last])
without_group_legacy_reads { get "/tag_groups/#{ id }" }
expect(response).to have_http_status(:ok)
expect(json.fetch('placements')).to eq([])
expect(json.fetch('members')).to match([
include('tag' => include('id' => unplaced.id, 'name' => '未掲載タグ'))])
end
it 'rejects indirect containment cycles' do
parent = seed_group(
name: '親', members: [{ 'tag_group_id' => group_id, 'version_no' => 1 }])
before_state = group_state
native_write(:update, payload.merge(
members: [{ tag_group_id: parent }], placements: []))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
it 'rejects direct containment even without a corresponding placement' do
before_state = group_state
native_write(:update, payload.merge(
members: [{ tag_group_id: group_id }], placements: []))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
it 'preserves list Section descriptions in native structured data' do
nodes = [{ tag_id: tag.id, children: [
{ section_name: 'リスト内区分', description: '消してはいけない説明', children: [] }] }]
native_write(:update, payload.merge(placements: nodes))
expect(response).to have_http_status(:ok)
expect(group_version(group_id, 2).fetch('placements')).to eq(
JSON.parse(nodes.to_json))
get "/tag_groups/#{ group_id }"
expect(response).to have_http_status(:ok)
section = json.fetch('placements').sole.fetch('children').sole
expect(section).to include(
'section_name' => 'リスト内区分', 'description' => '消してはいけない説明')
end
context 'compatibility writes for representable structures' do
let(:group_id) do
page = legacy_group('専用API', '説明')
seed_group(name: '専用API', id: page.id, description: '説明')
end
let(:members) { [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }] }
let(:snapshot_members) do
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
{ 'tag_group_id' => child_id, 'version_no' => 1 }]
end
[:create, :update].each do |operation|
it "synchronises Wiki current state and histories on native #{ operation }" do
native_write(operation)
expect(response).to have_http_status(operation == :create ? :created : :ok)
id = operation == :create ? json.fetch('id') : group_id
number = operation == :create ? 1 : 2
expect_group_snapshot(
id, number:, members: snapshot_members, placements:, description: '全体の説明')
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:構造化' })
expect(page.body).to include('全体の説明', '# 区分', '- 掲載タグ', '- タググループ:子')
expect(page.current_revision.body).to eq(page.body)
expect(page.wiki_versions.order(:version_no).last.body).to eq(page.body)
expect(page.wiki_revisions.count).to eq(number)
expect(page.wiki_versions.count).to eq(number)
get group_title_path('構造化')
expect(response).to have_http_status(:ok)
expect(json.fetch('body')).to eq(page.body)
end
(TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions'])
.each do |table|
it "rolls back native #{ operation } after #{ table } writes" do
expect_atomic_group_write(table) { native_write(operation) }
end
end
end
it 'uses AUTO_INCREMENT for a new group instead of supplying a compatibility ID' do
inserts = []
subscriber = lambda do |*args|
sql = args.last.fetch(:sql)
inserts << sql if sql.match?(/\A\s*INSERT INTO `?tag_groups`?\s/i)
end
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
native_write(:create)
end
expect(response).to have_http_status(:created)
expect(inserts.size).to eq(1)
columns = inserts.sole[/\((.*?)\)/, 1].split(',').map { _1.strip.delete('`') }
expect(columns).not_to include('id')
end
it 'updates a native group through Wiki and then reads structured current data' do
native_write(:create)
expect(response).to have_http_status(:created)
id = json.fetch('id')
previous = group_version(id, 1)
put "/wiki/#{ id }",
params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers
expect(response).to have_http_status(:ok)
expect_group_snapshot(
id, number: 2,
members: [{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }],
placements: [{ 'tag_id' => unplaced.id, 'children' => [] }])
expect(group_version(id, 1)).to eq(previous)
get "/tag_groups/#{ id }"
expect(response).to have_http_status(:ok)
expect(json).to include('version_no' => 2)
expect(json.fetch('members')).to match([
include('tag' => include('id' => unplaced.id, 'name' => '未掲載タグ'))])
end
end
end
end
+18 -8
ファイルの表示
@@ -25,10 +25,9 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
end end
describe 'PATCH /tags/:id' do describe 'PATCH /tags/:id' do
it 'renames the current Wiki resource without adding body history' do it 'records wiki_version when tag name changes and tag has wiki' do
tag = create_tag!(name: 'patch_tag_wiki_before') tag = create_tag!(name: 'patch_tag_wiki_before')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect { expect {
patch "/tags/#{ tag.id }", params: { patch "/tags/#{ tag.id }", params: {
@@ -36,17 +35,23 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
} }
} }
.to change(TagVersion, :count).by(2) .to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(0) .and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
tag.reload tag.reload
wiki_page.reload wiki_page.reload
version = wiki_page.wiki_versions.order(:version_no).last
expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_wiki_after') expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_wiki_after')
expect(wiki_page.title).to eq('patch_tag_wiki_after') expect(wiki_page.title).to eq('patch_tag_wiki_after')
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) expect(version).to have_attributes(
event_type: 'update',
title: 'patch_tag_wiki_after',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end end
it 'does not record wiki_version when only category changes' do it 'does not record wiki_version when only category changes' do
@@ -74,10 +79,9 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
end end
describe 'PUT /tags/:id' do describe 'PUT /tags/:id' do
it 'renames the current Wiki resource without adding body history' do it 'records wiki_version when tag name changes and tag has wiki' do
tag = create_tag!(name: 'put_tag_wiki_before') tag = create_tag!(name: 'put_tag_wiki_before')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect { expect {
put "/tags/#{ tag.id }", params: { put "/tags/#{ tag.id }", params: {
@@ -89,17 +93,23 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
} }
} }
.to change(TagVersion, :count).by(2) .to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(0) .and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
tag.reload tag.reload
wiki_page.reload wiki_page.reload
version = wiki_page.wiki_versions.order(:version_no).last
expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_wiki_after') expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_wiki_after')
expect(wiki_page.title).to eq('put_tag_wiki_after') expect(wiki_page.title).to eq('put_tag_wiki_after')
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) expect(version).to have_attributes(
event_type: 'update',
title: 'put_tag_wiki_after',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end end
it 'does not record wiki_version when only category changes' do it 'does not record wiki_version when only category changes' do
+35 -22
ファイルの表示
@@ -727,7 +727,7 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.reload.category).to eq('general') expect(tag.reload.category).to eq('general')
end end
it 'PATCH の名称変更で Wiki の本文履歴を追加しない' do it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do
wiki_page = wiki_page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: primary_tag_name_for(tag, 'ja'), tag_name: primary_tag_name_for(tag, 'ja'),
@@ -735,20 +735,24 @@ RSpec.describe 'Tags API', type: :request do
created_by_user: member_user, created_by_user: member_user,
message: 'init') message: 'init')
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect { expect {
patch "/tags/#{ tag.id }", params: { patch "/tags/#{ tag.id }", params: {
name: 'patch_wiki_renamed_tag', name: 'patch_wiki_renamed_tag',
} }
} }
.to change(TagVersion, :count).by(2) .to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(0) .and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(wiki_page.reload.title).to eq('patch_wiki_renamed_tag') version = wiki_page.reload.wiki_versions.order(:version_no).last
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
expect(version).to have_attributes(
event_type: 'update',
title: 'patch_wiki_renamed_tag',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end end
it 'tag の category だけを変更しても wiki version は作成しない' do it 'tag の category だけを変更しても wiki version は作成しない' do
@@ -1376,7 +1380,7 @@ RSpec.describe 'Tags API', type: :request do
expect(system_tag.category).to eq(old_category) expect(system_tag.category).to eq(old_category)
end end
it 'Wiki を持つ Tag の名称変更で本文履歴を追加しない' do it 'wiki を持つ tag を更新すると wiki version も作成する' do
wiki_page = wiki_page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: primary_tag_name_for(tag, 'ja'), tag_name: primary_tag_name_for(tag, 'ja'),
@@ -1391,8 +1395,6 @@ RSpec.describe 'Tags API', type: :request do
message: 'init' message: 'init'
) )
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect { expect {
put "/tags/#{ tag.id }", params: { put "/tags/#{ tag.id }", params: {
name: 'put_wiki_version_tag', name: 'put_wiki_version_tag',
@@ -1403,12 +1405,18 @@ RSpec.describe 'Tags API', type: :request do
} }
} }
.to change(TagVersion, :count).by(2) .to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(0) .and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(wiki_page.reload.title).to eq('put_wiki_version_tag') version = wiki_page.reload.wiki_versions.order(:version_no).last
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
expect(version).to have_attributes(
event_type: 'update',
title: 'put_wiki_version_tag',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end end
it 'wiki を持つ tag を旧 alias へ戻しても wiki を新しい正本名へ移す' do it 'wiki を持つ tag を旧 alias へ戻しても wiki を新しい正本名へ移す' do
@@ -1418,7 +1426,6 @@ RSpec.describe 'Tags API', type: :request do
body: 'wiki body before', body: 'wiki body before',
created_by_user: member_user, created_by_user: member_user,
message: 'init') message: 'init')
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect { expect {
put "/tags/#{ tag.id }", params: { put "/tags/#{ tag.id }", params: {
@@ -1438,7 +1445,7 @@ RSpec.describe 'Tags API', type: :request do
} }
} }
.to change(TagVersion, :count).by(3) .to change(TagVersion, :count).by(3)
.and change(WikiVersion, :count).by(0) .and change(WikiVersion, :count).by(2)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -1450,8 +1457,10 @@ RSpec.describe 'Tags API', type: :request do
expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b')) expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b'))
.to have_attributes(tag_id: tag.id, primary_flg: false) .to have_attributes(tag_id: tag.id, primary_flg: false)
expect(wiki_page.title).to eq('spec_tag') versions = wiki_page.wiki_versions.order(:version_no).last(2)
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
expect(versions.map(&:event_type)).to eq(['update', 'update'])
expect(versions.map(&:title)).to eq(['put_wiki_roundtrip_b', 'spec_tag'])
end end
it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do
@@ -1561,7 +1570,7 @@ RSpec.describe 'Tags API', type: :request do
expect(TagImplication.where(tag:, parent_tag: child)).not_to exist expect(TagImplication.where(tag:, parent_tag: child)).not_to exist
end end
it 'Tag の名称変更で Wiki の本文履歴を追加しない' do it 'tag の name を変更すると対応する wiki version を作成する' do
wiki_page = wiki_page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: primary_tag_name_for(tag, 'ja'), tag_name: primary_tag_name_for(tag, 'ja'),
@@ -1569,8 +1578,6 @@ RSpec.describe 'Tags API', type: :request do
created_by_user: member_user, created_by_user: member_user,
message: 'init') message: 'init')
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect { expect {
put "/tags/#{ tag.id }", params: { put "/tags/#{ tag.id }", params: {
name: 'put_wiki_renamed_tag', name: 'put_wiki_renamed_tag',
@@ -1581,12 +1588,18 @@ RSpec.describe 'Tags API', type: :request do
} }
} }
.to change(TagVersion, :count).by(2) .to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(0) .and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(wiki_page.reload.title).to eq('put_wiki_renamed_tag') version = wiki_page.reload.wiki_versions.order(:version_no).last
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
expect(version).to have_attributes(
event_type: 'update',
title: 'put_wiki_renamed_tag',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end end
end end
end end
+8 -10
ファイルの表示
@@ -21,7 +21,7 @@ RSpec.describe 'Wiki history integrity', type: :request do
expect { expect {
post '/wiki', post '/wiki',
params: { params: {
title: 'Help:wiki_history_create_atomic', title: 'wiki_history_create_atomic',
body: "a\nb\nc", body: "a\nb\nc",
message: 'initial commit', message: 'initial commit',
}, },
@@ -37,7 +37,7 @@ RSpec.describe 'Wiki history integrity', type: :request do
revision = page.current_revision revision = page.current_revision
version = page.wiki_versions.order(:version_no).last version = page.wiki_versions.order(:version_no).last
expect(page.title).to eq('Help:wiki_history_create_atomic') expect(page.title).to eq('wiki_history_create_atomic')
expect(page.body).to eq("a\nb\nc") expect(page.body).to eq("a\nb\nc")
expect(revision).to be_content expect(revision).to be_content
@@ -47,7 +47,7 @@ RSpec.describe 'Wiki history integrity', type: :request do
expect(version).to have_attributes( expect(version).to have_attributes(
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
title: 'Help:wiki_history_create_atomic', title: 'wiki_history_create_atomic',
body: "a\nb\nc", body: "a\nb\nc",
reason: 'initial commit', reason: 'initial commit',
created_by_user_id: user.id created_by_user_id: user.id
@@ -58,7 +58,7 @@ RSpec.describe 'Wiki history integrity', type: :request do
expect { expect {
post '/wiki', post '/wiki',
params: { params: {
title: 'Help:wiki_history_blank_body', title: 'wiki_history_blank_body',
body: "\r\n\r\n", body: "\r\n\r\n",
message: 'blank', message: 'blank',
}, },
@@ -67,17 +67,16 @@ RSpec.describe 'Wiki history integrity', type: :request do
.not_to change(WikiPage, :count) .not_to change(WikiPage, :count)
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
expect(WikiPage.joins(:tag_name) expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_blank_body' })).not_to exist
.where(tag_names: { name: 'Help:wiki_history_blank_body' })).not_to exist
end end
it 'returns 422 and creates no partial page when title already exists' do it 'returns 422 and creates no partial page when title already exists' do
create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first') create_wiki_page(title: 'wiki_history_duplicate_title', body: 'first')
expect { expect {
post '/wiki', post '/wiki',
params: { params: {
title: 'Help:wiki_history_duplicate_title', title: 'wiki_history_duplicate_title',
body: 'second', body: 'second',
message: 'duplicate', message: 'duplicate',
}, },
@@ -86,8 +85,7 @@ RSpec.describe 'Wiki history integrity', type: :request do
.not_to change(WikiPage, :count) .not_to change(WikiPage, :count)
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
expect(WikiPage.joins(:tag_name) expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_duplicate_title' }).count).to eq(1)
.where(tag_names: { name: 'Help:wiki_history_duplicate_title' }).count).to eq(1)
end end
end end
+14 -15
ファイルの表示
@@ -11,12 +11,6 @@ RSpec.describe 'Wiki API', type: :request do
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tn) { create(:tag_name, name: 'spec_wiki_title') } let!(:tn) { create(:tag_name, name: 'spec_wiki_title') }
let!(:tag) do
create(:tag,
primary_tag_name: tn,
category: :general,
deprecated_at: Time.zone.local(2026, 6, 1))
end
let!(:page) do let!(:page) do
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: tn, tag_name: tn,
@@ -24,6 +18,12 @@ RSpec.describe 'Wiki API', type: :request do
created_by_user: user, created_by_user: user,
message: 'init') message: 'init')
end end
let!(:tag) do
create(:tag,
primary_tag_name: tn,
category: :general,
deprecated_at: Time.zone.local(2026, 6, 1))
end
describe 'GET /wiki' do describe 'GET /wiki' do
it 'returns wiki pages with title' do it 'returns wiki pages with title' do
@@ -111,7 +111,7 @@ RSpec.describe 'Wiki API', type: :request do
context 'when success' do context 'when success' do
it 'creates wiki_page and first content revision' do it 'creates wiki_page and first content revision' do
expect do expect do
post endpoint, params: { title: 'Help:TestPage', body: "a\nb\nc", message: 'init' }, post endpoint, params: { title: 'TestPage', body: "a\nb\nc", message: 'init' },
headers: auth_headers(member) headers: auth_headers(member)
end end
.to change(WikiPage, :count).by(1) .to change(WikiPage, :count).by(1)
@@ -121,7 +121,7 @@ RSpec.describe 'Wiki API', type: :request do
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
page_id = json.fetch('id') page_id = json.fetch('id')
expect(json.fetch('title')).to eq('Help:TestPage') expect(json.fetch('title')).to eq('TestPage')
created_page = WikiPage.find(page_id) created_page = WikiPage.find(page_id)
version = created_page.wiki_versions.order(:version_no).last version = created_page.wiki_versions.order(:version_no).last
@@ -129,7 +129,7 @@ RSpec.describe 'Wiki API', type: :request do
expect(version).to have_attributes( expect(version).to have_attributes(
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
title: 'Help:TestPage', title: 'TestPage',
body: "a\nb\nc", body: "a\nb\nc",
created_by_user_id: member.id created_by_user_id: member.id
) )
@@ -151,7 +151,7 @@ RSpec.describe 'Wiki API', type: :request do
WikiLine.create!(sha256: Digest::SHA256.hexdigest('a'), body: 'a', created_at: Time.current, updated_at: Time.current) WikiLine.create!(sha256: Digest::SHA256.hexdigest('a'), body: 'a', created_at: Time.current, updated_at: Time.current)
post endpoint, post endpoint,
params: { title: 'Help:Reuse', body: "a\na" }, params: { title: 'Reuse', body: "a\na" },
headers: auth_headers(member) headers: auth_headers(member)
page = WikiPage.find(JSON.parse(response.body).fetch('id')) page = WikiPage.find(JSON.parse(response.body).fetch('id'))
@@ -166,7 +166,7 @@ RSpec.describe 'Wiki API', type: :request do
duplicated = 'duplicated_line_for_wiki_line_upsert_spec' duplicated = 'duplicated_line_for_wiki_line_upsert_spec'
post endpoint, post endpoint,
params: { title: 'Help:DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" }, params: { title: 'DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" },
headers: auth_headers(member) headers: auth_headers(member)
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
@@ -182,8 +182,7 @@ RSpec.describe 'Wiki API', type: :request do
it 'normalises CRLF and strips trailing newlines' do it 'normalises CRLF and strips trailing newlines' do
post endpoint, post endpoint,
params: { title: 'Help:NormalisedBody', params: { title: 'NormalisedBody', body: "a\r\nb\r\n\r\n", message: 'normalise' },
body: "a\r\nb\r\n\r\n", message: 'normalise' },
headers: auth_headers(member) headers: auth_headers(member)
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
@@ -414,10 +413,10 @@ RSpec.describe 'Wiki API', type: :request do
top = json.first top = json.first
expect(top).to include( expect(top).to include(
'revision_id' => rev2.id, 'revision_id' => rev2.id,
'pred' => rev1.id, 'pred' => rev2.base_revision_id,
'succ' => nil, 'succ' => nil,
'kind' => 'content', 'kind' => 'content',
'message' => nil 'message' => 'r2'
) )
expect(top['wiki_page']).to include( expect(top['wiki_page']).to include(
'id' => page.id, 'id' => page.id,
+2 -2
ファイルの表示
@@ -44,12 +44,12 @@ RSpec.describe 'Wiki title collision', type: :request do
end end
it 'returns 422 when creating wiki with existing title' do it 'returns 422 when creating wiki with existing title' do
create_wiki_page(title: 'Help:wiki_collision_create', body: 'already exists') create_wiki_page(title: 'wiki_collision_create', body: 'already exists')
expect { expect {
post '/wiki', post '/wiki',
params: { params: {
title: 'Help:wiki_collision_create', title: 'wiki_collision_create',
body: 'new body', body: 'new body',
message: 'duplicate create', message: 'duplicate create',
}, },
-115
ファイルの表示
@@ -1,115 +0,0 @@
require 'rails_helper'
RSpec.describe 'Unmigrated Wiki compatibility', type: :request do
let!(:user) { create_member_user! }
let!(:tag_name) { create(:tag_name, name: 'Help_contract') }
let!(:page) do
Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: user)
end
let!(:first_revision) { page.current_revision }
let!(:last_revision) do
Wiki::Commit.content!(page:, body: 'after', created_user: user,
message: 'legacy edit', base_revision_id: first_revision.id)
end
before { expect(TagDescription.exists?(id: page.id)).to be(false) }
it 'keeps a Tag-linked legacy page in the legacy domain when its title changes' do
create(:tag, primary_tag_name: tag_name)
expect do
put "/wiki/#{ page.id }",
params: { title: 'legacy_tag_renamed', body: 'changed legacy body',
message: 'legacy rename', base_revision_id: last_revision.id },
headers: { 'X-Transfer-Code' => user.inheritance_code }
end.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change(TagDescription, :count).by(0)
.and change(TagDescriptionVersion, :count).by(0)
expect(response).to have_http_status(:ok)
expect(page.reload).to have_attributes(body: 'changed legacy body')
expect(page.title).to eq('legacy_tag_renamed')
expect(page.current_revision).to have_attributes(message: 'legacy rename')
end
it 'shows legacy current and historical content by ID' do
get "/wiki/#{ page.id }"
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'after', 'revision_id' => last_revision.id,
'pred' => first_revision.id, 'succ' => nil)
get "/wiki/#{ page.id }", params: { version: first_revision.id }
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'before', 'revision_id' => first_revision.id,
'pred' => nil, 'succ' => last_revision.id)
end
it 'shows legacy current and historical content by title' do
get "/wiki/title/#{ tag_name.name }"
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'after', 'revision_id' => last_revision.id)
get "/wiki/title/#{ tag_name.name }", params: { version: first_revision.id }
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'before', 'revision_id' => first_revision.id)
end
it 'recognises legacy IDs' do
get "/wiki/#{ page.id }/exists"
expect(response).to have_http_status(:no_content)
end
it 'recognises legacy titles' do
get "/wiki/title/#{ tag_name.name }/exists"
expect(response).to have_http_status(:no_content)
end
it 'diffs legacy revisions with explicit and current target' do
[last_revision.id, nil].each do |to|
get "/wiki/#{ page.id }/diff", params: { from: first_revision.id, to: }
expect(response).to have_http_status(:ok)
expect(json).to include(
'wiki_page_id' => page.id, 'title' => tag_name.name,
'older_revision_id' => first_revision.id, 'newer_revision_id' => last_revision.id,
'diff' => [{ 'type' => 'removed', 'content' => 'before' },
{ 'type' => 'added', 'content' => 'after' }])
end
end
describe 'revision resource scope' do
let!(:other_page) do
Wiki::Commit.create_content!(
tag_name: create(:tag_name, name: 'Help_other_contract'),
body: 'other page history', created_by_user: user)
end
let(:other_revision) { other_page.current_revision }
it 'returns 404 when show specifies another page revision' do
get "/wiki/#{ page.id }", params: { version: other_revision.id }
expect(response).to have_http_status(:not_found)
end
it 'returns 404 when diff from belongs to another page' do
get "/wiki/#{ page.id }/diff",
params: { from: other_revision.id, to: last_revision.id }
expect(response).to have_http_status(:not_found)
end
it 'returns 404 when diff to belongs to another page' do
get "/wiki/#{ page.id }/diff",
params: { from: first_revision.id, to: other_revision.id }
expect(response).to have_http_status(:not_found)
end
end
it 'retains legacy history metadata and ordering' do
get '/wiki/changes', params: { id: page.id }
expect(response).to have_http_status(:ok)
expect(json.map { |row| row.fetch('revision_id') }).to eq(
[last_revision.id, first_revision.id])
expect(json.first).to include(
'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content',
'message' => 'legacy edit', 'timestamp' => last_revision.created_at.iso8601(3),
'wiki_page' => { 'id' => page.id, 'title' => tag_name.name, 'deprecated_at' => nil },
'user' => { 'id' => user.id, 'name' => user.name })
end
end
-189
ファイルの表示
@@ -1,189 +0,0 @@
require 'cgi'
# SQL fixtures use the migration contract without inventing application models.
# Include explicitly; these helpers must not affect unrelated examples.
module TagGroupRecords
GROUP_TABLES = [
'tag_groups', 'tag_group_tags', 'tag_group_groups',
'tag_group_placements', 'tag_group_versions'].freeze
WRITE_TABLES = GROUP_TABLES + [
'wiki_pages', 'wiki_revisions', 'wiki_versions', 'wiki_lines',
'wiki_revision_lines', 'tag_names', 'tag_description_versions',
'tag_descriptions']
def group_connection = ApplicationRecord.connection
def group_rows table, conditions = { }
clauses = conditions.map do |column, value|
"#{ group_connection.quote_column_name(column) } = #{ group_connection.quote(value) }"
end
sql = "SELECT * FROM #{ group_connection.quote_table_name(table) }"
sql += " WHERE #{ clauses.join(' AND ') }" if clauses.any?
group_connection.select_all(sql).to_a.sort_by { |row| row.values.map(&:to_s) }
end
def insert_group_row table, attributes
columns = attributes.keys.map { group_connection.quote_column_name(_1) }.join(', ')
values = attributes.values.map { group_connection.quote(_1) }.join(', ')
group_connection.insert(
"INSERT INTO #{ group_connection.quote_table_name(table) } (#{ columns }) " +
"VALUES (#{ values })")
end
def group_state
WRITE_TABLES.to_h { |table| [table, group_rows(table)] }
end
def expect_atomic_group_write table
before_state = group_state
failure = Class.new(StandardError)
injected = false
subscriber = lambda do |*args|
sql = args.last.fetch(:sql)
next unless sql.match?(/\A\s*(?:INSERT INTO|UPDATE)\s+`?#{ table }`?\s/i)
injected = true
raise failure, 'injected after SQL write'
end
begin
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
rescue failure
# Inspect the database after the operation's transaction unwinds.
end
aggregate_failures do
expect(injected).to be(true), "No INSERT/UPDATE reached #{ table }"
expect(group_state).to eq(before_state)
end
end
def group_version group_id, number
row = group_rows('tag_group_versions', tag_group_id: group_id, version_no: number).sole
row.merge('members' => JSON.parse(row.fetch('members')),
'placements' => JSON.parse(row.fetch('placements')))
end
def group_tag name
tag = create(:tag, primary_name: name)
TagVersioning.ensure_snapshot!(tag, created_by_user: user)
tag.reload
end
def legacy_group name, body
# Historical fixtures preserve blank lines independently of today's writer.
body = body.sub(/\n+\z/, '')
page = WikiPage.create!(
tag_name: create(:tag_name, name: "タググループ:#{ name }"),
body:, created_user: user, updated_user: user)
WikiVersionRecorder.record!(page:, event_type: :create, created_by_user: user)
lines = body.split("\n")
shas = lines.map { Digest::SHA256.hexdigest(_1) }
revision = WikiRevision.create!(
wiki_page: page, kind: :content, created_user: user, lines_count: lines.size,
tree_sha256: Digest::SHA256.hexdigest(shas.join(',')))
lines.each_with_index do |line, position|
sha = shas.fetch(position)
line_id = WikiLine.find_by(sha256: sha)&.id || insert_group_row('wiki_lines', {
body: line, sha256: sha, created_at: Time.current, updated_at: Time.current })
WikiRevisionLine.create!(wiki_revision: revision, wiki_line_id: line_id, position:)
end
page
end
def seed_group name:, placements: [], members: [], description: nil, id: nil
now = Time.current.change(usec: 0)
attributes = {
name:, description:, version_no: 1,
created_by_user_id: user.id, updated_by_user_id: user.id,
created_at: now, updated_at: now }
attributes[:id] = id if id
group_id = insert_group_row('tag_groups', attributes)
insert_group_row('tag_group_versions', {
tag_group_id: group_id, version_no: 1, event_type: 'create',
name:, description:, members: members.to_json, placements: placements.to_json,
created_by_user_id: user.id, created_at: now })
members.each do |member|
tag_member = member.key?('tag_id')
table = tag_member ? 'tag_group_tags' : 'tag_group_groups'
key = tag_member ? :tag_id : :child_tag_group_id
value = member.fetch(tag_member ? 'tag_id' : 'tag_group_id')
insert_group_row(table, {
tag_group_id: group_id, key => value,
created_by_user_id: user.id, created_at: now })
end
position = 0
append = lambda do |nodes, parent|
nodes.each do |node|
position += 1
own_position = position
insert_group_row('tag_group_placements', {
tag_group_id: group_id, position:, parent_position: parent,
section_name: node['section_name'], description: node['description'],
tag_id: node['tag_id'], child_tag_group_id: node['tag_group_id'],
created_by_user_id: user.id, updated_by_user_id: user.id,
created_at: now, updated_at: now })
append.call(node.fetch('children'), own_position)
end
end
append.call(placements, nil)
group_id
end
def without_group_legacy_reads
queries = []
subscriber = lambda do |*args|
sql = args.last.fetch(:sql)
if (sql.match?(/\bSELECT\b/i) &&
sql.match?(/\bwiki_(pages|revisions|versions|lines|revision_lines)\b/i))
queries << sql
end
end
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
expect(queries).to be_empty
end
def group_title_path name
"/wiki/title/#{ CGI.escapeURIComponent("タググループ:#{ name }") }"
end
def migrate_groups
require_relative '../../db/migrate/20261008010000_migrate_tag_groups_from_wiki_pages'
migration = MigrateTagGroupsFromWikiPages.new
@group_migration_messages = []
allow(migration).to receive(:say) do |message, *_args|
@group_migration_messages << message if message.start_with?('Wiki #', 'Tag Group')
end
migration.suppress_messages { migration.up }
end
def expect_group_snapshot group_id, number:, placements:, members:, description: nil
current = group_rows('tag_groups', id: group_id).sole
version = group_version(group_id, number)
expect(current).to include('version_no' => number, 'description' => description),
@group_migration_messages&.join("\n")
expect(version).to include(
'name' => current.fetch('name'), 'description' => description,
'version_no' => number, 'placements' => placements)
expect(version.fetch('members')).to match_array(members)
expect(group_rows('tag_group_tags', tag_group_id: group_id).map { _1.fetch('tag_id') })
.to match_array(members.filter_map { _1['tag_id'] })
children = group_rows('tag_group_groups', tag_group_id: group_id)
expect(children.map { _1.fetch('child_tag_group_id') })
.to match_array(members.filter_map { _1['tag_group_id'] })
rows = group_rows('tag_group_placements', tag_group_id: group_id)
tree = lambda do |parent|
rows.select { _1['parent_position'] == parent }.sort_by { _1.fetch('position') }
.map do |row|
node = if row['tag_id']
{ 'tag_id' => row.fetch('tag_id') }
elsif row['child_tag_group_id']
{ 'tag_group_id' => row.fetch('child_tag_group_id') }
else
{ 'section_name' => row.fetch('section_name'), 'description' => row['description'] }
end
node.merge('children' => tree.call(row.fetch('position')))
end
end
expect(tree.call(nil)).to eq(placements)
end
end