diff --git a/backend/db/migrate/20261008000000_create_tag_groups.rb b/backend/db/migrate/20261008000000_create_tag_groups.rb new file mode 100644 index 0000000..f113502 --- /dev/null +++ b/backend/db/migrate/20261008000000_create_tag_groups.rb @@ -0,0 +1,149 @@ +class CreateTagGroups < ActiveRecord::Migration[8.0] + def change + # 翻訳系は上流の Translation リソースが未完のため保留 + + create_table :tag_groups do |t| + t.integer :version_no, null: false + t.string :name, limit: 255, null: false + t.text :description + t.references :created_by_user, foreign_key: { to_table: :users } + t.references :updated_by_user, foreign_key: { to_table: :users } + t.timestamps + + t.check_constraint 'version_no > 0', name: 'chk_tag_groups_version_no_positive' + t.index :name, unique: true + end + + create_table :tag_group_tags, primary_key: [:tag_group_id, :tag_id] do |t| + t.references :tag_group, null: false, foreign_key: true, index: false + t.references :tag, null: false, foreign_key: true + t.references :created_by_user, foreign_key: { to_table: :users } + t.datetime :created_at, null: false + end + + create_table :tag_group_groups, primary_key: [:tag_group_id, :child_tag_group_id] do |t| + t.references :tag_group, null: false, foreign_key: true, index: false + t.references :child_tag_group, + null: false, foreign_key: { to_table: :tag_groups } + t.references :created_by_user, foreign_key: { to_table: :users } + t.datetime :created_at, null: false + + t.check_constraint 'tag_group_id <> child_tag_group_id', + name: 'chk_tag_group_groups_tag_group_id_not_child_tag_group_id' + end + + create_table :tag_group_placements, primary_key: [:tag_group_id, :position] do |t| + t.references :tag_group, null: false, foreign_key: true, index: false + t.integer :position, null: false + t.integer :parent_position + t.string :section_name, limit: 255 + t.text :description + t.references :tag + t.references :child_tag_group + t.references :created_by_user, foreign_key: { to_table: :users } + t.references :updated_by_user, foreign_key: { to_table: :users } + t.timestamps + + t.foreign_key :tag_group_placements, + column: [:tag_group_id, :parent_position], + primary_key: [:tag_group_id, :position] + + t.foreign_key :tag_group_tags, + column: [:tag_group_id, :tag_id], + primary_key: [:tag_group_id, :tag_id] + + t.foreign_key :tag_group_groups, + column: [:tag_group_id, :child_tag_group_id], + primary_key: [:tag_group_id, :child_tag_group_id] + + t.check_constraint 'position > parent_position', + name: 'chk_tag_group_placements_position_gt_parent_position' + + t.check_constraint ('((section_name IS NOT NULL AND tag_id IS NULL AND child_tag_group_id IS NULL) OR ' + + '(section_name IS NULL AND tag_id IS NOT NULL AND child_tag_group_id IS NULL) OR ' + + '(section_name IS NULL AND tag_id IS NULL AND child_tag_group_id IS NOT NULL)) AND ' + + '(section_name IS NOT NULL OR description IS NULL)'), + name: 'chk_tag_group_placements_references_exclusive' + end + + create_table :tag_group_versions do |t| + t.references :tag_group, null: false + t.integer :version_no, null: false + t.string :event_type, null: false + t.string :name, limit: 255, null: false + t.text :description + t.json :members, null: false + t.json :placements, null: false + t.references :created_by_user + t.datetime :created_at, null: false + + t.check_constraint 'version_no > 0', + name: 'chk_tag_group_versions_version_no_positive' + t.index [:tag_group_id, :version_no], unique: true + + add_members_constraint!(t) + add_placements_constraint!(t) + end + end + + private + + def add_members_constraint! t + schema = { type: 'array', + uniqueItems: true, + items: member_schema } + + quoted_schema = connection.quote(JSON.generate(schema)) + + t.check_constraint "JSON_SCHEMA_VALID(#{ quoted_schema }, members)", + name: 'chk_tag_group_versions_members_schema' + end + + def member_schema + { oneOf: [tag_member_schema, group_member_schema] } + end + + def tag_member_schema + { type: 'object', + required: ['tag_id', 'version_no'], + properties: { tag_id: { type: 'integer', minimum: 1 }, + version_no: { type: 'integer', minimum: 1 } }, + additionalProperties: false } + end + + def group_member_schema + { type: 'object', + required: ['tag_group_id', 'version_no'], + properties: { tag_group_id: { type: 'integer', minimum: 1 }, + version_no: { type: 'integer', minimum: 1 } }, + additionalProperties: false } + end + + def add_placements_constraint! t + schema = { type: 'array', items: placement_schema(5) } + + quoted_schema = connection.quote(JSON.generate(schema)) + + t.check_constraint "JSON_SCHEMA_VALID(#{ quoted_schema }, placements)", + name: 'chk_tag_group_versions_placements_schema' + end + + def placement_schema rest + { type: 'object', + required: ['children'], + properties: { section_name: { type: 'string' }, + description: { type: ['string', 'null'] }, + tag_id: { type: 'integer', minimum: 1 }, + tag_group_id: { type: 'integer', minimum: 1 }, + children: if rest > 1 + { type: 'array', items: placement_schema(rest - 1) } + else + { type: 'array', maxItems: 0 } + end }, + oneOf: [{ required: ['section_name'] }, + { required: ['tag_id'] }, + { required: ['tag_group_id'] }], + dependencies: { description: ['section_name'] }, + additionalProperties: false } + end +end diff --git a/backend/db/migrate/20261008010000_migrate_tag_groups_from_wiki_pages.rb b/backend/db/migrate/20261008010000_migrate_tag_groups_from_wiki_pages.rb new file mode 100644 index 0000000..9e68d7e --- /dev/null +++ b/backend/db/migrate/20261008010000_migrate_tag_groups_from_wiki_pages.rb @@ -0,0 +1,518 @@ +class MigrateTagGroupsFromWikiPages < ActiveRecord::Migration[8.0] + PREFIX = 'タググループ:' + + def up + require 'json' + require 'time' + require 'kramdown' + require 'kramdown-parser-gfm' + + assert_empty_destination! + + pages = connection.select_all(<<~SQL).to_a + SELECT + wp.id + , wp.created_user_id + , wp.updated_user_id + , wp.created_at + , wp.updated_at + , wp.discarded_at + , tn.name + FROM + wiki_pages wp + INNER JOIN + tag_names tn + ON + tn.id = wp.tag_name_id + WHERE + tn.language_code = 'ja' + AND tn.name LIKE #{ quote("#{ PREFIX }%") } + ORDER BY + wp.id + SQL + + return if pages.empty? + + pages.each do |page| + if page['discarded_at'] + say "Wiki ##{ page.fetch('id') }: 破棄済みですが,Revision の移行を継続します." + end + end + + tag_ids = load_tag_ids + page_ids = pages.map { |page| page.fetch('id').to_i } + + revisions = connection.select_all(<<~SQL).to_a + SELECT + wr.id + , wr.wiki_page_id + , wr.kind + , wr.redirect_page_id + , wr.created_user_id + , wr.created_at + FROM + wiki_revisions wr + WHERE + wr.wiki_page_id IN (#{ page_ids.join(', ') }) + ORDER BY + wr.wiki_page_id + , wr.id + SQL + + revisions_by_page = revisions.group_by { |row| row.fetch('wiki_page_id').to_i } + + pages = pages.select do |page| + page_id = page.fetch('id').to_i + if revisions_by_page.fetch(page_id, []).empty? + # tag_groups.version_no > 0 のため,Revision 0 件の Wiki は作成しない. + say "Wiki ##{ page_id }: Revision が 0 件のため移行対象から除外します." + false + else + true + end + end + + return if pages.empty? + + group_ids = pages.to_h { |page| [page.fetch('name'), page.fetch('id').to_i] } + revision_history = pages.to_h do |page| + page_id = page.fetch('id').to_i + history = revisions_by_page.fetch(page_id).each_with_index.map do |revision, index| + { 'id' => revision.fetch('id').to_i, + 'version_no' => index + 1, + 'created_at' => revision.fetch('created_at') } + end + [page_id, history] + end + + tag_history = connection.select_all(<<~SQL).to_a.group_by { |row| row.fetch('tag_id').to_i } + SELECT + tag_id + , version_no + , created_at + FROM + tag_versions + ORDER BY + tag_id + , created_at + , version_no + SQL + + prepared = { } + pages.each do |page| + page_id = page.fetch('id').to_i + history = revisions_by_page.fetch(page_id) + + prepared[page_id] = history.each_with_index.map do |revision, index| + context = "Wiki ##{ page_id } / Revision ##{ revision.fetch('id') }" + body = revision_body(revision.fetch('id').to_i) + + if revision.fetch('kind').to_i == 1 + destination = revision['redirect_page_id'] + description = "旧 Wiki のリダイレクト先: Wiki ##{ destination }" + placements = [] + members = [] + say "#{ context }: リダイレクトを説明文として移行します." + else + begin + description, placements = parse_wiki(body, tag_ids, group_ids, context) + validate_tree!(placements, context) + members = build_members(placements, revision.fetch('created_at'), + tag_history, revision_history, context) + rescue StandardError => error + # 解析できない版も捨てず,当時の Markdown を説明文として保存する. + say "#{ context }: 構造化を保留し,本文を保存します (#{ error.message })." + description = body + placements = [] + members = [] + end + end + + { revision:, version_no: index + 1, description:, members:, placements:, body: } + end + end + + # 自己包含は禁止されてゐるため,当該版は原文の保存を優先する. + prepared.each do |group_id, versions| + current = versions.last + next unless current.fetch(:members).any? { |member| member['tag_group_id'] == group_id } + + say "Tag Group ##{ group_id }: 直接の自己参照を検出したため,現行構造化を保留します." + current[:description] = current.fetch(:body) + current[:members] = [] + current[:placements] = [] + end + + begin + validate_current_groups!(prepared) + rescue StandardError => error + say "Tag Group の包含関係に問題がありますが,移行を継続します (#{ error.message })." + end + + # 移行先の書込みはすべて検証の後.DDL は含めない. + connection.transaction do + pages.each do |page| + page_id = page.fetch('id').to_i + current = prepared.fetch(page_id).last + + insert_row('tag_groups', { + id: page_id, + # page.version_no ではなく,この Wiki に紐付く Revision の総数. + version_no: revisions_by_page.fetch(page_id).size, + name: page.fetch('name').delete_prefix(PREFIX), + description: current.fetch(:description), + created_by_user_id: page.fetch('created_user_id'), + updated_by_user_id: page.fetch('updated_user_id'), + created_at: page.fetch('created_at'), + updated_at: page.fetch('updated_at') }) + end + + pages.each do |page| + page_id = page.fetch('id').to_i + name = page.fetch('name').delete_prefix(PREFIX) + + prepared.fetch(page_id).each do |item| + revision = item.fetch(:revision) + + insert_row('tag_group_versions', { + id: revision.fetch('id'), + tag_group_id: page_id, + version_no: item.fetch(:version_no), + event_type: item.fetch(:version_no) == 1 ? 'create' : 'update', + name:, + description: item.fetch(:description), + members: JSON.generate(item.fetch(:members)), + placements: JSON.generate(item.fetch(:placements)), + created_by_user_id: revision.fetch('created_user_id'), + created_at: revision.fetch('created_at') }) + end + end + + pages.each do |page| + page_id = page.fetch('id').to_i + current = prepared.fetch(page_id).last + revision = current.fetch(:revision) + + current.fetch(:members).each do |member| + if member.key?('tag_id') + insert_row('tag_group_tags', { + tag_group_id: page_id, + tag_id: member.fetch('tag_id'), + created_by_user_id: revision.fetch('created_user_id'), + created_at: revision.fetch('created_at') }) + else + insert_row('tag_group_groups', { + tag_group_id: page_id, + child_tag_group_id: member.fetch('tag_group_id'), + created_by_user_id: revision.fetch('created_user_id'), + created_at: revision.fetch('created_at') }) + end + end + end + + pages.each do |page| + page_id = page.fetch('id').to_i + current = prepared.fetch(page_id).last + revision = current.fetch(:revision) + position = 0 + + append = -> nodes, parent_position do + nodes.each do |node| + position += 1 + own_position = position + + insert_row('tag_group_placements', { + tag_group_id: page_id, + position: own_position, + parent_position:, + section_name: node['section_name'], + description: node['description'], + tag_id: node['tag_id'], + child_tag_group_id: node['tag_group_id'], + created_by_user_id: revision.fetch('created_user_id'), + updated_by_user_id: revision.fetch('created_user_id'), + created_at: revision.fetch('created_at'), + updated_at: revision.fetch('created_at') }) + + append.(node.fetch('children'), own_position) + end + end + + append.(current.fetch(:placements), nil) + end + end + end + + def down + raise ActiveRecord::IrreversibleMigration, '戻せません.' + end + + private + + def assert_empty_destination! + ['tag_groups', 'tag_group_versions', 'tag_group_tags', + 'tag_group_groups', 'tag_group_placements'].each do |table| + count = connection.select_value("SELECT COUNT(*) FROM #{ table }").to_i + raise "#{ table } が空ではありません." if count.positive? + end + end + + # 解決は tag_names.name -> tag_names.tag_id のみ. + # tags.tag_name_id は読まない. + def load_tag_ids + rows = connection.select_all(<<~SQL).to_a + SELECT + name + , tag_id + FROM + tag_names + WHERE + language_code = 'ja' + AND tag_id IS NOT NULL + SQL + + rows.each_with_object({ }) do |row, by_name| + name = row.fetch('name') + id = row.fetch('tag_id').to_i + by_name[name] ||= [] + by_name[name] << id unless by_name[name].include?(id) + end + end + + def revision_body revision_id + connection.select_rows(<<~SQL).flatten.join("\n") + SELECT + wl.body + FROM + wiki_revision_lines wrl + INNER JOIN wiki_lines wl ON wl.id = wrl.wiki_line_id + WHERE + wrl.wiki_revision_id = #{ Integer(revision_id) } + ORDER BY + wrl.position + SQL + end + + def parse_wiki markdown, tag_ids, group_ids, context + root = Kramdown::Document.new(markdown, input: 'GFM').root + placements = [] + description_parts = [] + headings = [] + + root.children.each_with_index do |node, index| + case node.type + when :blank + next + when :header + level = node.options.fetch(:level) + name = inline_text(node).strip + raise "#{ context }: 名前のない見出しがあります." if name.empty? + + headings.pop while headings.any? && headings.last.first >= level + parent = headings.last&.last + section = { 'section_name' => name, 'description' => nil, 'children' => [] } + (parent ? parent.fetch('children') : placements) << section + headings << [level, section] + when :p + text = inline_markdown(node).strip + next if text.empty? + + if headings.any? + heading = headings.last.last + heading['description'] = [heading['description'], text].compact.join("\n\n") + else + description_parts << text + end + when :ul, :ol + if headings.any? && see_also_heading?(headings.last.last.fetch('section_name')) + heading = headings.last.last + raw = original_block(markdown, root.children, index) + heading['description'] = [heading['description'], raw].compact.join("\n\n") + else + target = headings.any? ? headings.last.last.fetch('children') : placements + target.concat(node.children.map { |item| parse_list_item(item, tag_ids, group_ids, context) }) + end + else + raise "#{ context }: 未対応の Markdown 構文 #{ node.type.inspect }" + end + end + + [description_parts.empty? ? nil : description_parts.join("\n\n"), placements] + end + + def see_also_heading? name + ['see also', '関連項目'].include?(name.strip.downcase.sub(/[::]\z/, '')) + end + + def original_block markdown, siblings, index + current = siblings.fetch(index) + start_line = current.options[:location] + raise 'Markdown block の位置が不明です.' unless start_line + + following = siblings[(index + 1)..].find do |node| + node.options[:location] && node.options[:location] > start_line + end + end_line = following ? following.options.fetch(:location) - 1 : markdown.lines.length + markdown.lines[(start_line - 1)...end_line].join.strip + end + + def parse_list_item item, tag_ids, group_ids, context + raise "#{ context }: リスト項目ではありません." if item.type != :li + + nested, own = item.children.partition { |child| [:ul, :ol].include?(child.type) } + own.reject! { |child| child.type == :blank } + unless own.all? { |child| [:p, :text, :a, :codespan].include?(child.type) } + raise "#{ context }: 未対応のリスト項目があります." + end + + label = own.map { |child| inline_text(child) }.join(' ').strip + raise "#{ context }: 空のリスト項目があります." if label.empty? + + node = + if group_ids.key?(label) + { 'tag_group_id' => group_ids.fetch(label) } + elsif tag_ids.key?(label) + ids = tag_ids.fetch(label) + unless ids.one? + raise "#{ context }: タグ名 #{ label.inspect } が複数の ID を指します:#{ ids.inspect }" + end + { 'tag_id' => ids.first } + elsif label.end_with?(':', ':') + name = label.sub(/[::]\z/, '') + raise "#{ context }: 空の区分名があります." if name.empty? + if see_also_heading?(name) && nested.any? + raise "#{ context }: リスト内の See also は説明文への保管処理が未実装です." + end + { 'section_name' => name, 'description' => nil } + else + raise "#{ context }: 解決不能なリスト項目 #{ label.inspect }" + end + + node['children'] = nested.flat_map do |list| + list.children.map { |child| parse_list_item(child, tag_ids, group_ids, context) } + end + + node + end + + def inline_text node + case node.type + when :text, :codespan + node.value.to_s + when :softbreak, :br + ' ' + when :a, :p, :header, :em, :strong, :span, :li + node.children.map { |child| inline_text(child) }.join + else + raise "未対応のインライン構文 #{ node.type.inspect }" + end + end + + def inline_markdown node + case node.type + when :text + node.value.to_s + when :codespan + "`#{ node.value }`" + when :softbreak + "\n" + when :br + " \n" + when :p + node.children.map { |child| inline_markdown(child) }.join + when :a + href = node.attr.fetch('href') + "[#{ node.children.map { |child| inline_markdown(child) }.join }](#{ href })" + when :em + "*#{ node.children.map { |child| inline_markdown(child) }.join }*" + when :strong + "**#{ node.children.map { |child| inline_markdown(child) }.join }**" + else + raise "未対応のインライン構文 #{ node.type.inspect }" + end + end + + def validate_tree! nodes, context, depth = 1 + raise "#{ context }: 配置の深さが 5 を超えてゐます." if depth > 5 && nodes.any? + + nodes.each do |node| + name = node['section_name'] + if name && name.length > 255 + raise "#{ context }: 区分名が 255 文字を超えてゐます." + end + validate_tree!(node.fetch('children'), context, depth + 1) + end + end + + def build_members nodes, date, tag_history, group_history, context + ids_of_tags = [] + ids_of_groups = [] + + visit = -> items do + items.each do |node| + ids_of_tags << node['tag_id'] if node.key?('tag_id') + ids_of_groups << node['tag_group_id'] if node.key?('tag_group_id') + visit.(node.fetch('children')) + end + end + visit.(nodes) + + tags = ids_of_tags.uniq.map do |id| + { 'tag_id' => id, + 'version_no' => version_at(tag_history[id], date, context, "Tag ##{ id }") } + end + + groups = ids_of_groups.uniq.map do |id| + { 'tag_group_id' => id, + 'version_no' => version_at(group_history[id], date, context, "TagGroup ##{ id }") } + end + + tags + groups + end + + def version_at history, date, context, target + raise "#{ context }: #{ target } の履歴が存在しません." unless history&.any? + + timestamp = Time.parse(date.to_s) + candidates = history.select do |row| + Time.parse(row.fetch('created_at').to_s) <= timestamp + end + if candidates.empty? + raise "#{ context }: #{ target } は #{ date } 時点で存在しません." + end + + candidates.max_by { |row| + [Time.parse(row.fetch('created_at').to_s), row.fetch('version_no').to_i] + }.fetch('version_no').to_i + end + + def validate_current_groups! prepared + edges = { } + prepared.each do |group_id, versions| + edges[group_id] = versions.last.fetch(:members) + .filter_map { |member| member['tag_group_id'] } + end + + visiting = { } + visited = { } + visit = -> group_id do + raise "Tag Group の循環参照を検出しました:#{ group_id }" if visiting[group_id] + return if visited[group_id] + + visiting[group_id] = true + edges.fetch(group_id, []).each { |child_id| visit.(child_id) } + visiting.delete(group_id) + visited[group_id] = true + end + + edges.keys.each { |group_id| visit.(group_id) } + end + + def quote(value) = connection.quote(value) + + def insert_row table, values + columns = values.keys.join(', ') + payload = values.values.map { |value| quote(value) }.join(', ') + execute "INSERT INTO #{ table } (#{ columns }) VALUES (#{ payload })" + end +end