diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 13b71ba..2c4d67d 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -802,7 +802,8 @@ class TagsController < ApplicationController affected_tags << alias_tag_name&.tag end - affected_tags.compact.uniq.each do |affected_tag| + affected_tags = affected_tags.compact.uniq(&:id) + affected_tags.each do |affected_tag| TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user, language_code: locale.language_code) end @@ -821,7 +822,10 @@ class TagsController < ApplicationController alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false) end - affected_tags.compact.uniq.each do |affected_tag| + affected_tags.each do |affected_tag| + # update_all records this Tag after its parent relationships are updated. + next if affected_tag.id == tag.id + record_tag_version!(affected_tag, event_type: :update, created_by_user: current_user, language_code: locale.language_code) diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 688fe7b..5cae0b5 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -135,10 +135,10 @@ class WikiPagesController < ApplicationController name = title.delete_prefix(TagGroupRepr::PREFIX) parsed = TagGroupMarkdownParser.parse(body:, group: nil) group = TagGroupOperation.create!( - name:, **parsed.except(:compatibility_body), actor: current_user) + name:, **parsed.except(:compatibility_body), actor: current_user, + require_compatibility: true) version = current_group_version(group) return render_group_representation(TagGroupRepr.one(group), version, - body: parsed.fetch(:compatibility_body), status: :created) end @@ -179,7 +179,8 @@ class WikiPagesController < ApplicationController name = title.delete_prefix(TagGroupRepr::PREFIX) parsed = TagGroupMarkdownParser.parse(body:, group:) TagGroupOperation.update!( - group:, name:, **parsed.except(:compatibility_body), actor: current_user) + group:, name:, **parsed.except(:compatibility_body), actor: current_user, + require_compatibility: true) return head :ok end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 9baa7e6..b89b31f 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -305,9 +305,10 @@ class Tag < ApplicationRecord end TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) - source_tag.tag_names.update_all(tag_id: target_tag.id, primary_flg: false, - auto_generated: false, - updated_at: Time.current) + target_languages = target_tag.tag_names.where(primary_flg: true).pluck(:language_code) + source_tag.tag_names.where(language_code: target_languages) + .update_all(primary_flg: false, auto_generated: false, updated_at: Time.current) + source_tag.tag_names.update_all(tag_id: target_tag.id, updated_at: Time.current) source_tag.destroy! TagVersioning.record!(target_tag, event_type: :update, created_by_user:) diff --git a/backend/app/models/tag_description_version.rb b/backend/app/models/tag_description_version.rb index 69f31ac..39f167d 100644 --- a/backend/app/models/tag_description_version.rb +++ b/backend/app/models/tag_description_version.rb @@ -1,3 +1,3 @@ class TagDescriptionVersion < ApplicationRecord - ; + before_create { SharedResourceIdAllocator.assign!(self) } end diff --git a/backend/app/models/tag_group.rb b/backend/app/models/tag_group.rb index 19cf4ed..cda2107 100644 --- a/backend/app/models/tag_group.rb +++ b/backend/app/models/tag_group.rb @@ -11,7 +11,7 @@ class TagGroup < ApplicationRecord has_many :placements, -> { order(:position) }, class_name: 'TagGroupPlacement', dependent: :delete_all has_many :versions, -> { order(:version_no) }, - class_name: 'TagGroupVersion', dependent: :delete_all + class_name: 'TagGroupVersion' validates :name, presence: true, uniqueness: true, length: { maximum: 255 } validates :version_no, numericality: { only_integer: true, greater_than: 0 } diff --git a/backend/app/models/tag_group_version.rb b/backend/app/models/tag_group_version.rb index 95257e9..46a5cfb 100644 --- a/backend/app/models/tag_group_version.rb +++ b/backend/app/models/tag_group_version.rb @@ -1,6 +1,8 @@ class TagGroupVersion < ApplicationRecord include VersionRecord + before_create { SharedResourceIdAllocator.assign!(self) } + belongs_to :tag_group scope :ordered, -> { order(:version_no) } diff --git a/backend/app/models/tag_name_sanitisation_rule.rb b/backend/app/models/tag_name_sanitisation_rule.rb index b853f00..bd23abc 100644 --- a/backend/app/models/tag_name_sanitisation_rule.rb +++ b/backend/app/models/tag_name_sanitisation_rule.rb @@ -16,31 +16,36 @@ class TagNameSanitisationRule < ApplicationRecord def apply! Language.find_each do |language| - TagName.where(language:).find_each do |tn| - name = sanitise(tn.name) - next if name == tn.name - + TagName.where(language:).find_each do |candidate| TagName.transaction do + tn = TagName.find_by(id: candidate.id) + next unless tn + + name = sanitise(tn.name) + next if name == tn.name + existing_tn = TagName.find_by(language:, name:) - if existing_tn - unless existing_tn.primary_flg - existing_tn = TagName.find_by!(language:, - tag_id: existing_tn.tag_id, - primary_flg: true) + source_tag = tn.tag + target_tag = existing_tn ? existing_tn.tag : source_tag + [source_tag, target_tag].uniq(&:id).sort_by(&:id).each(&:lock!) + TagVersioning.ensure_snapshot!(source_tag, created_by_user: nil) + + if existing_tn && target_tag != source_tag + Tag.merge_tags!(target_tag, source_tag) + tn.reload.destroy! + elsif existing_tn + if tn.primary? + existing_tn.destroy! + tn.update_columns(name:, updated_at: Time.current) + else + tn.destroy! end - next if existing_tn.id == tn.id - - existing_tag = existing_tn.tag - source_tag = tn.tag - - Tag.merge_tags!(existing_tag, source_tag) if existing_tag != source_tag - tn.destroy! - - next + else + # TagName 側の自動サニタイズを回避 + tn.update_columns(name:, updated_at: Time.current) end - # TagName 側の自動サニタイズを回避 - tn.update_columns(name:, updated_at: Time.current) + TagVersioning.record!(target_tag, event_type: :update, created_by_user: nil) end end end diff --git a/backend/app/services/shared_resource_id_allocator.rb b/backend/app/services/shared_resource_id_allocator.rb index 03ad394..c1be915 100644 --- a/backend/app/services/shared_resource_id_allocator.rb +++ b/backend/app/services/shared_resource_id_allocator.rb @@ -4,7 +4,14 @@ require 'digest' class SharedResourceIdAllocator SPACES = { 'tags' => ['tags', 'external_tags'], - 'wiki' => ['tag_descriptions', 'tag_groups'] }.freeze + 'wiki' => ['tag_descriptions', 'tag_groups'], + 'wiki_history' => ['tag_description_versions', 'tag_group_versions'] }.freeze + HISTORY_IDS = { + 'tags' => [ + ['tag_versions', 'tag_id'], + ['nico_tag_versions', 'tag_id'], + ['tag_description_versions', 'tag_id']], + 'wiki' => [['tag_group_versions', 'tag_group_id']] }.freeze LOCK_TIMEOUT = 10 def self.assign! record @@ -13,7 +20,9 @@ class SharedResourceIdAllocator connection = record.class.connection transaction = record.class.current_transaction - raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction' unless transaction.open? + unless transaction.open? + raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction' + end database = connection.pool.db_config.database name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }" @@ -41,16 +50,45 @@ class SharedResourceIdAllocator raise end - return if record.id.present? - - # Locking reads see committed maxima even after earlier repeatable reads. - maxima = record.class.uncached do - tables.map do |table| + next_id = record.class.uncached do + historical_max = HISTORY_IDS.fetch(space, []).map do |table, column| quoted_table = connection.quote_table_name(table) + quoted_column = connection.quote_column_name(column) connection.select_value( - "SELECT id FROM #{ quoted_table } ORDER BY id DESC LIMIT 1 FOR UPDATE").to_i - end + "SELECT MAX(#{ quoted_column }) FROM #{ quoted_table }").to_i + end.max.to_i + [next_ids(connection, tables).max, historical_max + 1].max end - record.id = maxima.max + 1 + if record.id.present? + if record.id < next_id + raise ActiveRecord::RecordNotUnique, 'Shared ID is below the reserved position' + end + else + record.id = next_id + end + # The INSERT advances InnoDB's persistent counter, including explicit IDs. end + + def self.next_ids connection, tables + expiry = connection.select_value('SELECT @@SESSION.information_schema_stats_expiry').to_i + begin + connection.execute('SET SESSION information_schema_stats_expiry = 0') + quoted_tables = tables.map { connection.quote(_1) }.join(', ') + rows = connection.select_all(<<~SQL).to_a + SELECT TABLE_NAME, ENGINE, AUTO_INCREMENT + FROM information_schema.tables + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (#{ quoted_tables }) + SQL + persistent = rows.all? do |row| + row['ENGINE'] == 'InnoDB' && row['AUTO_INCREMENT'].to_i.positive? + end + unless rows.size == tables.size && persistent + raise ActiveRecord::ActiveRecordError, 'Persistent InnoDB counters are required' + end + rows.map { _1.fetch('AUTO_INCREMENT').to_i } + ensure + connection.execute("SET SESSION information_schema_stats_expiry = #{ expiry }") + end + end + private_class_method :next_ids end diff --git a/backend/app/services/tag_description_operation.rb b/backend/app/services/tag_description_operation.rb index 69020dd..80284e8 100644 --- a/backend/app/services/tag_description_operation.rb +++ b/backend/app/services/tag_description_operation.rb @@ -40,15 +40,14 @@ class TagDescriptionOperation def self.record_version! description:, body:, user: creating = description.new_record? version_no = creating ? 1 : description.version_no + 1 - version = TagDescriptionVersion.create!( + description.created_by_user_id = user.id if creating + description.update!(body:, version_no:, updated_by_user_id: user.id) + TagDescriptionVersion.create!( tag_id: description.tag_id, language_code: description.language_code, version_no:, event_type: creating ? 'create' : 'update', body:, source_language_code: description.source_language_code, source_version_no: description.source_version_no, created_by_user_id: user.id, created_at: Time.current) - description.created_by_user_id = user.id if creating - description.update!(body:, version_no:, updated_by_user_id: user.id) - version end private_class_method :record_version! diff --git a/backend/app/services/tag_group_operation.rb b/backend/app/services/tag_group_operation.rb index b565486..6cd506c 100644 --- a/backend/app/services/tag_group_operation.rb +++ b/backend/app/services/tag_group_operation.rb @@ -1,7 +1,8 @@ class TagGroupOperation MAX_PLACEMENT_DEPTH = 5 - def self.create!(name:, description:, members:, placements:, actor:) + def self.create!(name:, description:, members:, placements:, actor:, + require_compatibility: false) ApplicationRecord.transaction do group = TagGroup.new(version_no: 1, created_by_user: actor, updated_by_user: actor) @@ -10,27 +11,40 @@ class TagGroupOperation description: attributes.fetch(:description)) group.save! persist_structure!(group:, attributes:, actor:) + validate_compatibility!(group) if require_compatibility persist_version!(group:, attributes:, actor:, event_type: 'create') group end end - def self.update!(group:, name:, description:, members:, placements:, actor:) + def self.update!(group:, name:, description:, members:, placements:, actor:, + require_compatibility: false) ApplicationRecord.transaction do group.lock! attributes = prepare!(group:, name:, description:, members:, placements:) - next group if unchanged?(group, attributes) + if unchanged?(group, attributes) + validate_compatibility!(group) if require_compatibility + next group + end group.update!(name: attributes.fetch(:name), description: attributes.fetch(:description), version_no: group.version_no + 1, updated_by_user: actor) persist_structure!(group:, attributes:, actor:) + validate_compatibility!(group) if require_compatibility persist_version!(group:, attributes:, actor:, event_type: 'update') group end end + def self.validate_compatibility! group + TagGroupMarkdownRenderer.render(TagGroupRepr.one(group)) + rescue TagGroupMarkdownRenderer::UnsupportedStructure => error + invalid!(group, :base, error.message) + end + private_class_method :validate_compatibility! + def self.prepare!(group:, name:, description:, members:, placements:) record = group normalized_name = name.to_s.strip