このコミットが含まれているのは:
@@ -0,0 +1,279 @@
|
||||
class AddTagsJsonToPostVersions < ActiveRecord::Migration[8.0]
|
||||
RESOLUTION_GRACE = 1.second
|
||||
SECTION_LITERAL_PATTERN = /\[[^\[\]\s]*-[^\[\]\s]*\]\z/
|
||||
|
||||
class MigrationPostVersion < ActiveRecord::Base
|
||||
self.table_name = 'post_versions'
|
||||
end
|
||||
|
||||
class MigrationTag < ActiveRecord::Base
|
||||
self.table_name = 'tags'
|
||||
end
|
||||
|
||||
class MigrationTagName < ActiveRecord::Base
|
||||
self.table_name = 'tag_names'
|
||||
end
|
||||
|
||||
class MigrationTagVersion < ActiveRecord::Base
|
||||
self.table_name = 'tag_versions'
|
||||
end
|
||||
|
||||
class MigrationNicoTagVersion < ActiveRecord::Base
|
||||
self.table_name = 'nico_tag_versions'
|
||||
end
|
||||
|
||||
def up
|
||||
add_column :post_versions, :tags_json, :json, after: :tags
|
||||
MigrationPostVersion.reset_column_information
|
||||
|
||||
backfill_missing_initial_tag_versions!
|
||||
intervals_by_name = build_intervals_by_name
|
||||
|
||||
say_with_time 'Backfilling post_versions.tags_json' do
|
||||
MigrationPostVersion
|
||||
.where(tags_json: nil)
|
||||
.find_each(batch_size: 500) do |version|
|
||||
version.update_columns(
|
||||
tags_json: build_tags_json(version, intervals_by_name))
|
||||
end
|
||||
end
|
||||
|
||||
assert_backfill_complete!
|
||||
change_column_null :post_versions, :tags_json, false
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :post_versions, :tags_json
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def backfill_missing_initial_tag_versions!
|
||||
say_with_time 'Backfilling missing initial tag versions' do
|
||||
tag_rows = missing_initial_version_rows(MigrationTagVersion, nico: false)
|
||||
nico_rows = missing_initial_version_rows(MigrationNicoTagVersion, nico: true)
|
||||
|
||||
MigrationTagVersion.insert_all!(tag_rows) if tag_rows.any?
|
||||
MigrationNicoTagVersion.insert_all!(nico_rows) if nico_rows.any?
|
||||
|
||||
tag_rows.length + nico_rows.length
|
||||
end
|
||||
end
|
||||
|
||||
def missing_initial_version_rows version_class, nico:
|
||||
first_versions =
|
||||
version_class
|
||||
.order(:tag_id, :version_no)
|
||||
.to_a
|
||||
.group_by(&:tag_id)
|
||||
.transform_values(&:first)
|
||||
rows = []
|
||||
|
||||
MigrationTag.find_each do |tag|
|
||||
next if (tag.category == 'nico') != nico
|
||||
|
||||
first_version = first_versions[tag.id]
|
||||
next unless first_version
|
||||
next if valid_initial_version?(first_version)
|
||||
|
||||
assert_inferable_initial_version!(tag, first_version)
|
||||
rows << initial_version_row(tag, first_version, nico:)
|
||||
end
|
||||
|
||||
rows
|
||||
end
|
||||
|
||||
def valid_initial_version? version
|
||||
version.version_no == 1 && version.event_type == 'create'
|
||||
end
|
||||
|
||||
def assert_inferable_initial_version! tag, version
|
||||
inferable =
|
||||
version.version_no == 2 &&
|
||||
version.event_type == 'discard' &&
|
||||
tag.created_at < version.created_at
|
||||
return if inferable
|
||||
|
||||
details = [
|
||||
"tag_id=#{ tag.id }",
|
||||
"version_no=#{ version.version_no }",
|
||||
"event_type=#{ version.event_type.inspect }",
|
||||
"tag_created_at=#{ tag.created_at.iso8601(6) }",
|
||||
"version_created_at=#{ version.created_at.iso8601(6) }"]
|
||||
|
||||
raise "Cannot infer initial tag version: #{ details.join(', ') }"
|
||||
end
|
||||
|
||||
def initial_version_row tag, discard_version, nico:
|
||||
row = {
|
||||
tag_id: tag.id,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
name: discard_version.name,
|
||||
created_at: tag.created_at,
|
||||
created_by_user_id: nil }
|
||||
|
||||
if nico
|
||||
return row.merge(linked_tags: discard_version.linked_tags)
|
||||
end
|
||||
|
||||
row.merge(
|
||||
category: discard_version.category,
|
||||
aliases: discard_version.aliases,
|
||||
parent_tag_ids: discard_version.parent_tag_ids,
|
||||
deprecated_at: discard_version.deprecated_at)
|
||||
end
|
||||
|
||||
def build_intervals_by_name
|
||||
intervals_by_name = Hash.new { |hash, name| hash[name] = [] }
|
||||
versions_by_kind = {
|
||||
tag: versions_by_tag_id(MigrationTagVersion),
|
||||
nico: versions_by_tag_id(MigrationNicoTagVersion) }
|
||||
current_names = current_names_by_tag_id
|
||||
|
||||
MigrationTag.find_each do |tag|
|
||||
nico = tag.category == 'nico'
|
||||
kind = nico ? :nico : :tag
|
||||
versions = versions_by_kind.fetch(kind).fetch(tag.id, [])
|
||||
|
||||
intervals_for(
|
||||
tag,
|
||||
versions,
|
||||
current_name: current_names.fetch(tag.id),
|
||||
nico:).each do |interval|
|
||||
name = interval.delete(:name)
|
||||
intervals_by_name[name] << interval
|
||||
end
|
||||
end
|
||||
|
||||
intervals_by_name
|
||||
end
|
||||
|
||||
def versions_by_tag_id version_class
|
||||
version_class
|
||||
.order(:tag_id, :version_no)
|
||||
.to_a
|
||||
.group_by(&:tag_id)
|
||||
end
|
||||
|
||||
def current_names_by_tag_id
|
||||
MigrationTagName
|
||||
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
|
||||
.pluck('tags.id', 'tag_names.name')
|
||||
.to_h
|
||||
end
|
||||
|
||||
def intervals_for tag, versions, current_name:, nico:
|
||||
if versions.empty?
|
||||
return [{
|
||||
name: current_name,
|
||||
tag_id: tag.id,
|
||||
version_no: tag.version_no,
|
||||
category: nico ? 'nico' : tag.category,
|
||||
from: tag.created_at,
|
||||
to: tag.discarded_at }]
|
||||
end
|
||||
|
||||
versions.each_with_index.filter_map do |version, index|
|
||||
next if version.event_type == 'discard'
|
||||
|
||||
{
|
||||
name: version.name,
|
||||
tag_id: tag.id,
|
||||
version_no: version.version_no,
|
||||
category: nico ? 'nico' : version.category,
|
||||
from: version.created_at,
|
||||
to: versions[index + 1]&.created_at || tag.discarded_at }
|
||||
end
|
||||
end
|
||||
|
||||
def build_tags_json version, intervals_by_name
|
||||
entries = version.tags.to_s.split.map do |literal|
|
||||
name = tag_name_from_literal(literal)
|
||||
interval = resolve_tag!(intervals_by_name.fetch(name, []), name:, version:)
|
||||
|
||||
{ 'id' => interval.fetch(:tag_id),
|
||||
'version_no' => interval.fetch(:version_no),
|
||||
'name' => name,
|
||||
'category' => interval.fetch(:category),
|
||||
'sections' => [] }
|
||||
end
|
||||
|
||||
assert_unique_tag_ids!(version, entries)
|
||||
|
||||
entries.sort_by { |entry| entry.fetch('id') }
|
||||
end
|
||||
|
||||
def tag_name_from_literal literal
|
||||
name = literal.dup
|
||||
name.sub!(SECTION_LITERAL_PATTERN, '') while name.match?(
|
||||
SECTION_LITERAL_PATTERN)
|
||||
|
||||
if name.empty? || name.include?('[') || name.include?(']')
|
||||
raise "Invalid legacy tag literal: #{ literal.inspect }"
|
||||
end
|
||||
|
||||
name
|
||||
end
|
||||
|
||||
def resolve_tag! intervals, name:, version:
|
||||
time = version.created_at
|
||||
candidates = intervals.select do |interval|
|
||||
interval.fetch(:from) <= time &&
|
||||
(interval[:to].nil? || time < interval.fetch(:to))
|
||||
end
|
||||
|
||||
candidates = future_candidates(intervals, time) if candidates.empty?
|
||||
|
||||
return candidates.first if candidates.one?
|
||||
|
||||
candidate_versions = candidates.map do |candidate|
|
||||
[candidate.fetch(:tag_id), candidate.fetch(:version_no)]
|
||||
end
|
||||
details = [
|
||||
"post_version_id=#{ version.id }",
|
||||
"post_id=#{ version.post_id }",
|
||||
"name=#{ name.inspect }",
|
||||
"created_at=#{ time.iso8601(6) }",
|
||||
"candidates=#{ candidate_versions.inspect }"].join(', ')
|
||||
|
||||
raise "Could not resolve tag snapshot: #{ details }"
|
||||
end
|
||||
|
||||
def future_candidates intervals, time
|
||||
candidates = intervals.select do |interval|
|
||||
interval.fetch(:from) > time &&
|
||||
interval.fetch(:from) <= time + RESOLUTION_GRACE
|
||||
end
|
||||
return [] if candidates.empty?
|
||||
|
||||
nearest_from = candidates.map { |interval| interval.fetch(:from) }.min
|
||||
|
||||
candidates.select do |interval|
|
||||
interval.fetch(:from) == nearest_from
|
||||
end
|
||||
end
|
||||
|
||||
def assert_unique_tag_ids! version, entries
|
||||
duplicate_tag_ids =
|
||||
entries
|
||||
.map { |entry| entry.fetch('id') }
|
||||
.tally
|
||||
.select { |_tag_id, count| count > 1 }
|
||||
.keys
|
||||
return if duplicate_tag_ids.empty?
|
||||
|
||||
details = [
|
||||
"post_version_id=#{ version.id }",
|
||||
"duplicate_tag_ids=#{ duplicate_tag_ids.inspect }"].join(', ')
|
||||
|
||||
raise "Duplicate tag IDs: #{ details }"
|
||||
end
|
||||
|
||||
def assert_backfill_complete!
|
||||
missing_count = MigrationPostVersion.where(tags_json: nil).count
|
||||
return if missing_count.zero?
|
||||
|
||||
raise "#{ missing_count } post versions were not backfilled"
|
||||
end
|
||||
end
|
||||
新しい課題から参照
ユーザをブロックする