From c1d9e3638baffccebd8823bd421773a5dd7b7707 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 4 Aug 2026 02:12:47 +0900 Subject: [PATCH] #354 --- ...27123600_add_tags_json_to_post_versions.rb | 279 ++++++++++++++++++ backend/db/schema.rb | 3 +- 2 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 backend/db/migrate/20260727123600_add_tags_json_to_post_versions.rb diff --git a/backend/db/migrate/20260727123600_add_tags_json_to_post_versions.rb b/backend/db/migrate/20260727123600_add_tags_json_to_post_versions.rb new file mode 100644 index 0000000..d306715 --- /dev/null +++ b/backend/db/migrate/20260727123600_add_tags_json_to_post_versions.rb @@ -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 diff --git a/backend/db/schema.rb b/backend/db/schema.rb index c515aee..d4a82c7 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # 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| t.string "name", 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 "thumbnail_base", limit: 2000 t.text "tags", null: false + t.json "tags_json", null: false t.text "parent_post_ids", null: false t.datetime "original_created_from" t.datetime "original_created_before"