diff --git a/backend/db/migrate/20260921040000_create_external_tags.rb b/backend/db/migrate/20260921040000_create_external_tags.rb new file mode 100644 index 0000000..8afecaa --- /dev/null +++ b/backend/db/migrate/20260921040000_create_external_tags.rb @@ -0,0 +1,54 @@ +class CreateExternalTags < ActiveRecord::Migration[8.0] + def up + create_table :external_tags do |t| + t.string :platform, limit: 16, null: false + t.string :name, limit: 255, null: false + t.integer :post_count, null: false, default: 0 + t.datetime :created_at, null: false + + t.index [:platform, :name], unique: true + end + + execute <<~SQL + INSERT INTO + external_tags(id, platform, name, post_count, created_at) + SELECT + t.id + , 'nico' AS platform + , SUBSTR(tn.name, 6) AS name + , t.post_count + , t.created_at + FROM + tags t + INNER JOIN + tag_names tn + ON + tn.id = t.tag_name_id + AND t.category = 'nico' + SQL + + execute <<~SQL + INSERT INTO + external_tags(id, platform, name, post_count, created_at) + SELECT + ntv.tag_id + , 'nico' AS platform + , SUBSTR(ntv.name, 6) AS name + , 0 AS post_count + , ntv.created_at + FROM + nico_tag_versions ntv + LEFT JOIN + external_tags et + ON + et.id = ntv.tag_id + WHERE + ntv.version_no = 1 + AND et.id IS NULL + SQL + end + + def down + drop_table :external_tags + end +end diff --git a/backend/db/migrate/20260921050000_create_post_external_tags.rb b/backend/db/migrate/20260921050000_create_post_external_tags.rb new file mode 100644 index 0000000..3b2efc5 --- /dev/null +++ b/backend/db/migrate/20260921050000_create_post_external_tags.rb @@ -0,0 +1,29 @@ +class CreatePostExternalTags < ActiveRecord::Migration[8.0] + def up + create_table :post_external_tags, primary_key: [:post_id, :external_tag_id] do |t| + t.references :post, null: false, index: false, foreign_key: true + t.references :external_tag, null: false, foreign_key: true + t.datetime :created_at, null: false + end + + execute <<~SQL + INSERT INTO + post_external_tags(post_id, external_tag_id, created_at) + SELECT + pt.post_id + , pt.tag_id AS external_tag_id + , pt.created_at + FROM + post_tags pt + INNER JOIN + tags t + ON + pt.tag_id = t.id + AND t.category = 'nico' + SQL + end + + def down + drop_table :post_external_tags + end +end diff --git a/backend/db/migrate/20260921060000_change_foreign_key_on_nico_tag_relations.rb b/backend/db/migrate/20260921060000_change_foreign_key_on_nico_tag_relations.rb new file mode 100644 index 0000000..ac24e0b --- /dev/null +++ b/backend/db/migrate/20260921060000_change_foreign_key_on_nico_tag_relations.rb @@ -0,0 +1,6 @@ +class ChangeForeignKeyOnNicoTagRelations < ActiveRecord::Migration[8.0] + def change + remove_foreign_key :nico_tag_relations, :tags, column: :nico_tag_id + add_foreign_key :nico_tag_relations, :external_tags, column: :nico_tag_id + end +end diff --git a/backend/db/migrate/20260921230000_migrate_external_tags.rb b/backend/db/migrate/20260921230000_migrate_external_tags.rb new file mode 100644 index 0000000..f410d67 --- /dev/null +++ b/backend/db/migrate/20260921230000_migrate_external_tags.rb @@ -0,0 +1,199 @@ +class MigrateExternalTags < ActiveRecord::Migration[8.0] + class MigrationPostVersion < ActiveRecord::Base + self.table_name = 'post_versions' + end + + def up + x = connection.select_value(<<~SQL) + SELECT + COUNT(0) + FROM + post_tag_sections pts + INNER JOIN + tags t + ON + t.id = pts.tag_id + WHERE + t.category = 'nico' + SQL + if x > 0 + raise "post_tag_sections に #{ x } 件のチンカスがあります!" + end + + x = connection.select_value(<<~SQL) + SELECT + COUNT(0) + FROM + materials m + INNER JOIN + tags t + ON + t.id = m.tag_id + WHERE + t.category = 'nico' + SQL + if x > 0 + raise "materials に #{ x } 件のチンカスがあります!" + end + + x = connection.select_value(<<~SQL) + SELECT + COUNT(0) + FROM + tag_implications ti + INNER JOIN + tags t + ON + t.id = ti.tag_id + OR t.id = ti.parent_tag_id + WHERE + t.category = 'nico' + SQL + if x > 0 + raise "tag_implications に #{ x } 件のチンカスがあります!" + end + + execute <<~SQL + DELETE + ts + FROM + tag_similarities ts + INNER JOIN + tags t + ON + t.category = 'nico' + AND (t.id = ts.tag_id + OR t.id = ts.target_tag_id) + SQL + + execute <<~SQL + DELETE + tset + FROM + theatre_skip_event_tags tset + INNER JOIN + tags t + ON + t.category = 'nico' + AND t.id = tset.tag_id + SQL + + remove_check_constraint :post_versions, name: 'chk_post_versions_tags_json_schema' + + say_with_time 'Migrate post_versions.tags_json' do + count = 0 + + MigrationPostVersion.find_each(batch_size: 500) do |version| + tags = version.tags_json.map do |tag| + if tag.fetch('category') == 'nico' + { 'external_tag_id' => tag.fetch('id') } + else + { 'tag_id' => tag.fetch('id'), + 'version_no' => tag.fetch('version_no'), + 'name' => tag.fetch('name'), + 'category' => tag.fetch('category'), + 'sections' => tag.fetch('sections') } + end + end + + version.update_columns(tags_json: tags) + count += 1 + end + + count + end + + add_tags_json_constraint! + + tag_name_ids = connection.select_values(<<~SQL) + SELECT + tag_name_id + FROM + tags + WHERE + category = 'nico' + SQL + + connection.transaction do + execute <<~SQL + DELETE + pt + FROM + post_tags pt + INNER JOIN + tags t + ON + t.category = 'nico' + AND t.id = pt.tag_id + SQL + + execute <<~SQL + DELETE + FROM + tags + WHERE + category = 'nico' + SQL + + unless tag_name_ids.empty? + execute <<~SQL + DELETE + FROM + tag_names + WHERE + id IN (#{ tag_name_ids.join(', ') }) + SQL + end + end + end + + def down + raise ActiveRecord::IrreversibleMigration, '戻せません.' + end + + private + + def add_tags_json_constraint! + schema = { type: 'array', + items: { oneOf: [internal_tag_schema, external_tag_schema] } } + + quoted_schema = connection.quote(JSON.generate(schema)) + + add_check_constraint :post_versions, + "JSON_SCHEMA_VALID(#{ quoted_schema }, tags_json)", + name: 'chk_post_versions_tags_json_schema' + end + + def internal_tag_schema + { type: 'object', + properties: { tag_id: { type: 'integer', minimum: 1 }, + version_no: { type: 'integer', minimum: 1 }, + name: { type: 'string', minLength: 1 }, + category: { type: 'string', + enum: ['deerjikist', + 'meme', + 'character', + 'general', + 'material', + 'meta'] }, + sections: sections_schema }, + required: ['tag_id', 'version_no', 'sections'], + additionalProperties: false } + end + + def external_tag_schema + { type: 'object', + properties: { external_tag_id: { type: 'integer', minimum: 1 } }, + required: ['external_tag_id'], + additionalProperties: false } + end + + def sections_schema + { type: 'array', + items: { type: 'object', + properties: { begin_ms: { type: 'integer', minimum: 0 }, + end_ms: { type: ['integer', 'null'], minimum: 0 } }, + required: ['begin_ms', 'end_ms'], + additionalProperties: false } } + end +end