コミットを比較

..
8 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 694ed60431 #411 2026-09-21 05:56:38 +09:00
みてるぞ 493ca749b4 #411 2026-09-21 05:46:02 +09:00
みてるぞ b217a307fe #411 2026-09-21 04:55:02 +09:00
みてるぞ 4b99743435 #411 化石ロジックを削除 2026-09-21 04:07:18 +09:00
みてるぞ 4fd95b74f1 #411 2026-09-21 03:57:16 +09:00
みてるぞ 3a5d79b5d7 #411 2026-09-21 03:49:46 +09:00
みてるぞ 6e33c5f192 #411 2026-09-21 03:30:22 +09:00
みてるぞ 2425f1af1f #411 2026-09-21 02:55:00 +09:00
15個のファイルの変更69行の追加388行の削除
+1 -1
ファイルの表示
@@ -237,7 +237,7 @@ class MaterialsController < ApplicationController
end end
def resolve_material_tag! tag_name_raw def resolve_material_tag! tag_name_raw
tag_name = TagName.find_or_create_by!(name: tag_name_raw) tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag tag = tag_name.tag
tag || Tag.create!(tag_name:, category: :material) tag || Tag.create!(tag_name:, category: :material)
end end
+11 -6
ファイルの表示
@@ -573,7 +573,7 @@ class TagsController < ApplicationController
return false return false
end end
target_tag_name = TagName.find_by(name:) target_tag_name = TagName.with_discarded.find_by(name:)
return true if target_tag_name.nil? return true if target_tag_name.nil?
return true if target_tag_name.canonical_id? return true if target_tag_name.canonical_id?
@@ -585,14 +585,17 @@ class TagsController < ApplicationController
return if name == tag.name return if name == tag.name
current_tag_name = tag.tag_name current_tag_name = tag.tag_name
target_tag_name = TagName.find_by(name:) target_tag_name = TagName.with_discarded.find_by(name:)
if target_tag_name.nil? if target_tag_name.nil?
current_tag_name.update!(name:) current_tag_name.update!(name:)
return return
end end
promote_tag_alias!(tag, current_tag_name:, promoted_tag_name: target_tag_name) promote_tag_alias!(
tag,
current_tag_name:,
promoted_tag_name: target_tag_name)
end end
def promote_tag_alias! tag, current_tag_name:, promoted_tag_name: def promote_tag_alias! tag, current_tag_name:, promoted_tag_name:
@@ -602,9 +605,11 @@ class TagsController < ApplicationController
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user) TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
end end
promoted_tag_name.undiscard! if promoted_tag_name.discarded?
promoted_tag_name.update!(canonical: nil) promoted_tag_name.update!(canonical: nil)
TagName.where(canonical_id: current_tag_name.id) TagName.with_discarded
.where(canonical_id: current_tag_name.id)
.where.not(id: promoted_tag_name.id) .where.not(id: promoted_tag_name.id)
.find_each do |alias_tag_name| .find_each do |alias_tag_name|
alias_tag_name.update!(canonical: promoted_tag_name) alias_tag_name.update!(canonical: promoted_tag_name)
@@ -635,7 +640,7 @@ class TagsController < ApplicationController
end end
alias_names.each do |alias_name| alias_names.each do |alias_name|
alias_tag_name = TagName.find_or_create_by!(name: alias_name) alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
affected_tags << alias_tag_name.canonical&.tag affected_tags << alias_tag_name.canonical&.tag
end end
@@ -650,7 +655,7 @@ class TagsController < ApplicationController
end end
alias_names.each do |alias_name| alias_names.each do |alias_name|
alias_tag_name = TagName.find_or_create_by!(name: alias_name) alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
alias_tag_name.update!(canonical: tag.tag_name) alias_tag_name.update!(canonical: tag.tag_name)
end end
+1 -1
ファイルの表示
@@ -94,7 +94,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?
tag_name = TagName.find_or_create_by!(name: title) tag_name = TagName.find_undiscard_or_create_by!(name: title)
page = page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
-3
ファイルの表示
@@ -63,19 +63,16 @@ class Post < ApplicationRecord
has_many :user_post_views, dependent: :delete_all has_many :user_post_views, dependent: :delete_all
has_many :post_similarities, dependent: :delete_all has_many :post_similarities, dependent: :delete_all
has_many :post_versions has_many :post_versions
has_many :gekanator_guessed_games, has_many :gekanator_guessed_games,
class_name: 'GekanatorGame', class_name: 'GekanatorGame',
foreign_key: :guessed_post_id, foreign_key: :guessed_post_id,
dependent: :delete_all, dependent: :delete_all,
inverse_of: :guessed_post inverse_of: :guessed_post
has_many :gekanator_correct_games, has_many :gekanator_correct_games,
class_name: 'GekanatorGame', class_name: 'GekanatorGame',
foreign_key: :correct_post_id, foreign_key: :correct_post_id,
dependent: :delete_all, dependent: :delete_all,
inverse_of: :correct_post inverse_of: :correct_post
has_many :gekanator_question_examples, dependent: :delete_all has_many :gekanator_question_examples, dependent: :delete_all
has_many :parent_post_implications, has_many :parent_post_implications,
+7 -5
ファイルの表示
@@ -2,6 +2,8 @@ require 'set'
class Tag < ApplicationRecord class Tag < ApplicationRecord
include MyDiscard
class NicoTagNormalisationError < ArgumentError class NicoTagNormalisationError < ArgumentError
; ;
end end
@@ -230,10 +232,10 @@ class Tag < ApplicationRecord
end end
def self.find_or_create_by_tag_name! name, category: def self.find_or_create_by_tag_name! name, category:
tn = TagName.find_or_create_by!(name: name.to_s.strip) tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
tn = tn.canonical if tn.canonical_id? tn = tn.canonical if tn.canonical_id?
Tag.find_or_create_by!(tag_name_id: tn.id) do |t| Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do |t|
t.category = category t.category = category
end end
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
@@ -271,10 +273,10 @@ class Tag < ApplicationRecord
end end
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
source_tag.destroy! source_tag.discard!
if source_tag.nico? if source_tag.nico?
source_tag_name.destroy! source_tag_name.discard!
else else
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current) updated_at: Time.current)
@@ -295,7 +297,7 @@ class Tag < ApplicationRecord
target_tag.reload target_tag.reload
end end
def snapshot_aliases = tag_name.aliases.order(:name).pluck(:name) def snapshot_aliases = tag_name.aliases.kept.order(:name).pluck(:name)
def snapshot_parent_tag_ids = parents.order(:id).pluck(:id) def snapshot_parent_tag_ids = parents.order(:id).pluck(:id)
+2
ファイルの表示
@@ -1,4 +1,6 @@
class TagName < ApplicationRecord class TagName < ApplicationRecord
include MyDiscard
has_one :tag has_one :tag
has_one :wiki_page has_one :wiki_page
+1 -1
ファイルの表示
@@ -32,7 +32,7 @@ class TagNameSanitisationRule < ApplicationRecord
elsif source_tag elsif source_tag
source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current) source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current)
end end
tn.destroy! tn.discard!
next next
end end
-46
ファイルの表示
@@ -1,46 +0,0 @@
class DeleteDiscardedRecordsFromTags < ActiveRecord::Migration[8.0]
def up
remove_foreign_key :tag_versions, :tags, column: :tag_id
remove_foreign_key :nico_tag_versions, :tags, column: :tag_id
remove_foreign_key :material_versions, :tags, column: :tag_id
execute <<~SQL
DELETE
ntr
FROM
nico_tag_relations ntr
INNER JOIN
tags t
ON
t.discarded_at IS NOT NULL
AND t.id IN (ntr.tag_id, ntr.nico_tag_id)
SQL
execute <<~SQL
DELETE
ti
FROM
tag_implications ti
INNER JOIN
tags t
ON
t.discarded_at IS NOT NULL
AND t.id IN (ti.tag_id, ti.parent_tag_id)
SQL
execute <<~SQL
DELETE
FROM
tags
WHERE
discarded_at IS NOT NULL
SQL
remove_index :tags, :discarded_at
remove_column :tags, :discarded_at
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
-18
ファイルの表示
@@ -1,18 +0,0 @@
class DeleteDiscardedRecordsFromTagNames < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE
FROM
tag_names
WHERE
discarded_at IS NOT NULL
SQL
remove_index :tag_names, :discarded_at
remove_column :tag_names, :discarded_at
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
生成ファイル
+8 -1
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do ActiveRecord::Schema[8.0].define(version: 2026_09_21_010000) 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
@@ -409,7 +409,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do
t.bigint "canonical_id" t.bigint "canonical_id"
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.index ["canonical_id"], name: "index_tag_names_on_canonical_id" t.index ["canonical_id"], name: "index_tag_names_on_canonical_id"
t.index ["discarded_at"], name: "index_tag_names_on_discarded_at"
t.index ["name"], name: "index_tag_names_on_name", unique: true t.index ["name"], name: "index_tag_names_on_name", unique: true
end end
@@ -446,8 +448,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) 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.integer "post_count", default: 0, null: false t.integer "post_count", default: 0, null: false
t.datetime "discarded_at"
t.integer "version_no", null: false t.integer "version_no", null: false
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at" t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
t.index ["discarded_at"], name: "index_tags_on_discarded_at"
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico" t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive" t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
@@ -694,6 +698,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do
add_foreign_key "material_sync_suppressions", "users", column: "created_by_user_id" add_foreign_key "material_sync_suppressions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "materials" add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id" add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags"
add_foreign_key "material_versions", "users", column: "created_by_user_id" add_foreign_key "material_versions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "users", column: "updated_by_user_id" add_foreign_key "material_versions", "users", column: "updated_by_user_id"
add_foreign_key "materials", "materials", column: "parent_id" add_foreign_key "materials", "materials", column: "parent_id"
@@ -702,6 +707,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do
add_foreign_key "materials", "users", column: "updated_by_user_id" add_foreign_key "materials", "users", column: "updated_by_user_id"
add_foreign_key "nico_tag_relations", "tags" add_foreign_key "nico_tag_relations", "tags"
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
add_foreign_key "nico_tag_versions", "tags"
add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id" add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id"
add_foreign_key "post_implications", "posts" add_foreign_key "post_implications", "posts"
add_foreign_key "post_implications", "posts", column: "parent_post_id" add_foreign_key "post_implications", "posts", column: "parent_post_id"
@@ -720,6 +726,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do
add_foreign_key "tag_names", "tag_names", column: "canonical_id" add_foreign_key "tag_names", "tag_names", column: "canonical_id"
add_foreign_key "tag_similarities", "tags" add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id" add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
add_foreign_key "tag_versions", "tags"
add_foreign_key "tag_versions", "users", column: "created_by_user_id" add_foreign_key "tag_versions", "users", column: "created_by_user_id"
add_foreign_key "tags", "tag_names" add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres" add_foreign_key "theatre_comments", "theatres"
-123
ファイルの表示
@@ -1,123 +0,0 @@
require 'rails_helper'
require_relative '../../db/migrate/20260921020000_delete_discarded_records_from_tags'
require_relative '../../db/migrate/20260921030000_delete_discarded_records_from_tag_names'
RSpec.describe 'discarded tag cleanup migrations' do
[DeleteDiscardedRecordsFromTags, DeleteDiscardedRecordsFromTagNames].each do |migration_class|
it "rejects rollback of #{ migration_class.name }" do
expect { migration_class.new.down }
.to raise_error(ActiveRecord::IrreversibleMigration)
end
end
context 'with legacy records' do
self.use_transactional_tests = false
before do
record_class = Class.new(ActiveRecord::Base) do
self.abstract_class = true
end
stub_const('TagCleanupMigrationRecord', record_class)
config = ActiveRecord::Base.connection_db_config.configuration_hash
@database = "btrc_hub_test_tag_cleanup_#{ Process.pid }_#{ SecureRandom.hex(4) }"
record_class.establish_connection(config.merge(database: nil))
@connection = record_class.lease_connection
@connection.create_database(@database)
@database_created = true
@connection.execute("USE #{ @connection.quote_table_name(@database) }")
end
after do
@connection.drop_database(@database) if @database_created
ensure
TagCleanupMigrationRecord.remove_connection
end
before do
@connection.create_table(:tag_names) do |t|
t.string :name, null: false, index: { unique: true }
t.bigint :canonical_id
t.datetime :discarded_at, index: true
end
@connection.add_foreign_key(:tag_names, :tag_names, column: :canonical_id)
@connection.create_table(:tags) do |t|
t.references :tag_name, null: false, foreign_key: true, index: { unique: true }
t.datetime :discarded_at, index: true
end
@connection.create_table(:nico_tag_relations) do |t|
t.references :tag, null: false, foreign_key: true
t.references :nico_tag, null: false, foreign_key: { to_table: :tags }
end
@connection.create_table(:tag_implications) do |t|
t.references :tag, null: false, foreign_key: true
t.references :parent_tag, null: false, foreign_key: { to_table: :tags }
end
[:tag_versions, :nico_tag_versions, :material_versions].each do |table|
@connection.create_table(table) do |t|
t.references :tag, null: false, foreign_key: true
end
end
@connection.execute(<<~SQL)
INSERT INTO tag_names (id, name, canonical_id, discarded_at) VALUES
(1, 'kept', NULL, NULL),
(2, 'merged_alias', 1, NULL),
(3, 'nico:deleted', NULL, '2026-09-20'),
(4, 'nico:kept', NULL, NULL),
(5, 'deleted_name', NULL, '2026-09-20')
SQL
@connection.execute(<<~SQL)
INSERT INTO tags (id, tag_name_id, discarded_at) VALUES
(1, 1, NULL), (2, 2, '2026-09-20'),
(3, 3, '2026-09-20'), (4, 4, NULL)
SQL
@connection.execute(<<~SQL)
INSERT INTO nico_tag_relations (id, tag_id, nico_tag_id) VALUES
(1, 1, 4), (2, 2, 4), (3, 1, 3), (4, 2, 3)
SQL
@connection.execute(<<~SQL)
INSERT INTO tag_implications (id, tag_id, parent_tag_id) VALUES
(1, 1, 4), (2, 2, 1), (3, 1, 2), (4, 2, 3)
SQL
@connection.execute('INSERT INTO tag_versions (tag_id) VALUES (1), (2)')
@connection.execute('INSERT INTO nico_tag_versions (tag_id) VALUES (3), (4)')
@connection.execute('INSERT INTO material_versions (tag_id) VALUES (1), (2)')
end
it 'removes discarded records and their links while retaining aliases and history' do
[DeleteDiscardedRecordsFromTags, DeleteDiscardedRecordsFromTagNames].each do |klass|
migration = klass.new
allow(migration).to receive(:connection).and_return(@connection)
migration.suppress_messages { migration.up }
end
expect(@connection.select_values('SELECT id FROM tags ORDER BY id')).to eq([1, 4])
expect(@connection.select_rows('SELECT id, canonical_id FROM tag_names ORDER BY id'))
.to eq([[1, nil], [2, 1], [4, nil]])
expect(@connection.select_values('SELECT id FROM nico_tag_relations')).to eq([1])
expect(@connection.select_values('SELECT id FROM tag_implications')).to eq([1])
expect(@connection.select_values('SELECT tag_id FROM tag_versions ORDER BY tag_id'))
.to eq([1, 2])
expect(@connection.select_values('SELECT tag_id FROM nico_tag_versions ORDER BY tag_id'))
.to eq([3, 4])
expect(@connection.select_values('SELECT tag_id FROM material_versions ORDER BY tag_id'))
.to eq([1, 2])
[:tags, :tag_names].each do |table|
expect(@connection.column_exists?(table, :discarded_at)).to be(false)
expect(@connection.index_exists?(table, :discarded_at)).to be(false)
end
[:tag_versions, :nico_tag_versions, :material_versions].each do |table|
expect(@connection.foreign_key_exists?(table, :tags, column: :tag_id)).to be(false)
end
expect(@connection.foreign_key_exists?(:tags, :tag_names)).to be(true)
expect(@connection.foreign_key_exists?(:tag_names, :tag_names, column: :canonical_id))
.to be(true)
expect(@connection.index_exists?(:tag_names, :name, unique: true)).to be(true)
expect(@connection.index_exists?(:tags, :tag_name_id, unique: true)).to be(true)
[:nico_tag_relations, :tag_implications].each do |table|
expect(@connection.foreign_keys(table).map(&:to_table)).to eq(['tags', 'tags'])
end
end
end
end
+6 -28
ファイルの表示
@@ -57,7 +57,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do
it 'deletes the source tag_name' do it 'deletes the source tag_name' do
described_class.apply! described_class.apply!
expect(TagName.unscoped.exists?(source.id)).to be(false) expect(TagName.exists?(source.id)).to be(false)
expect(existing.reload.name).to eq('foobar') expect(existing.reload.name).to eq('foobar')
end end
end end
@@ -75,27 +75,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do
described_class.apply! described_class.apply!
expected_tag_name_id = existing.canonical_id || existing.id expected_tag_name_id = existing.canonical_id || existing.id
expect(source_tag.reload.tag_name_id).to eq(expected_tag_name_id) expect(source_tag.reload.tag_name_id).to eq(expected_tag_name_id)
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false) expect(TagName.exists?(source_tag_name_id)).to be(false)
end
end
context 'when the sanitised name is an alias of an existing tag' do
let!(:existing_tag) { create(:tag) }
let!(:alias_name) do
TagName.create!(name: 'foobar', canonical: existing_tag.tag_name)
end
let!(:source) do
TagName.create!(name: 'tmp').tap do |tn|
tn.update_columns(name: 'foo_bar', updated_at: Time.current)
end
end
it 'deletes only the source and preserves the alias and its canonical tag' do
described_class.apply!
expect(TagName.unscoped.exists?(source.id)).to be(false)
expect(alias_name.reload.canonical).to eq(existing_tag.tag_name)
expect(Tag.find(existing_tag.id)).to eq(existing_tag)
end end
end end
@@ -112,15 +92,13 @@ RSpec.describe TagNameSanitisationRule, type: :model do
end end
it 'merges the source tag into the existing tag and deletes the source tag_name' do it 'merges the source tag into the existing tag and deletes the source tag_name' do
post = create(:post) expect(TagName.find_by(name: 'foobar')&.tag&.id).to eq(existing_tag.id)
PostTag.create!(post:, tag: source_tag) expect(TagName.find_by(name: 'foo_bar')&.tag&.id).to eq(source_tag.id)
described_class.apply! described_class.apply!
expect(Tag.unscoped.exists?(source_tag.id)).to be(false) expect(Tag.exists?(source_tag.id)).to be(false)
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false) expect(TagName.exists?(source_tag.tag_name_id)).to be(false)
expect(post.reload.tags).to contain_exactly(existing_tag)
expect(existing_tag.reload.name).to eq('foobar')
end end
end end
end end
+24 -152
ファイルの表示
@@ -173,47 +173,6 @@ RSpec.describe Tag, type: :model do
end end
end end
describe '.find_or_create_by_tag_name!' do
it 'creates a tag and name with the requested category after stripping whitespace' do
tag = nil
expect {
tag = described_class.find_or_create_by_tag_name!(
' lookup_new ', category: :character)
}.to change(Tag, :count).by(1).and change(TagName, :count).by(1)
expect(tag.name).to eq('lookup_new')
expect(tag.category).to eq('character')
end
it 'reuses the canonical tag for an alias without changing its category' do
tag = create(:tag, category: :character)
alias_name = TagName.create!(name: 'lookup_alias', canonical: tag.tag_name)
found = nil
expect {
found = described_class.find_or_create_by_tag_name!(
alias_name.name, category: :general)
}.to change(Tag, :count).by(0).and change(TagName, :count).by(0)
expect(found).to eq(tag)
expect(found.category).to eq('character')
end
it 'creates a tag for an existing canonical name reached through an alias' do
canonical = create(:tag_name)
alias_name = TagName.create!(name: 'lookup_alias', canonical:)
tag = nil
expect {
tag = described_class.find_or_create_by_tag_name!(
alias_name.name, category: :general)
}.to change(Tag, :count).by(1).and change(TagName, :count).by(0)
expect(tag.tag_name).to eq(canonical)
end
end
describe '.merge_tags!' do describe '.merge_tags!' do
let!(:target_tag) { create(:tag, category: :general) } let!(:target_tag) { create(:tag, category: :general) }
let!(:source_tag) { create(:tag, category: :general) } let!(:source_tag) { create(:tag, category: :general) }
@@ -226,14 +185,17 @@ RSpec.describe Tag, type: :model do
context 'when merging a simple source tag' do context 'when merging a simple source tag' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'deletes the source tag, moves its post link, and keeps its name as an alias' do it 'deletes the source link, links the target, and aliases the discarded source tag' do
described_class.merge_tags!(target_tag, [source_tag]) described_class.merge_tags!(target_tag, [source_tag])
target_link = PostTag.find_by(post: post_record, tag: target_tag) target_link = PostTag.find_by(post: post_record, tag: target_tag)
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false) expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present expect(target_link).to be_present
expect(Tag.unscoped.exists?(source_tag.id)).to be(false) expect(source_tag.reload.post_count).to eq(0)
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id) expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(target_tag.reload.post_count).to eq(1) expect(target_tag.reload.post_count).to eq(1)
end end
@@ -256,76 +218,17 @@ RSpec.describe Tag, type: :model do
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false) expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_links).to contain_exactly(target_post_tag) expect(target_links).to contain_exactly(target_post_tag)
expect(source_tag.reload.post_count).to eq(0)
expect(PostTagSection.where(post: post_record, tag: source_tag)).to be_empty expect(PostTagSection.where(post: post_record, tag: source_tag)).to be_empty
expect(target_post_tag.reload.sections).to contain_exactly(target_section) expect(target_post_tag.reload.sections).to contain_exactly(target_section)
expect(Tag.unscoped.exists?(source_tag.id)).to be(false) expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id) expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(target_tag.reload.post_count).to eq(1) expect(target_tag.reload.post_count).to eq(1)
end end
end end
it 'keeps source history and records the new target alias after deleting the source' do
user = create_member_user!
source_name = source_tag.name
source_alias = TagName.create!(name: 'merge_alias', canonical: source_tag_name)
TagVersioning.ensure_snapshot!(source_tag, created_by_user: user)
original_version = source_tag.tag_versions.first
described_class.merge_tags!(target_tag, [source_tag], created_by_user: user)
versions = TagVersion.where(tag_id: source_tag.id).order(:version_no)
expect(versions.pluck(:version_no, :event_type))
.to eq([[1, 'create'], [2, 'discard']])
expect(versions.first).to eq(original_version)
expect(versions.last).to have_attributes(
name: source_name, aliases: source_alias.name, created_by_user: user)
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
target_versions = target_tag.tag_versions.order(:version_no)
expect(target_versions.pluck(:event_type)).to eq(['create', 'update'])
expect(target_versions.last.aliases.split).to eq([source_name])
end
it 'deletes source relationships while preserving unrelated relationships' do
parent = create(:tag)
child = create(:tag)
nico_tag = create(:tag, :nico)
TagImplication.create!(tag: source_tag, parent_tag: parent)
TagImplication.create!(tag: child, parent_tag: source_tag)
kept_implication = TagImplication.create!(tag: target_tag, parent_tag: parent)
NicoTagRelation.create!(tag: source_tag, nico_tag:)
kept_relation = NicoTagRelation.create!(tag: target_tag, nico_tag:)
TagSimilarity.create!(tag: source_tag, target_tag:, cos: 0.5)
TagSimilarity.create!(tag: target_tag, target_tag: source_tag, cos: 0.5)
kept_similarity = TagSimilarity.create!(tag: target_tag, target_tag: parent,
cos: 0.5)
described_class.merge_tags!(target_tag, [source_tag])
expect(TagImplication.all).to contain_exactly(kept_implication)
expect(NicoTagRelation.all).to contain_exactly(kept_relation)
expect(TagSimilarity.all).to contain_exactly(kept_similarity)
expect(TagVersion.where(tag_id: source_tag.id).order(:version_no).last.parent_tag_ids)
.to eq(parent.id.to_s)
end
it 'preserves material history referencing the deleted source tag' do
source_tag.update!(category: :material)
target_tag.update!(category: :material)
material = Material.create!(tag: source_tag, url: 'https://example.com/material')
version = MaterialVersionRecorder.record!(
material:, event_type: :create, created_by_user: nil)
material.update!(tag: target_tag)
described_class.merge_tags!(target_tag, [source_tag])
expect(version.reload).to have_attributes(
tag_id: source_tag.id, tag_name: source_tag_name.name, tag_category: 'material')
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(material.reload.tag).to eq(target_tag)
end
context 'when source_tags includes the target itself' do context 'when source_tags includes the target itself' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
@@ -335,7 +238,7 @@ RSpec.describe Tag, type: :model do
target_link = PostTag.find_by(post: post_record, tag: target_tag) target_link = PostTag.find_by(post: post_record, tag: target_tag)
expect(Tag.find(target_tag.id)).to be_present expect(Tag.find(target_tag.id)).to be_present
expect(Tag.unscoped.exists?(source_tag.id)).to be(false) expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false) expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present expect(target_link).to be_present
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id) expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
@@ -360,7 +263,7 @@ RSpec.describe Tag, type: :model do
) )
end end
it 'still merges and keeps the source name as an alias without validating it' do it 'still merges, but discards the source tag_name instead of aliasing it' do
described_class.merge_tags!(target_tag, [source_tag]) described_class.merge_tags!(target_tag, [source_tag])
target_link = PostTag.find_by(post: post_record, tag: target_tag) target_link = PostTag.find_by(post: post_record, tag: target_tag)
@@ -368,8 +271,7 @@ RSpec.describe Tag, type: :model do
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false) expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present expect(target_link).to be_present
expect(Tag.unscoped.exists?(source_tag.id)).to be(false) expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(target_tag.reload.post_count).to eq(1) expect(target_tag.reload.post_count).to eq(1)
end end
end end
@@ -386,23 +288,17 @@ RSpec.describe Tag, type: :model do
message: 'init') message: 'init')
end end
it 'rolls back earlier deletions, links, and history when a later source has a wiki' do it 'rolls back the transaction' do
earlier_source = create(:tag)
earlier_name = earlier_source.tag_name
source_section = create(:post_tag_section, post: post_record, source_section = create(:post_tag_section, post: post_record,
tag: source_tag, tag: source_tag,
begin_ms: 1000, end_ms: 2000) begin_ms: 1000, end_ms: 2000)
expect { expect {
described_class.merge_tags!(target_tag, [earlier_source, source_tag]) described_class.merge_tags!(target_tag, [source_tag])
}.to raise_error(ActiveRecord::RecordInvalid) }.to raise_error(ActiveRecord::RecordInvalid)
expect(Tag.unscoped.exists?(earlier_source.id)).to be(true) expect(Tag.with_discarded.find(source_tag.id)).not_to be_discarded
expect(earlier_name.reload.canonical_id).to be_nil expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(TagVersion.where(tag_id: [earlier_source.id, source_tag.id, target_tag.id]))
.to be_empty
expect(Tag.unscoped.exists?(source_tag.id)).to be(true)
expect(TagName.unscoped.exists?(source_tag_name.id)).to be(true)
expect(source_post_tag.reload.tag_id).to eq(source_tag.id) expect(source_post_tag.reload.tag_id).to eq(source_tag.id)
expect(source_post_tag.sections).to contain_exactly(source_section) expect(source_post_tag.sections).to contain_exactly(source_section)
expect(PostTag.find_by(post: post_record, tag: target_tag)).to be_nil expect(PostTag.find_by(post: post_record, tag: target_tag)).to be_nil
@@ -413,45 +309,21 @@ RSpec.describe Tag, type: :model do
end end
context 'when merging a nico source tag' do context 'when merging a nico source tag' do
let!(:target_tag) do let!(:target_tag) { create(:tag, category: :nico, name: 'nico:foo') }
create(:tag, category: :nico, tag_name: create(:tag_name, name: 'nico:foo')) let!(:source_tag) { create(:tag, category: :nico, name: 'nico:bar') }
end
let!(:source_tag) do
create(:tag, category: :nico, tag_name: create(:tag_name, name: 'nico:bar'))
end
let!(:source_tag_name_id) { source_tag.tag_name_id } let!(:source_tag_name_id) { source_tag.tag_name_id }
it 'deletes the source tag and name instead of keeping an alias' do it 'discards the source tag_name instead of aliasing it' do
described_class.merge_tags!(target_tag, [source_tag]) described_class.merge_tags!(target_tag, [source_tag])
expect(Tag.unscoped.exists?(source_tag.id)).to be(false) discarded_source_tag = Tag.with_discarded.find(source_tag.id)
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false) discarded_source_tag_name = TagName.with_discarded.find(source_tag_name_id)
expect(discarded_source_tag).to be_discarded
expect(discarded_source_tag_name).to be_discarded
expect(discarded_source_tag_name.canonical_id).to be_nil
expect(target_tag.reload.post_count).to eq(0) expect(target_tag.reload.post_count).to eq(0)
end end
it 'keeps nico history while deleting source links and allows recreating the name' do
linked_tag = create(:tag)
NicoTagRelation.create!(nico_tag: source_tag, tag: linked_tag)
kept_relation = NicoTagRelation.create!(nico_tag: target_tag, tag: linked_tag)
user = create_member_user!
source_name = source_tag.name
described_class.merge_tags!(target_tag, [source_tag], created_by_user: user)
expect(NicoTagRelation.all).to contain_exactly(kept_relation)
versions = NicoTagVersion.where(tag_id: source_tag.id).order(:version_no)
expect(versions.pluck(:version_no, :event_type))
.to eq([[1, 'create'], [2, 'discard']])
expect(versions.last).to have_attributes(
name: source_name, linked_tags: linked_tag.name, created_by_user: user)
recreated = described_class.find_or_create_by_tag_name!(source_name, category: :nico)
expect(recreated.id).not_to eq(source_tag.id)
expect(recreated.tag_name_id).not_to eq(source_tag_name_id)
expect(recreated.nico_tag_versions).to be_empty
expect(versions.reload.size).to eq(2)
end
end end
def snapshot_tags(post) def snapshot_tags(post)
+6 -2
ファイルの表示
@@ -1252,7 +1252,9 @@ RSpec.describe 'Posts API', type: :request do
context 'when nico tag already exists in tags' do context 'when nico tag already exists in tags' do
before do before do
Tag.find_or_create_by_tag_name!('nico:nico_tag', category: :nico) Tag.find_undiscard_or_create_by!(
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
category: :nico)
end end
it 'returns 422 with tag field errors' do it 'returns 422 with tag field errors' do
@@ -1578,7 +1580,9 @@ RSpec.describe 'Posts API', type: :request do
context 'when nico tag already exists in tags' do context 'when nico tag already exists in tags' do
before do before do
Tag.find_or_create_by_tag_name!('nico:nico_tag', category: :nico) Tag.find_undiscard_or_create_by!(
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
category: :nico)
end end
it 'returns 422 with tag field errors' do it 'returns 422 with tag field errors' do
+2 -1
ファイルの表示
@@ -8,7 +8,8 @@ RSpec.describe 'nico:sync' do
end end
def create_tag!(name, category:) def create_tag!(name, category:)
Tag.find_or_create_by_tag_name!(name, category:) tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) { |t| t.category = category }
end end
def link_nico_to_tag!(nico_tag, tag) def link_nico_to_tag!(nico_tag, tag)