このコミットが含まれているのは:
@@ -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
|
||||||
生成ファイル
+2
-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_07_13_000000) do
|
ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) 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
|
||||||
@@ -349,6 +349,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
|
|||||||
t.string "url", limit: 768, null: false
|
t.string "url", limit: 768, null: false
|
||||||
t.string "thumbnail_base", limit: 2000
|
t.string "thumbnail_base", limit: 2000
|
||||||
t.text "tags", null: false
|
t.text "tags", null: false
|
||||||
|
t.json "tags_json", null: false
|
||||||
t.text "parent_post_ids", null: false
|
t.text "parent_post_ids", null: false
|
||||||
t.datetime "original_created_from"
|
t.datetime "original_created_from"
|
||||||
t.datetime "original_created_before"
|
t.datetime "original_created_before"
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする