このコミットが含まれているのは:
@@ -802,7 +802,8 @@ class TagsController < ApplicationController
|
|||||||
affected_tags << alias_tag_name&.tag
|
affected_tags << alias_tag_name&.tag
|
||||||
end
|
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,
|
TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user,
|
||||||
language_code: locale.language_code)
|
language_code: locale.language_code)
|
||||||
end
|
end
|
||||||
@@ -821,7 +822,10 @@ class TagsController < ApplicationController
|
|||||||
alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false)
|
alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false)
|
||||||
end
|
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,
|
record_tag_version!(affected_tag, event_type: :update,
|
||||||
created_by_user: current_user,
|
created_by_user: current_user,
|
||||||
language_code: locale.language_code)
|
language_code: locale.language_code)
|
||||||
|
|||||||
@@ -135,10 +135,10 @@ class WikiPagesController < ApplicationController
|
|||||||
name = title.delete_prefix(TagGroupRepr::PREFIX)
|
name = title.delete_prefix(TagGroupRepr::PREFIX)
|
||||||
parsed = TagGroupMarkdownParser.parse(body:, group: nil)
|
parsed = TagGroupMarkdownParser.parse(body:, group: nil)
|
||||||
group = TagGroupOperation.create!(
|
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)
|
version = current_group_version(group)
|
||||||
return render_group_representation(TagGroupRepr.one(group), version,
|
return render_group_representation(TagGroupRepr.one(group), version,
|
||||||
body: parsed.fetch(:compatibility_body),
|
|
||||||
status: :created)
|
status: :created)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -179,7 +179,8 @@ class WikiPagesController < ApplicationController
|
|||||||
name = title.delete_prefix(TagGroupRepr::PREFIX)
|
name = title.delete_prefix(TagGroupRepr::PREFIX)
|
||||||
parsed = TagGroupMarkdownParser.parse(body:, group:)
|
parsed = TagGroupMarkdownParser.parse(body:, group:)
|
||||||
TagGroupOperation.update!(
|
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
|
return head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -305,9 +305,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.tag_names.update_all(tag_id: target_tag.id, primary_flg: false,
|
target_languages = target_tag.tag_names.where(primary_flg: true).pluck(:language_code)
|
||||||
auto_generated: false,
|
source_tag.tag_names.where(language_code: target_languages)
|
||||||
updated_at: Time.current)
|
.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!
|
source_tag.destroy!
|
||||||
|
|
||||||
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
|
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
class TagDescriptionVersion < ApplicationRecord
|
class TagDescriptionVersion < ApplicationRecord
|
||||||
;
|
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class TagGroup < ApplicationRecord
|
|||||||
has_many :placements, -> { order(:position) },
|
has_many :placements, -> { order(:position) },
|
||||||
class_name: 'TagGroupPlacement', dependent: :delete_all
|
class_name: 'TagGroupPlacement', dependent: :delete_all
|
||||||
has_many :versions, -> { order(:version_no) },
|
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 :name, presence: true, uniqueness: true, length: { maximum: 255 }
|
||||||
validates :version_no, numericality: { only_integer: true, greater_than: 0 }
|
validates :version_no, numericality: { only_integer: true, greater_than: 0 }
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
class TagGroupVersion < ApplicationRecord
|
class TagGroupVersion < ApplicationRecord
|
||||||
include VersionRecord
|
include VersionRecord
|
||||||
|
|
||||||
|
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||||
|
|
||||||
belongs_to :tag_group
|
belongs_to :tag_group
|
||||||
|
|
||||||
scope :ordered, -> { order(:version_no) }
|
scope :ordered, -> { order(:version_no) }
|
||||||
|
|||||||
@@ -16,31 +16,36 @@ class TagNameSanitisationRule < ApplicationRecord
|
|||||||
|
|
||||||
def apply!
|
def apply!
|
||||||
Language.find_each do |language|
|
Language.find_each do |language|
|
||||||
TagName.where(language:).find_each do |tn|
|
TagName.where(language:).find_each do |candidate|
|
||||||
name = sanitise(tn.name)
|
|
||||||
next if name == tn.name
|
|
||||||
|
|
||||||
TagName.transaction do
|
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:)
|
existing_tn = TagName.find_by(language:, name:)
|
||||||
if existing_tn
|
source_tag = tn.tag
|
||||||
unless existing_tn.primary_flg
|
target_tag = existing_tn ? existing_tn.tag : source_tag
|
||||||
existing_tn = TagName.find_by!(language:,
|
[source_tag, target_tag].uniq(&:id).sort_by(&:id).each(&:lock!)
|
||||||
tag_id: existing_tn.tag_id,
|
TagVersioning.ensure_snapshot!(source_tag, created_by_user: nil)
|
||||||
primary_flg: true)
|
|
||||||
|
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
|
end
|
||||||
next if existing_tn.id == tn.id
|
else
|
||||||
|
# TagName 側の自動サニタイズを回避
|
||||||
existing_tag = existing_tn.tag
|
tn.update_columns(name:, updated_at: Time.current)
|
||||||
source_tag = tn.tag
|
|
||||||
|
|
||||||
Tag.merge_tags!(existing_tag, source_tag) if existing_tag != source_tag
|
|
||||||
tn.destroy!
|
|
||||||
|
|
||||||
next
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# TagName 側の自動サニタイズを回避
|
TagVersioning.record!(target_tag, event_type: :update, created_by_user: nil)
|
||||||
tn.update_columns(name:, updated_at: Time.current)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ require 'digest'
|
|||||||
class SharedResourceIdAllocator
|
class SharedResourceIdAllocator
|
||||||
SPACES = {
|
SPACES = {
|
||||||
'tags' => ['tags', 'external_tags'],
|
'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
|
LOCK_TIMEOUT = 10
|
||||||
|
|
||||||
def self.assign! record
|
def self.assign! record
|
||||||
@@ -13,7 +20,9 @@ class SharedResourceIdAllocator
|
|||||||
|
|
||||||
connection = record.class.connection
|
connection = record.class.connection
|
||||||
transaction = record.class.current_transaction
|
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
|
database = connection.pool.db_config.database
|
||||||
name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }"
|
name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }"
|
||||||
@@ -41,16 +50,45 @@ class SharedResourceIdAllocator
|
|||||||
raise
|
raise
|
||||||
end
|
end
|
||||||
|
|
||||||
return if record.id.present?
|
next_id = record.class.uncached do
|
||||||
|
historical_max = HISTORY_IDS.fetch(space, []).map do |table, column|
|
||||||
# Locking reads see committed maxima even after earlier repeatable reads.
|
|
||||||
maxima = record.class.uncached do
|
|
||||||
tables.map do |table|
|
|
||||||
quoted_table = connection.quote_table_name(table)
|
quoted_table = connection.quote_table_name(table)
|
||||||
|
quoted_column = connection.quote_column_name(column)
|
||||||
connection.select_value(
|
connection.select_value(
|
||||||
"SELECT id FROM #{ quoted_table } ORDER BY id DESC LIMIT 1 FOR UPDATE").to_i
|
"SELECT MAX(#{ quoted_column }) FROM #{ quoted_table }").to_i
|
||||||
end
|
end.max.to_i
|
||||||
|
[next_ids(connection, tables).max, historical_max + 1].max
|
||||||
end
|
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
|
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
|
end
|
||||||
|
|||||||
@@ -40,15 +40,14 @@ class TagDescriptionOperation
|
|||||||
def self.record_version! description:, body:, user:
|
def self.record_version! description:, body:, user:
|
||||||
creating = description.new_record?
|
creating = description.new_record?
|
||||||
version_no = creating ? 1 : description.version_no + 1
|
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,
|
tag_id: description.tag_id, language_code: description.language_code,
|
||||||
version_no:, event_type: creating ? 'create' : 'update', body:,
|
version_no:, event_type: creating ? 'create' : 'update', body:,
|
||||||
source_language_code: description.source_language_code,
|
source_language_code: description.source_language_code,
|
||||||
source_version_no: description.source_version_no,
|
source_version_no: description.source_version_no,
|
||||||
created_by_user_id: user.id, created_at: Time.current)
|
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
|
end
|
||||||
private_class_method :record_version!
|
private_class_method :record_version!
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
class TagGroupOperation
|
class TagGroupOperation
|
||||||
MAX_PLACEMENT_DEPTH = 5
|
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
|
ApplicationRecord.transaction do
|
||||||
group = TagGroup.new(version_no: 1, created_by_user: actor,
|
group = TagGroup.new(version_no: 1, created_by_user: actor,
|
||||||
updated_by_user: actor)
|
updated_by_user: actor)
|
||||||
@@ -10,27 +11,40 @@ class TagGroupOperation
|
|||||||
description: attributes.fetch(:description))
|
description: attributes.fetch(:description))
|
||||||
group.save!
|
group.save!
|
||||||
persist_structure!(group:, attributes:, actor:)
|
persist_structure!(group:, attributes:, actor:)
|
||||||
|
validate_compatibility!(group) if require_compatibility
|
||||||
persist_version!(group:, attributes:, actor:, event_type: 'create')
|
persist_version!(group:, attributes:, actor:, event_type: 'create')
|
||||||
group
|
group
|
||||||
end
|
end
|
||||||
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
|
ApplicationRecord.transaction do
|
||||||
group.lock!
|
group.lock!
|
||||||
attributes = prepare!(group:, name:, description:, members:, placements:)
|
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),
|
group.update!(name: attributes.fetch(:name),
|
||||||
description: attributes.fetch(:description),
|
description: attributes.fetch(:description),
|
||||||
version_no: group.version_no + 1,
|
version_no: group.version_no + 1,
|
||||||
updated_by_user: actor)
|
updated_by_user: actor)
|
||||||
persist_structure!(group:, attributes:, actor:)
|
persist_structure!(group:, attributes:, actor:)
|
||||||
|
validate_compatibility!(group) if require_compatibility
|
||||||
persist_version!(group:, attributes:, actor:, event_type: 'update')
|
persist_version!(group:, attributes:, actor:, event_type: 'update')
|
||||||
group
|
group
|
||||||
end
|
end
|
||||||
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:)
|
def self.prepare!(group:, name:, description:, members:, placements:)
|
||||||
record = group
|
record = group
|
||||||
normalized_name = name.to_s.strip
|
normalized_name = name.to_s.strip
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする