diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index 8bd5dae..837cb84 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -133,12 +133,14 @@ class MaterialsController < ApplicationController block = MaterialImportBlockMatcher.match_for_sha256(file_sha256) return render_material_import_block(block) if block + locale = resolve_locale! + uploaded_blob = build_uploaded_material_blob!(file, file_sha256) begin Material.transaction do MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user) - tag = resolve_material_tag!(tag_name_raw) + tag = resolve_material_tag!(locale, tag_name_raw) material.assign_attributes(tag:, url:, updated_by_user: current_user) if uploaded_blob material.file.attach(uploaded_blob) diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 228cb7b..4ca7a17 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -218,7 +218,7 @@ class PostsController < ApplicationController preflight = PostCreatePreflight.new( attributes: post_create_attributes, thumbnail: params[:thumbnail], - host: request.base_url).run + host: request.base_url).run(locale) return render json: dry_run_json(preflight) if bool?(:dry) if preflight[:existing_post_id].present? post = Post.new(url: preflight[:url]) @@ -273,13 +273,18 @@ class PostsController < ApplicationController return head :forbidden unless current_user.gte_member? return head :unsupported_media_type unless request.content_mime_type == Mime[:multipart_form] return head :payload_too_large if request.content_length.to_i > MAX_BULK_REQUEST_BYTES + + locale = resolve_locale! + posts = parse_bulk_posts_manifest thumbnails = parse_bulk_thumbnails(posts.length) + result = PostBulkCreator.new( actor: current_user, posts:, thumbnails:, - host: request.base_url).run + host: request.base_url).run(locale) + render json: result rescue JSON::ParserError render_bad_request 'posts manifest の JSON が不正です.' diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index ffa5696..e906737 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -406,10 +406,10 @@ class TagsController < ApplicationController return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank? return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated) - return unless validate_tag_rename(tag, name) - locale = resolve_locale! + return unless validate_tag_rename(locale, tag, name) + alias_names = params[:aliases].to_s.split.uniq parent_names = params[:parent_tags].to_s.split.uniq deprecated = bool?(:deprecated) @@ -455,6 +455,8 @@ class TagsController < ApplicationController return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? + locale = resolve_locale! + name = params[:name].presence category = params[:category].presence deprecated_given = params.key?(:deprecated) @@ -462,7 +464,7 @@ class TagsController < ApplicationController tag = Tag.find(params[:id]) - return unless validate_tag_rename(tag, name) + return unless validate_tag_rename(locale, tag, name) if category.present? && category == 'nico' return render_unprocessable_entity 'ニコタグは変更できません.', field: :category @@ -684,7 +686,7 @@ class TagsController < ApplicationController created_by_user:) end - def validate_tag_rename tag, name + def validate_tag_rename locale, tag, name return true if name.blank? || name == tag.name if tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]) @@ -692,9 +694,8 @@ class TagsController < ApplicationController return false end - target_tag_name = TagName.find_by(name:) - return true if target_tag_name.nil? - return true if target_tag_name.canonical_id? + target_tag_name = TagName.find_by(language_code: locale.language_code, name:) + return true unless target_tag_name&.primary_flg render_unprocessable_entity 'その名前は既に使はれてゐます.', field: :name false diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 8a60314..2648097 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -135,7 +135,7 @@ class Tag < ApplicationRecord raise SectionLiteralParseError.new(raw_name, raw_name) end - name = TagName.canonicalise(name).first + name = TagName.canonicalise(locale, name).first find_or_create_by_tag_name!(locale, name, category: (cat || :general)).tap do |tag| if deny_deprecated && tag.deprecated? @@ -235,10 +235,7 @@ class Tag < ApplicationRecord end tag = tn.tag - if tag - tag.update!(tag_name_id: tn.id) if tag.tag_name_id != tn.id - return tag - end + return tag if tag tag = Tag.create!(tag_name: tn, category:) tn.update!(tag:) @@ -279,6 +276,8 @@ class Tag < ApplicationRecord end TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) + source_tag.tag_names.update_all(tag_id: source_tag.id, primary_key: false, + updated_at: Time.current) source_tag.destroy! source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index 82be505..91081ee 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -1,24 +1,36 @@ class TagName < ApplicationRecord + belongs_to :language, foreign_key: :language_code, primary_key: :code belongs_to :tag, optional: true has_one :wiki_page - belongs_to :canonical, class_name: 'TagName', optional: true - has_many :aliases, class_name: 'TagName', foreign_key: :canonical_id + validates :name, presence: true, + length: { maximum: 255 }, + uniqueness: { scope: :language_code } - validates :name, presence: true, length: { maximum: 255 }, uniqueness: true - - validate :canonical_must_be_canonical validate :alias_name_must_not_have_prefix - validate :canonical_must_not_be_present_with_tag_or_wiki_page + validate :alias_must_not_have_wiki_page validate :name_must_be_sanitised - def self.canonicalise names + def primary? = primary_flg + + def canonical = TagName.find_by(language:, tag:, primary_flg: true) + def aliases = TagName.where(language:, tag:, primary_flg: false) + + def self.canonicalise locale, names names = Array(names).map { |n| n.to_s.strip }.reject(&:blank?) return [] if names.blank? - tns = TagName.includes(:canonical).where(name: names).index_by(&:name) + tns = TagName.where(language_code: locale.language_code, name: names).index_by(&:name) - names.map { |name| tns[name]&.canonical&.name || name }.uniq + names.map { |name| + if tns[name].primary_flg + name + else + TagName.find_by(language_code: locale.language_code, + tag_id: tns[name].tag_id, + primary_flg: true).name + end + }.uniq end def self.generate_name locale, tag, name @@ -29,21 +41,15 @@ class TagName < ApplicationRecord private - def canonical_must_be_canonical - if canonical&.canonical_id? - errors.add :canonical, 'canonical は実体を示す必要があります.' - end - end - def alias_name_must_not_have_prefix - if canonical_id? && name.to_s.include?(':') + if !(primary?) && name.to_s.include?(':') errors.add :name, 'エーリアス名にプレフィクスを含むことはできません.' end end - def canonical_must_not_be_present_with_tag_or_wiki_page - if canonical_id? && (tag || wiki_page) - errors.add :canonical, 'タグもしくは Wiki の参照がある名前はエーリアスになれません.' + def alias_must_not_have_wiki_page + if !(primary?) && wiki_page + errors.add :primary_flg, 'Wiki 参照がある名前はエーリアスになれません.' end end diff --git a/backend/app/models/tag_name_sanitisation_rule.rb b/backend/app/models/tag_name_sanitisation_rule.rb index 37cde5f..1086bbb 100644 --- a/backend/app/models/tag_name_sanitisation_rule.rb +++ b/backend/app/models/tag_name_sanitisation_rule.rb @@ -15,31 +15,38 @@ class TagNameSanitisationRule < ApplicationRecord end def apply! - TagName.find_each do |tn| - name = sanitise(tn.name) - next if name == tn.name + Language.find_each do |language| + TagName.where(language:).find_each do |tn| + name = sanitise(tn.name) + next if name == tn.name - TagName.transaction do - existing_tn = TagName.find_by(name:) - if existing_tn - existing_tn = existing_tn.canonical || existing_tn - next if existing_tn.id == tn.id + TagName.transaction do + existing_tn = TagName.find_by(language:, name:) + if existing_tn + unless existing_tn.primary_flg + existing_tn = TagName.find_by!(language:, + tag_id: existing_tn.tag_id, + primary_flg: true) + end + next if existing_tn.id == tn.id - existing_tag = Tag.find_by(tag_name_id: existing_tn.id) - source_tag = Tag.find_by(tag_name_id: tn.id) + existing_tag = existing_tn.tag + source_tag = tn.tag - if existing_tag - Tag.merge_tags!(existing_tag, source_tag) if tn.tag - elsif source_tag - source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current) + if existing_tag + Tag.merge_tags!(existing_tag, source_tag) if tn.tag + elsif source_tag + source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current) + existing_tn.update_columns(tag_id: source_tag.id, updated_at: Time.current) + end + tn.destroy! + + next end - tn.destroy! - next + # TagName 側の自動サニタイズを回避 + tn.update_columns(name:, updated_at: Time.current) end - - # TagName 側の自動サニタイズを回避 - tn.update_columns(name:, updated_at: Time.current) end end end diff --git a/backend/app/services/post_create_plan.rb b/backend/app/services/post_create_plan.rb index fedd574..6a0f9e6 100644 --- a/backend/app/services/post_create_plan.rb +++ b/backend/app/services/post_create_plan.rb @@ -8,13 +8,13 @@ class PostCreatePlan @existing_tags_by_name = nil end - def build! - direct_tag_specs, tag_sections = parse_direct_tag_specs + def build! locale + direct_tag_specs, tag_sections = parse_direct_tag_specs(locale) default_tag_specs = build_default_tag_specs(direct_tag_specs) snapshot_tag_specs = merge_tag_specs(direct_tag_specs + default_tag_specs) preload_existing_tags_by_name!(snapshot_tag_specs.map { _1[:name] }) validate_new_tag_specs!(snapshot_tag_specs) - post_tag_specs = expand_parent_tag_specs(snapshot_tag_specs) + post_tag_specs = expand_parent_tag_specs(locale, snapshot_tag_specs) video_ms = normalise_video_ms(snapshot_tag_specs) validate_video_sections!(video_ms, tag_sections) parent_post_ids = normalise_parent_post_ids @@ -42,13 +42,13 @@ class PostCreatePlan def tag_names = @attributes[:tags].to_s.split - def parse_direct_tag_specs + def parse_direct_tag_specs locale tag_sections = { } direct_tag_specs = [] tag_names.each do |raw_name| - tag_name, category, sections = parse_raw_tag_name(raw_name) - existing_tag = existing_tags_by_name[tag_name] + tag_name, category, sections = parse_raw_tag_name(locale, raw_name) + existing_tag = existing_tags_by_name(locale)[tag_name] raise Tag::DeprecatedTagNormalisationError, [existing_tag.name] if existing_tag&.deprecated? direct_tag_specs << { @@ -65,7 +65,7 @@ class PostCreatePlan [merge_tag_specs(direct_tag_specs), tag_sections] end - def parse_raw_tag_name raw_name + def parse_raw_tag_name locale, raw_name name = raw_name.to_s prefix, category = Tag::CATEGORY_PREFIXES.find { @@ -74,7 +74,7 @@ class PostCreatePlan name = name.sub(/\A#{ prefix }/i, '') sections = [] - while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/)) + while match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/) name = "#{ match[1] }#{ match[4] }" next if match[2].empty? && match[3].empty? @@ -87,17 +87,17 @@ class PostCreatePlan raise Tag::SectionLiteralParseError.new(raw_name, raw_name) end - [resolved_tag_name(name), category&.to_sym, sections] + [resolved_tag_name(locale, name), category&.to_sym, sections] end - def build_default_tag_specs direct_tag_specs + def build_default_tag_specs locale, direct_tag_specs default_tag_specs = [] if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME } default_tag_specs << { name: TAGME_TAG_NAME, category: :meta } end - if direct_tag_specs.none? { deerjikist_tag_spec?(_1) } + if direct_tag_specs.none? { deerjikist_tag_spec?(locale, _1) } default_tag_specs << { name: NO_DEERJIKIST_TAG_NAME, category: :meta } @@ -131,8 +131,10 @@ class PostCreatePlan raise ActiveRecord::RecordInvalid, post end - def expand_parent_tag_specs snapshot_tag_specs - existing_snapshot_tags = snapshot_tag_specs.filter_map { existing_tags_by_name[_1[:name]] } + def expand_parent_tag_specs locale, snapshot_tag_specs + existing_snapshot_tags = snapshot_tag_specs.filter_map do + existing_tags_by_name(locale)[_1[:name]] + end expanded_parent_specs = Tag.expand_parent_tags(existing_snapshot_tags) .reject(&:deprecated?) @@ -156,33 +158,33 @@ class PostCreatePlan }.values.sort_by { _1[:name] } end - def existing_tags_by_name + def existing_tags_by_name locale @existing_tags_by_name ||= begin - names = tag_names.map { canonical_tag_name_without_sections(_1) }.uniq + names = tag_names.map { canonical_tag_name_without_sections(locale, _1) }.uniq Tag.joins(:tag_name).where(tag_names: { name: names }).index_by(&:name) end end - def preload_existing_tags_by_name! names + def preload_existing_tags_by_name! locale, names wanted_names = Array(names).map { _1.to_s }.reject(&:blank?).uniq - missing_names = wanted_names - existing_tags_by_name.keys + missing_names = wanted_names - existing_tags_by_name(locale).keys return if missing_names.empty? - existing_tags_by_name.merge!( + existing_tags_by_name(locale).merge!( Tag.joins(:tag_name) .where(tag_names: { name: missing_names }) .index_by(&:name)) end - def canonical_tag_name_without_sections raw_name - name, = parse_raw_tag_name(raw_name) + def canonical_tag_name_without_sections locale, raw_name + name, = parse_raw_tag_name(locale, raw_name) name end - def deerjikist_tag_spec? spec + def deerjikist_tag_spec? locale, spec return true if spec[:category] == :deerjikist - existing_tags_by_name[spec[:name]]&.deerjikist? + existing_tags_by_name(locale)[spec[:name]]&.deerjikist? end def normalise_parent_post_ids @@ -260,10 +262,13 @@ class PostCreatePlan end end - def resolved_tag_name name - tag_name = TagName.includes(:canonical).find_by(name:) - return name if tag_name.nil? - - (tag_name.canonical || tag_name).name + def resolved_tag_name locale, name + tag_name = TagName.find_by!(language_code: locale.language_code, name:) + unless tag_name.primary_flg + tag_name = TagName.find_by!(language_code: locale.language_code, + tag_id: tag_name.tag_id, + primary_flg: true) + end + tag_name.name end end diff --git a/backend/app/services/post_creator.rb b/backend/app/services/post_creator.rb index dbe4c89..b200901 100644 --- a/backend/app/services/post_creator.rb +++ b/backend/app/services/post_creator.rb @@ -21,14 +21,14 @@ class PostCreator ApplicationRecord.transaction do post.save! post.thumbnail.attach(thumbnail_attachment) if thumbnail_attachment.present? - snapshot_tags = planned_snapshot_tags - post_tags = planned_post_tags - sections = planned_sections + snapshot_tags = planned_snapshot_tags(locale) + post_tags = planned_post_tags(locale) + sections = planned_sections(locale) TagVersioning.record_tag_snapshots!(snapshot_tags, created_by_user: @actor) post.video_ms = planned_video_ms(locale) post.save! sync_post_tags!(post, post_tags, sections) - sync_parent_posts!(post, planned_parent_post_ids) + sync_parent_posts!(post, planned_parent_post_ids(locale)) PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor) end post @@ -45,13 +45,15 @@ class PostCreator thumbnail_base: @attributes[:thumbnail_base].presence) end - def planned_snapshot_tags = planned_create_attributes[:snapshot_tags] + def planned_snapshot_tags(locale) = planned_create_attributes(locale)[:snapshot_tags] - def planned_post_tags = planned_create_attributes[:post_tags] + def planned_post_tags(locale) = planned_create_attributes(locale)[:post_tags] - def planned_sections = planned_create_attributes[:tag_sections] + def planned_sections(locale) = planned_create_attributes(locale)[:tag_sections] - def planned_parent_post_ids = planned_create_attributes[:normalised_parent_post_ids] + def planned_parent_post_ids locale + planned_create_attributes(locale)[:normalised_parent_post_ids] + end def planned_video_ms locale planned_create_attributes(locale)[:video_ms] diff --git a/backend/spec/db/delete_discarded_tags_spec.rb b/backend/spec/db/delete_discarded_tags_spec.rb index 3882e0d..6d6a533 100644 --- a/backend/spec/db/delete_discarded_tags_spec.rb +++ b/backend/spec/db/delete_discarded_tags_spec.rb @@ -11,6 +11,7 @@ RSpec.describe 'discarded tag cleanup migrations' do end context 'with legacy records' do + # Reproduce the historical schema required by these pre-multilingual migrations. self.use_transactional_tests = false before do diff --git a/backend/spec/factories/tag_names.rb b/backend/spec/factories/tag_names.rb index 4af5bcf..f4d6890 100644 --- a/backend/spec/factories/tag_names.rb +++ b/backend/spec/factories/tag_names.rb @@ -7,15 +7,6 @@ FactoryBot.define do trait :alias do primary_flg { false } - - after(:create) do |tag_name| - owner = tag_name.canonical&.tag - raise ArgumentError, 'alias requires a canonical tag' unless owner - - # The alias validation still rejects tag_id; its contract is tested separately. - tag_name.update_columns(tag_id: owner.id) - tag_name.association(:tag).reset - end end end end diff --git a/backend/spec/models/locale_spec.rb b/backend/spec/models/locale_spec.rb index d87bb56..a3b328e 100644 --- a/backend/spec/models/locale_spec.rb +++ b/backend/spec/models/locale_spec.rb @@ -27,10 +27,10 @@ RSpec.describe Locale, type: :model do expect(first.tag_names.find_by!(language_code: 'fr', primary_flg: true)) .to have_attributes(name: 'name_from_generator', tag_id: first.id, language_code: 'fr', script_code: 'Latn', - primary_flg: true, canonical_id: nil) + primary_flg: true) expect(second.tag_names.find_by!(language_code: 'fr', primary_flg: true)) .to have_attributes(tag_id: second.id, language_code: 'fr', - script_code: 'Latn', primary_flg: true, canonical_id: nil) + script_code: 'Latn', primary_flg: true) expect(TagName.where(language_code: 'fr', primary_flg: true, tag_id: [first.id, second.id]).count).to eq(2) end @@ -53,13 +53,13 @@ RSpec.describe Locale, type: :model do prepare_french_reference! tag = create(:tag, name: 'alias_only_tag') alias_name = create(:tag_name, :alias, name: 'alias_fr', - canonical: tag.tag_name, + tag:, language_code: 'fr', script_code: 'Latn') create_french_locale! expect(alias_name.reload).to have_attributes( - canonical_id: tag.tag_name_id, tag_id: tag.id, + tag_id: tag.id, language_code: 'fr', script_code: 'Latn', primary_flg: false) generated = TagName.find_by!(tag_id: tag.id, language_code: 'fr', primary_flg: true) expect(generated).to have_attributes(tag_id: tag.id, language_code: 'fr', diff --git a/backend/spec/models/tag_name_sanitisation_rule_spec.rb b/backend/spec/models/tag_name_sanitisation_rule_spec.rb index 6fb9929..50d0a10 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -79,7 +79,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do end end - context 'when a conflicting canonical tag_name exists' do + context 'when a conflicting primary tag name exists' do let!(:existing) { create(:tag_name, name: 'foobar') } let!(:source) do create(:tag_name, name: 'tmp').tap do |tn| @@ -105,8 +105,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do it 'moves the tag to the existing tag_name' do described_class.apply! - 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(existing.id) expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false) end end @@ -114,7 +113,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do context 'when the sanitised name is an alias of an existing tag' do let!(:existing_tag) { create(:tag) } let!(:alias_name) do - create(:tag_name, :alias, name: 'foobar', canonical: existing_tag.tag_name) + create(:tag_name, :alias, name: 'foobar', tag: existing_tag) end let!(:source) do create(:tag_name, name: 'tmp').tap do |tn| @@ -122,11 +121,15 @@ RSpec.describe TagNameSanitisationRule, type: :model do end end - it 'deletes only the source and preserves the alias and its canonical tag' do + it 'deletes only the source and preserves the alias and its owning 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(alias_name.reload).to have_attributes( + tag_id: existing_tag.id, language_code: 'ja', primary_flg: false) + expect(TagName.find_by!(tag_id: existing_tag.id, + language_code: 'ja', primary_flg: true)) + .to eq(existing_tag.tag_name) expect(Tag.find(existing_tag.id)).to eq(existing_tag) end end diff --git a/backend/spec/models/tag_name_spec.rb b/backend/spec/models/tag_name_spec.rb index 66aa120..b8e251f 100644 --- a/backend/spec/models/tag_name_spec.rb +++ b/backend/spec/models/tag_name_spec.rb @@ -32,37 +32,58 @@ RSpec.describe TagName, type: :model do expect(tag.reload.tag_name.reload.tag_id).to eq(tag.id) expect(tag.tag_name.tag).to eq(tag) + expect(described_class.find_by!(tag_id: tag.id, language_code: 'ja', primary_flg: true)) + .to eq(tag.tag_name) end - it 'accepts an alias owned by the canonical tag' do + it 'persists an alias owned by the same tag as its primary name' do tag = create(:tag) - alias_name = build(:tag_name, name: 'valid_alias', canonical: tag.tag_name, - tag:, primary_flg: false) + alias_name = create(:tag_name, :alias, name: 'valid_alias', tag:) - expect(alias_name).to have_attributes( - canonical_id: tag.tag_name_id, tag_id: tag.id, + expect(alias_name.reload).to have_attributes( + tag_id: tag.id, primary_flg: false, language_code: 'ja') - expect(alias_name).to be_valid + primary = described_class.find_by!(tag_id: alias_name.tag_id, + language_code: alias_name.language_code, + primary_flg: true) + expect(primary).to eq(tag.tag_name) end describe '.canonicalise' do + it 'resolves the primary name of the alias language on the same tag' do + tag = create(:tag, name: '日本語正本') + english_primary = create(:tag_name, tag:, name: 'english_primary', + language_code: 'en', script_code: 'Latn') + english_alias = create(:tag_name, :alias, tag:, name: 'english_alias', + language_code: 'en', script_code: 'Latn') + representative_id = tag.tag_name_id + + primary = described_class.find_by!(tag_id: english_alias.tag_id, + language_code: english_alias.language_code, + primary_flg: true) + + expect(primary).to eq(english_primary) + expect(described_class.canonicalise(locale, [english_alias.name])) + .to eq([english_primary.name]) + expect(tag.reload.tag_name_id).to eq(representative_id) + end + it 'resolves only aliases in the requested language' do japanese = create(:tag, name: 'japanese_canonical') english_name = create(:tag_name, name: 'english_canonical', language_code: 'en', script_code: 'Latn') english = create(:tag, tag_name: english_name) japanese_alias = create(:tag_name, :alias, name: 'shared_alias', - canonical: japanese.tag_name) - english_alias = create(:tag_name, :alias, name: 'temporary_alias', - canonical: english_name, + tag: japanese) + english_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: english, language_code: 'en', script_code: 'Latn') - english_alias.update_columns(name: 'shared_alias') expect(japanese_alias.reload).to have_attributes( - canonical_id: japanese.tag_name_id, tag_id: japanese.id, + tag_id: japanese.id, primary_flg: false, language_code: 'ja') expect(english_alias.reload).to have_attributes( - canonical_id: english_name.id, tag_id: english.id, + tag_id: english.id, primary_flg: false, language_code: 'en') expect(described_class.canonicalise(locale, ['shared_alias'])) .to eq(['english_canonical']) diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index e9683dd..13feb00 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -37,17 +37,16 @@ RSpec.describe Tag, type: :model do language_code: 'en', script_code: 'Latn') english = create(:tag, tag_name: english_name) japanese_alias = create(:tag_name, :alias, name: 'shared_alias', - canonical: japanese.tag_name) - english_alias = create(:tag_name, :alias, name: 'temporary_alias', - canonical: english_name, + tag: japanese) + english_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: english, language_code: 'en', script_code: 'Latn') - english_alias.update_columns(name: 'shared_alias') expect(japanese_alias.reload).to have_attributes( - canonical_id: japanese.tag_name_id, tag_id: japanese.id, + tag_id: japanese.id, primary_flg: false, language_code: 'ja') expect(english_alias.reload).to have_attributes( - canonical_id: english_name.id, tag_id: english.id, + tag_id: english.id, primary_flg: false, language_code: 'en') expect(described_class.normalise_tags!( english_locale, ['shared_alias'], @@ -270,12 +269,12 @@ RSpec.describe Tag, type: :model do expect(tag.category).to eq('character') end - it 'reuses the canonical tag for an alias without changing its category' do + it 'reuses the owning tag for an alias without changing its category' do tag = create(:tag, category: :character) representative_id = tag.tag_name_id - alias_name = create(:tag_name, :alias, name: 'lookup_alias', canonical: tag.tag_name) + alias_name = create(:tag_name, :alias, name: 'lookup_alias', tag:) expect(alias_name.reload).to have_attributes( - canonical_id: tag.tag_name_id, tag_id: tag.id, + tag_id: tag.id, primary_flg: false, language_code: 'ja') found = nil @@ -289,14 +288,14 @@ RSpec.describe Tag, type: :model do expect(tag.reload.tag_name_id).to eq(representative_id) end - it 'reuses the canonical tag through another alias' do + it 'reuses the owning tag through another alias' do tag = create(:tag) - canonical = tag.tag_name - alias_name = create(:tag_name, :alias, name: 'lookup_alias', canonical:) - other_alias = create(:tag_name, :alias, name: 'lookup_other_alias', canonical:) + primary = tag.tag_name + alias_name = create(:tag_name, :alias, name: 'lookup_alias', tag:) + other_alias = create(:tag_name, :alias, name: 'lookup_other_alias', tag:) expect([alias_name.reload, other_alias.reload]).to all(have_attributes( - canonical_id: canonical.id, tag_id: tag.id, + tag_id: tag.id, primary_flg: false, language_code: 'ja')) expect { @@ -305,7 +304,7 @@ RSpec.describe Tag, type: :model do expect(found).to eq(tag) }.to change(Tag, :count).by(0).and change(TagName, :count).by(0) - expect(tag.tag_name).to eq(canonical) + expect(tag.reload.tag_name).to eq(primary) end end @@ -329,7 +328,8 @@ RSpec.describe Tag, type: :model do expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false) expect(target_link).to be_present expect(Tag.unscoped.exists?(source_tag.id)).to be(false) - expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id) + expect(source_tag_name.reload).to have_attributes( + tag_id: target_tag.id, language_code: 'ja', primary_flg: false) expect(target_tag.reload.post_count).to eq(1) end end @@ -355,7 +355,8 @@ RSpec.describe Tag, type: :model do expect(target_post_tag.reload.sections).to contain_exactly(target_section) expect(Tag.unscoped.exists?(source_tag.id)).to be(false) - expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id) + expect(source_tag_name.reload).to have_attributes( + tag_id: target_tag.id, language_code: 'ja', primary_flg: false) expect(target_tag.reload.post_count).to eq(1) end end @@ -363,7 +364,7 @@ RSpec.describe Tag, type: :model do 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 = create(:tag_name, :alias, name: 'merge_alias', canonical: source_tag_name) + source_alias = create(:tag_name, :alias, name: 'merge_alias', tag: source_tag) TagVersioning.ensure_snapshot!(source_tag, created_by_user: user) original_version = source_tag.tag_versions.first @@ -433,7 +434,8 @@ RSpec.describe Tag, type: :model do expect(Tag.unscoped.exists?(source_tag.id)).to be(false) expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false) 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).to have_attributes( + tag_id: target_tag.id, language_code: 'ja', primary_flg: false) expect(target_tag.reload.post_count).to eq(1) end end @@ -464,7 +466,8 @@ RSpec.describe Tag, type: :model do expect(target_link).to be_present expect(Tag.unscoped.exists?(source_tag.id)).to be(false) - expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id) + expect(source_tag_name.reload).to have_attributes( + tag_id: target_tag.id, language_code: 'ja', primary_flg: false) expect(target_tag.reload.post_count).to eq(1) end end @@ -493,7 +496,8 @@ RSpec.describe Tag, type: :model do }.to raise_error(ActiveRecord::RecordInvalid) expect(Tag.unscoped.exists?(earlier_source.id)).to be(true) - expect(earlier_name.reload.canonical_id).to be_nil + expect(earlier_name.reload).to have_attributes( + tag_id: earlier_source.id, language_code: 'ja', primary_flg: true) 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) @@ -502,7 +506,8 @@ RSpec.describe Tag, type: :model do expect(source_post_tag.sections).to contain_exactly(source_section) expect(PostTag.find_by(post: post_record, tag: target_tag)).to be_nil expect(source_tag.reload.post_count).to eq(1) - expect(source_tag_name.reload.canonical_id).to be_nil + expect(source_tag_name.reload).to have_attributes( + tag_id: source_tag.id, language_code: 'ja', primary_flg: true) expect(target_tag.reload.post_count).to eq(0) end end diff --git a/backend/spec/requests/locale_write_paths_spec.rb b/backend/spec/requests/locale_write_paths_spec.rb index f90a8c3..7d269dc 100644 --- a/backend/spec/requests/locale_write_paths_spec.rb +++ b/backend/spec/requests/locale_write_paths_spec.rb @@ -202,17 +202,19 @@ RSpec.describe 'Locale propagation on write paths', type: :request do expect(response).to have_http_status(:ok), response.body expect(TagName.find_by!(language_code: 'en', name: 'english_alias')) .to have_attributes( - script_code: 'Latn', canonical_id: english_name.id, + script_code: 'Latn', tag_id: tag.id, primary_flg: false, language_code: 'en') expect(tag.reload.tag_name_id).to eq(english_name.id) + primary = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true) + expect(primary).to have_attributes(name: 'english_original', script_code: 'Latn') end it 'does not take an alias from another language' do japanese = create(:tag, name: 'japanese_owner') japanese_alias = create(:tag_name, :alias, name: 'shared_alias', - canonical: japanese.tag_name) + tag: japanese) expect(japanese_alias.reload).to have_attributes( - canonical_id: japanese.tag_name_id, tag_id: japanese.id, + tag_id: japanese.id, primary_flg: false, language_code: 'ja') put "/tags/#{ tag.id }", params: { @@ -221,12 +223,14 @@ RSpec.describe 'Locale propagation on write paths', type: :request do expect(response).to have_http_status(:ok), response.body expect(japanese_alias.reload).to have_attributes( - canonical_id: japanese.tag_name_id, tag_id: japanese.id, + tag_id: japanese.id, primary_flg: false, language_code: 'ja') expect(TagName.find_by!(language_code: 'en', name: 'shared_alias')) .to have_attributes( - canonical_id: english_name.id, tag_id: tag.id, + tag_id: tag.id, script_code: 'Latn', primary_flg: false, language_code: 'en') + expect(TagName.where(name: 'shared_alias').pluck(:language_code, :tag_id)) + .to contain_exactly(['ja', japanese.id], ['en', tag.id]) end end end diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 585dacb..580271a 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -129,7 +129,7 @@ RSpec.describe 'Posts API', type: :request do let!(:tag) { create(:tag, tag_name:, category: :general) } let!(:tag_name2) { create(:tag_name, name: 'unko') } let!(:tag2) { create(:tag, tag_name: tag_name2, category: :deerjikist) } - let!(:alias_tag_name) { create(:tag_name, :alias, name: 'manko', canonical: tag_name) } + let!(:alias_tag_name) { create(:tag_name, :alias, name: 'manko', tag:) } let!(:hit_post) do Post.create!(uploaded_user: user, title: 'hello spec world', @@ -351,7 +351,7 @@ RSpec.describe 'Posts API', type: :request do let!(:baz_tag) { create(:tag, tag_name: baz_tag_name, category: :general) } let!(:foo_alias_tag_name) do - create(:tag_name, :alias, name: 'not_spec_foo_alias', canonical: foo_tag_name) + create(:tag_name, :alias, name: 'not_spec_foo_alias', tag: foo_tag) end let!(:foo_only_post) do @@ -854,7 +854,7 @@ RSpec.describe 'Posts API', type: :request do 15.times.map do |i| tag_name = create(:tag_name, name: "show_query_tag_#{ i }") tag = create(:tag, tag_name:, category: :general) - create(:tag_name, :alias, name: "show_query_alias_#{ i }", canonical: tag_name) + create(:tag_name, :alias, name: "show_query_alias_#{ i }", tag:) PostTag.create!(post: post_record, tag:) tag end @@ -999,7 +999,7 @@ RSpec.describe 'Posts API', type: :request do describe 'POST /posts' do let(:member) { create(:user, :member) } - let!(:alias_tag_name) { create(:tag_name, :alias, name: 'manko', canonical: tag_name) } + let!(:alias_tag_name) { create(:tag_name, :alias, name: 'manko', tag:) } it '401 when not logged in' do sign_out diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 26c1b52..8d10525 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' RSpec.describe 'Tags API', type: :request do let!(:tn) { create(:tag_name, name: 'spec_tag') } let!(:tag) { create(:tag, tag_name: tn, category: :general) } - let!(:alias_tn) { create(:tag_name, :alias, name: 'unko', canonical: tn) } + let!(:alias_tn) { create(:tag_name, :alias, name: 'unko', tag:) } let!(:post) { Post.create!(url: 'https://example.com/unkounkounko') } let!(:post_tag) { PostTag.create!(post:, tag:) } let!(:tn2) { create(:tag_name, name: 'unknown') } @@ -466,7 +466,7 @@ RSpec.describe 'Tags API', type: :request do it 'excludes external tags but preserves internal alias matches when nico is false' do internal = create(:tag, category: :general, name: 'switch_internal', post_count: 1) alias_target = create(:tag, category: :general, name: 'alias_target', post_count: 1) - create(:tag_name, :alias, name: 'switch_alias', canonical: alias_target.tag_name) + create(:tag_name, :alias, name: 'switch_alias', tag: alias_target) create(:external_tag, name: 'switch_external', post_count: 1) get '/tags/autocomplete', params: { q: 'switch', nico: '0' } @@ -479,13 +479,13 @@ RSpec.describe 'Tags API', type: :request do end ['%', '_'].each do |wildcard| - it "treats #{ wildcard } literally for canonical, alias, and external names" do + it "treats #{ wildcard } literally for primary, alias, and external names" do literal = "literal#{ wildcard }match" create(:tag, category: :general, name: literal, post_count: 1) alias_target = create( :tag, category: :general, name: 'literal_alias_target', post_count: 1) create(:tag_name, :alias, name: "#{ literal }_alias", - canonical: alias_target.tag_name) + tag: alias_target) create(:external_tag, name: literal, post_count: 1) create(:tag, category: :general, name: 'literalXmatch', post_count: 2) create(:external_tag, name: 'literalXmatch', post_count: 2) @@ -509,7 +509,7 @@ RSpec.describe 'Tags API', type: :request do expect(t['matched_alias']).to be(nil) end - it 'returns matching canonical tags by q with aliases' do + it 'returns matching primary tag names by q with aliases' do get '/tags/autocomplete', params: { q: 'unk' } expect(response).to have_http_status(:ok) @@ -772,7 +772,8 @@ RSpec.describe 'Tags API', type: :request do } expect(response).to have_http_status(:ok) - expect(TagName.find_by!(name: 'spec_tag').canonical).to eq(tag.reload.tag_name) + expect(TagName.find_by!(language_code: 'ja', name: 'spec_tag')) + .to have_attributes(tag_id: tag.id, primary_flg: false) patch "/tags/#{ tag.id }", params: { name: 'spec_tag' } @@ -781,8 +782,10 @@ RSpec.describe 'Tags API', type: :request do tag.reload expect(tag.name).to eq('spec_tag') - expect(tag.tag_name.canonical_id).to be_nil - expect(TagName.find_by!(name: 'patch_roundtrip_target').canonical).to eq(tag.tag_name) + expect(tag.tag_name).to have_attributes( + tag_id: tag.id, language_code: 'ja', primary_flg: true) + expect(TagName.find_by!(language_code: 'ja', name: 'patch_roundtrip_target')) + .to have_attributes(tag_id: tag.id, primary_flg: false) end it '別 tag の正規名には変更できない' do @@ -801,7 +804,8 @@ RSpec.describe 'Tags API', type: :request do ) expect(tag.reload.name).to eq('spec_tag') - expect(tag.tag_name.aliases.map(&:name)).to contain_exactly('unko') + expect(TagName.where(tag_id: tag.id, language_code: 'ja', primary_flg: false).pluck(:name)) + .to contain_exactly('unko') expect(wiki_page.reload.tag_name).to eq(tag.tag_name) end @@ -1170,14 +1174,17 @@ RSpec.describe 'Tags API', type: :request do expect(tag.name).to eq('put_renamed_tag') expect(tag.category).to eq('meme') - expect(TagName.find_by(name: 'put_alias_a').canonical).to eq(tag.tag_name) - expect(TagName.find_by(name: 'put_alias_b').canonical).to eq(tag.tag_name) + expect(TagName.find_by!(language_code: 'ja', name: 'put_alias_a')) + .to have_attributes(tag_id: tag.id, primary_flg: false) + expect(TagName.find_by!(language_code: 'ja', name: 'put_alias_b')) + .to have_attributes(tag_id: tag.id, primary_flg: false) old_name_alias = TagName.find_by(name: 'spec_tag') expect(old_name_alias).to be_present - expect(old_name_alias.canonical).to eq(tag.tag_name) + expect(old_name_alias).to have_attributes( + tag_id: tag.id, language_code: 'ja', primary_flg: false) - expect(alias_tn.reload.canonical).to be_nil + expect(alias_tn.reload.tag_id).to be_nil expect(tag.parents.map(&:name)).to contain_exactly( 'put_kept_parent', @@ -1212,7 +1219,8 @@ RSpec.describe 'Tags API', type: :request do tag.reload - expect(TagName.find_by(name: 'put_alias_self_test').canonical).to eq(tag.tag_name) + expect(TagName.find_by!(language_code: 'ja', name: 'put_alias_self_test')) + .to have_attributes(tag_id: tag.id, primary_flg: false) expect(json['aliases']).to include('put_alias_self_test') expect(json['aliases']).not_to include('spec_tag') end @@ -1283,10 +1291,14 @@ RSpec.describe 'Tags API', type: :request do tag.reload expect(tag.name).to eq('spec_tag') - expect(TagName.find_by!(name: 'put_roundtrip_b').canonical).to eq(tag.tag_name) - expect(tag.tag_name.aliases.map(&:name)).to contain_exactly('put_roundtrip_b', 'unko') - expect(tag.tag_name.aliases.map(&:name)).not_to include('spec_tag') - expect(alias_tn.reload.canonical).to eq(tag.tag_name) + expect(TagName.find_by!(language_code: 'ja', name: 'put_roundtrip_b')) + .to have_attributes(tag_id: tag.id, primary_flg: false) + expect(TagName.where(tag_id: tag.id, language_code: 'ja', primary_flg: false).pluck(:name)) + .to contain_exactly('put_roundtrip_b', 'unko') + expect(TagName.where(tag_id: tag.id, language_code: 'ja', primary_flg: false).pluck(:name)) + .not_to include('spec_tag') + expect(alias_tn.reload).to have_attributes( + tag_id: tag.id, language_code: 'ja', primary_flg: false) version = tag.tag_versions.order(:version_no).last @@ -1395,7 +1407,7 @@ RSpec.describe 'Tags API', type: :request do ) end - it 'wiki を持つ tag を旧 alias へ戻しても wiki を新 canonical へ移す' do + it 'wiki を持つ tag を旧 alias へ戻しても wiki を新しい正本名へ移す' do wiki_page = Wiki::Commit.create_content!( tag_name: tag.tag_name, @@ -1429,7 +1441,8 @@ RSpec.describe 'Tags API', type: :request do expect(wiki_page.reload.tag_name).to eq(tag.tag_name) expect(TagName.find_by!(name: 'put_wiki_roundtrip_b').wiki_page).to be_nil - expect(TagName.find_by!(name: 'put_wiki_roundtrip_b').canonical).to eq(tag.tag_name) + expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b')) + .to have_attributes(tag_id: tag.id, primary_flg: false) versions = wiki_page.wiki_versions.order(:version_no).last(2) @@ -1443,9 +1456,11 @@ RSpec.describe 'Tags API', type: :request do category: :general) stolen_alias = create(:tag_name, :alias, name: 'put_stolen_alias', - canonical: old_owner.tag_name) + tag: old_owner) - expect(old_owner.tag_name.aliases.map(&:name)).to include('put_stolen_alias') + expect(TagName.where(tag_id: old_owner.id, language_code: 'ja', + primary_flg: false).pluck(:name)) + .to include('put_stolen_alias') expect { put "/tags/#{ tag.id }", params: { @@ -1461,8 +1476,11 @@ RSpec.describe 'Tags API', type: :request do expect(response).to have_http_status(:ok) - expect(stolen_alias.reload.canonical).to eq(tag.tag_name) - expect(old_owner.reload.tag_name.aliases.map(&:name)).not_to include('put_stolen_alias') + expect(stolen_alias.reload).to have_attributes( + tag_id: tag.id, language_code: 'ja', primary_flg: false) + expect(TagName.where(tag_id: old_owner.id, language_code: 'ja', + primary_flg: false).pluck(:name)) + .not_to include('put_stolen_alias') old_owner_versions = old_owner.tag_versions.order(:version_no) @@ -1479,7 +1497,7 @@ RSpec.describe 'Tags API', type: :request do category: :general) stolen_alias = create(:tag_name, :alias, name: 'put_alias_collision_name', - canonical: old_owner.tag_name) + tag: old_owner) wiki_page = Wiki::Commit.create_content!( tag_name: tag.tag_name, @@ -1502,9 +1520,13 @@ RSpec.describe 'Tags API', type: :request do stolen_alias.reload expect(tag.name).to eq('put_alias_collision_name') - expect(stolen_alias.canonical_id).to be_nil - expect(TagName.find_by!(name: 'spec_tag').canonical).to eq(tag.tag_name) - expect(old_owner.tag_name.aliases.map(&:name)).not_to include('put_alias_collision_name') + expect(stolen_alias).to have_attributes( + tag_id: tag.id, language_code: 'ja', primary_flg: true) + expect(TagName.find_by!(language_code: 'ja', name: 'spec_tag')) + .to have_attributes(tag_id: tag.id, primary_flg: false) + expect(TagName.where(tag_id: old_owner.id, language_code: 'ja', + primary_flg: false).pluck(:name)) + .not_to include('put_alias_collision_name') old_owner_versions = old_owner.tag_versions.order(:version_no) diff --git a/backend/spec/services/post_create_plan_spec.rb b/backend/spec/services/post_create_plan_spec.rb index e87b492..209def7 100644 --- a/backend/spec/services/post_create_plan_spec.rb +++ b/backend/spec/services/post_create_plan_spec.rb @@ -40,17 +40,16 @@ RSpec.describe PostCreatePlan do language_code: 'en', script_code: 'Latn') english = create(:tag, tag_name: english_name, category: :character) japanese_alias = create(:tag_name, :alias, name: 'shared_alias', - canonical: japanese.tag_name) - english_alias = create(:tag_name, :alias, name: 'temporary_alias', - canonical: english_name, + tag: japanese) + english_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: english, language_code: 'en', script_code: 'Latn') - english_alias.update_columns(name: 'shared_alias') expect(japanese_alias.reload).to have_attributes( - canonical_id: japanese.tag_name_id, tag_id: japanese.id, + tag_id: japanese.id, primary_flg: false, language_code: 'ja') expect(english_alias.reload).to have_attributes( - canonical_id: english_name.id, tag_id: english.id, + tag_id: english.id, primary_flg: false, language_code: 'en') plan = described_class.new(attributes: { tags: 'shared_alias' }).build!(locale) @@ -79,9 +78,9 @@ RSpec.describe PostCreatePlan do expect([TagName.count, Tag.count]).to eq(counts) end - it 'resolves aliases and keeps tag sections separate from canonical names' do - canonical = create_tag!('虹夏', :character) - create(:tag_name, :alias, name: 'にじか', canonical: canonical.tag_name) + it 'resolves aliases and keeps tag sections separate from primary names' do + tag = create_tag!('虹夏', :character) + create(:tag_name, :alias, name: 'にじか', tag:) create_tag!('動画', :meta) plan = described_class.new(