Browse Source

タグ履歴 (#309) (#319)

#309

#309

#309

#309

#309

Merge remote-tracking branch 'origin/main' into feature/309

#309

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/319
pull/325/head
みてるぞ 2 weeks ago
parent
commit
bde7d33949
27 changed files with 923 additions and 123 deletions
  1. +11
    -5
      backend/app/controllers/nico_tags_controller.rb
  2. +11
    -4
      backend/app/controllers/posts_controller.rb
  3. +20
    -2
      backend/app/controllers/tag_children_controller.rb
  4. +19
    -5
      backend/app/controllers/tags_controller.rb
  5. +5
    -1
      backend/app/models/my_discard.rb
  6. +7
    -0
      backend/app/models/nico_tag_version.rb
  7. +1
    -17
      backend/app/models/post_version.rb
  8. +27
    -24
      backend/app/models/tag.rb
  9. +0
    -2
      backend/app/models/tag_name.rb
  10. +15
    -0
      backend/app/models/tag_version.rb
  11. +19
    -0
      backend/app/models/version_record.rb
  12. +0
    -2
      backend/app/models/wiki_page.rb
  13. +19
    -0
      backend/app/services/nico_tag_version_recorder.rb
  14. +16
    -42
      backend/app/services/post_version_recorder.rb
  15. +22
    -0
      backend/app/services/tag_version_recorder.rb
  16. +38
    -0
      backend/app/services/tag_versioning.rb
  17. +62
    -0
      backend/app/services/version_recorder.rb
  18. +3
    -3
      backend/db/migrate/20260409123700_create_post_versions.rb
  19. +156
    -0
      backend/db/migrate/20260419035400_create_tag_versions.rb
  20. +37
    -1
      backend/db/schema.rb
  21. +5
    -0
      backend/lib/tasks/sync_nico.rake
  22. +74
    -0
      backend/spec/models/version_record_spec.rb
  23. +55
    -0
      backend/spec/requests/nico_tags_spec.rb
  24. +55
    -6
      backend/spec/requests/posts_spec.rb
  25. +78
    -6
      backend/spec/requests/tag_children_spec.rb
  26. +64
    -3
      backend/spec/requests/tags_spec.rb
  27. +104
    -0
      backend/spec/tasks/nico_sync_spec.rb

+ 11
- 5
backend/app/controllers/nico_tags_controller.rb View File

@@ -30,15 +30,21 @@ class NicoTagsController < ApplicationController
id = params[:id].to_i

tag = Tag.find(id)
return head :bad_request if tag.category != 'nico'
return head :bad_request unless tag.nico?

linked_tag_names = params[:tags].to_s.split(' ')
linked_tag_names = params[:tags].to_s.split
linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
return head :bad_request if linked_tags.any? { |t| t.category == 'nico' }
return head :bad_request if linked_tags.any? { |t| t.nico? }

tag.linked_tags = linked_tags
tag.save!
ApplicationRecord.transaction do
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)

tag.linked_tags = linked_tags
tag.save!

NicoTagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
end

render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
end


+ 11
- 4
backend/app/controllers/posts_controller.rb View File

@@ -128,9 +128,11 @@ class PostsController < ApplicationController
original_created_from:, original_created_before:)
post.thumbnail.attach(thumbnail)

ActiveRecord::Base.transaction do
ApplicationRecord.transaction do
post.save!
tags = Tag.normalise_tags(tag_names)
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)

tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
post.resized_thumbnail!
@@ -170,10 +172,15 @@ class PostsController < ApplicationController

post = Post.find(params[:id].to_i)

ActiveRecord::Base.transaction do
ApplicationRecord.transaction do
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)

post.update!(title:, original_created_from:, original_created_before:)
tags = post.tags.where(category: 'nico').to_a +
Tag.normalise_tags(tag_names, with_tagme: false)

normalised_tags = Tag.normalise_tags(tag_names, with_tagme: false)
TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user)

tags = post.tags.nico.to_a + normalised_tags
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)


+ 20
- 2
backend/app/controllers/tag_children_controller.rb View File

@@ -7,7 +7,16 @@ class TagChildrenController < ApplicationController
child_id = params[:child_id]
return head :bad_request if parent_id.blank? || child_id.blank?

Tag.find(parent_id).children << Tag.find(child_id) rescue nil
parent = Tag.find(parent_id)
child = Tag.find(child_id)
return head :bad_request if parent.nico? || child.nico?

ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)

TagImplication.find_or_create_by!(parent_tag: parent, tag: child)
TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user)
end

head :no_content
end
@@ -20,7 +29,16 @@ class TagChildrenController < ApplicationController
child_id = params[:child_id]
return head :bad_request if parent_id.blank? || child_id.blank?

Tag.find(parent_id).children.delete(Tag.find(child_id)) rescue nil
parent = Tag.find(parent_id)
child = Tag.find(child_id)
return head :bad_request if parent.nico? || child.nico?

ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)

TagImplication.find_by(parent_tag: parent, tag: child)&.destroy!
TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user)
end

head :no_content
end


+ 19
- 5
backend/app/controllers/tags_controller.rb View File

@@ -218,15 +218,21 @@ class TagsController < ApplicationController

tag = Tag.find(params[:id])

if name.present?
tag.tag_name.update!(name:)
if category.present? && tag.nico? != (category == 'nico')
return render json: { error: 'ニコタグのカテゴリ変更はできません.' },
status: :unprocessable_entity
end

if category.present?
tag.update!(category:)
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)

tag.tag_name.update!(name:) if name.present?
tag.update!(category:) if category.present?

record_tag_version!(tag, event_type: :update, created_by_user: current_user)
end

render json: TagRepr.base(tag)
render json: TagRepr.base(tag.reload)
end

private
@@ -244,4 +250,12 @@ class TagsController < ApplicationController
children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) },
material: material.as_json&.merge(file:, content_type:))
end

def record_tag_version!(tag, event_type:, created_by_user:)
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
else
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
end
end
end

+ 5
- 1
backend/app/models/my_discard.rb View File

@@ -1,7 +1,11 @@
module MyDiscard
extend ActiveSupport::Concern

included { include Discard::Model }
included do
include Discard::Model

default_scope -> { kept }
end

class_methods do
def find_undiscard_or_create_by! attrs, &block


+ 7
- 0
backend/app/models/nico_tag_version.rb View File

@@ -0,0 +1,7 @@
class NicoTagVersion < ApplicationRecord
include VersionRecord

belongs_to :tag

validates :name, presence: true
end

+ 1
- 17
backend/app/models/post_version.rb View File

@@ -1,29 +1,13 @@
class PostVersion < ApplicationRecord
before_update do
raise ActiveRecord::ReadOnlyRecord, '版は更新できません.'
end

before_destroy do
raise ActiveRecord::ReadOnlyRecord, '版は削除できません.'
end
include VersionRecord

belongs_to :post
belongs_to :parent, class_name: 'Post', optional: true
belongs_to :created_by_user, class_name: 'User', optional: true

enum :event_type, { create: 'create',
update: 'update',
discard: 'discard',
restore: 'restore' }, prefix: true, validate: true

validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :event_type, presence: true, inclusion: { in: event_types.keys }
validates :url, presence: true

validate :validate_original_created_range

scope :chronological, -> { order(:version_no, :id) }

private

def validate_original_created_range


+ 27
- 24
backend/app/models/tag.rb View File

@@ -8,8 +8,6 @@ class Tag < ApplicationRecord
;
end

default_scope -> { kept }

has_many :post_tags, inverse_of: :tag
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
@@ -36,6 +34,9 @@ class Tag < ApplicationRecord
has_many :deerjikists, dependent: :delete_all
has_many :materials

has_many :tag_versions
has_many :nico_tag_versions

belongs_to :tag_name
delegate :wiki_page, to: :tag_name

@@ -78,25 +79,11 @@ class Tag < ApplicationRecord

def material_id = materials.first&.id

def self.tagme
@tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta)
end

def self.bot
@bot ||= find_or_create_by_tag_name!('bot操作', category: :meta)
end

def self.no_deerjikist
@no_deerjikist ||= find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
end

def self.video
@video ||= find_or_create_by_tag_name!('動画', category: :meta)
end

def self.niconico
@niconico ||= find_or_create_by_tag_name!('ニコニコ', category: :meta)
end
def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
def self.video = find_or_create_by_tag_name!('動画', category: :meta)
def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta)

def self.normalise_tags tag_names, with_tagme: true,
with_no_deerjikist: true,
@@ -152,21 +139,25 @@ class Tag < ApplicationRecord
retry
end

def self.merge_tags! target_tag, source_tags
def self.merge_tags! target_tag, source_tags, created_by_user: nil
target_tag => Tag

affected_post_ids = Set.new

Tag.transaction do
TagVersioning.ensure_snapshot!(target_tag, created_by_user:)

Array(source_tags).compact.uniq.each do |source_tag|
source_tag => Tag

next if source_tag == target_tag

TagVersioning.ensure_snapshot!(source_tag, created_by_user:)

source_tag.post_tags.kept.find_each do |source_pt|
post_id = source_pt.post_id
affected_post_ids << post_id
source_pt.discard_by!(nil)
source_pt.discard_by!(created_by_user)
unless PostTag.kept.exists?(post_id:, tag: target_tag)
PostTag.create!(post_id:, tag: target_tag)
end
@@ -178,6 +169,7 @@ class Tag < ApplicationRecord
raise ActiveRecord::RecordInvalid.new(source_tag_name)
end

TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
source_tag.discard!

if source_tag.nico?
@@ -186,10 +178,13 @@ class Tag < ApplicationRecord
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current)
end

TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
end

Post.where(id: affected_post_ids.to_a).find_each do |post|
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
PostVersionRecorder.ensure_snapshot!(post, created_by_user:)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user:)
end

# 投稿件数を再集計
@@ -199,6 +194,14 @@ class Tag < ApplicationRecord
target_tag.reload
end

def snapshot_aliases = tag_name.aliases.kept.order(:name).pluck(:name)

def snapshot_parent_tag_ids = parents.order(:id).pluck(:id)

def snapshot_linked_tag_names
linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
end

private

def nico_tag_name_must_start_with_nico


+ 0
- 2
backend/app/models/tag_name.rb View File

@@ -1,8 +1,6 @@
class TagName < ApplicationRecord
include MyDiscard

default_scope -> { kept }

has_one :tag
has_one :wiki_page



+ 15
- 0
backend/app/models/tag_version.rb View File

@@ -0,0 +1,15 @@
class TagVersion < ApplicationRecord
include VersionRecord

belongs_to :tag

enum :category, { deerjikist: 'deerjikist',
meme: 'meme',
character: 'character',
general: 'general',
material: 'material',
meta: 'meta' }, validate: true

validates :name, presence: true
validates :category, presence: true
end

+ 19
- 0
backend/app/models/version_record.rb View File

@@ -0,0 +1,19 @@
module VersionRecord
extend ActiveSupport::Concern

def readonly? = persisted?

included do
belongs_to :created_by_user, class_name: 'User', optional: true

enum :event_type, { create: 'create',
update: 'update',
discard: 'discard',
restore: 'restore' }, prefix: true, validate: true

validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :event_type, presence: true

scope :chronological, -> { order(:version_no, :id) }
end
end

+ 0
- 2
backend/app/models/wiki_page.rb View File

@@ -4,8 +4,6 @@ require 'set'
class WikiPage < ApplicationRecord
include MyDiscard

default_scope -> { kept }

has_many :wiki_revisions, dependent: :destroy
belongs_to :created_user, class_name: 'User'
belongs_to :updated_user, class_name: 'User'


+ 19
- 0
backend/app/services/nico_tag_version_recorder.rb View File

@@ -0,0 +1,19 @@
class NicoTagVersionRecorder < VersionRecorder
def self.record! tag:, event_type:, created_by_user:
new(tag:, event_type:, created_by_user:).record!
end

def initialize tag:, event_type:, created_by_user:
super(record: tag, event_type:, created_by_user:)
end

private

def version_class = NicoTagVersion
def version_association = :nico_tag_versions
def record_key = :tag

def snapshot_attributes
{ name: @record.name, linked_tags: @record.snapshot_linked_tag_names.join(' ') }
end
end

+ 16
- 42
backend/app/services/post_version_recorder.rb View File

@@ -1,57 +1,31 @@
class PostVersionRecorder
class PostVersionRecorder < VersionRecorder
def self.record! post:, event_type:, created_by_user:
new(post:, event_type:, created_by_user:).record!
end

def initialize post:, event_type:, created_by_user:
@post = post
@event_type = event_type
@created_by_user = created_by_user
super(record: post, event_type:, created_by_user:)
end

def record!
@post.with_lock do
latest = @post.post_versions.order(version_no: :desc).first
attrs = snapshot_attributes
def self.ensure_snapshot! post, created_by_user:
return if post.post_versions.exists?

return latest if @event_type == :update && latest && same_snapshot?(latest, attrs)

PostVersion.create!(
post: @post,
version_no: (latest&.version_no || 0) + 1,
event_type: @event_type,
title: attrs[:title],
url: attrs[:url],
thumbnail_base: attrs[:thumbnail_base],
tags: attrs[:tags],
parent: attrs[:parent],
original_created_from: attrs[:original_created_from],
original_created_before: attrs[:original_created_before],
created_at: Time.current,
created_by_user: @created_by_user)
end
record!(post:, event_type: :create, created_by_user:)
end

private

def snapshot_attributes
{ title: @post.title,
url: @post.url,
thumbnail_base: @post.thumbnail_base,
tags: @post.snapshot_tag_names.join(' '),
parent: @post.parent,
original_created_from: @post.original_created_from,
original_created_before: @post.original_created_before }
end
def version_class = PostVersion
def version_association = :post_versions
def record_key = :post

def same_snapshot? version, attrs
true &&
version.title == attrs[:title] &&
version.url == attrs[:url] &&
version.thumbnail_base == attrs[:thumbnail_base] &&
version.tags == attrs[:tags] &&
version.parent_id == attrs[:parent]&.id &&
version.original_created_from == attrs[:original_created_from] &&
version.original_created_before == attrs[:original_created_before]
def snapshot_attributes
{ title: @record.title,
url: @record.url,
thumbnail_base: @record.thumbnail_base,
tags: @record.snapshot_tag_names.join(' '),
parent_id: @record.parent_id,
original_created_from: @record.original_created_from,
original_created_before: @record.original_created_before }
end
end

+ 22
- 0
backend/app/services/tag_version_recorder.rb View File

@@ -0,0 +1,22 @@
class TagVersionRecorder < VersionRecorder
def self.record! tag:, event_type:, created_by_user:
new(tag:, event_type:, created_by_user:).record!
end

def initialize tag:, event_type:, created_by_user:
super(record: tag, event_type:, created_by_user:)
end

private

def version_class = TagVersion
def version_association = :tag_versions
def record_key = :tag

def snapshot_attributes
{ name: @record.name,
category: @record.category,
aliases: @record.snapshot_aliases.join(' '),
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
end
end

+ 38
- 0
backend/app/services/tag_versioning.rb View File

@@ -0,0 +1,38 @@
class TagVersioning
def self.record! tag, event_type:, created_by_user:
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
else
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
end
end

def self.ensure_snapshot! tag, created_by_user:
if tag.nico?
return if tag.nico_tag_versions.exists?

NicoTagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
else
return if tag.tag_versions.exists?

TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
end
end

def self.record_tag_snapshot! tag, created_by_user:
event_type =
if tag.nico?
tag.nico_tag_versions.exists? ? :update : :create
else
tag.tag_versions.exists? ? :update : :create
end

record!(tag, event_type:, created_by_user:)
end

def self.record_tag_snapshots! tags, created_by_user:
tags.each do |tag|
record_tag_snapshot!(tag, created_by_user:)
end
end
end

+ 62
- 0
backend/app/services/version_recorder.rb View File

@@ -0,0 +1,62 @@
class VersionRecorder
EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze

def initialize record:, event_type:, created_by_user:
@record = record
@event_type = event_type.to_s
@created_by_user = created_by_user

validate_event_type!
end

def record!
raise "#{ record_class.name } must be persisted" unless @record.persisted?

ApplicationRecord.transaction do
@record = record_class.unscoped.lock.find(@record.id)
latest = latest_version

if !(latest) && @event_type != 'create'
raise "#{ version_class.name } first event must be create"
end

if @event_type == 'create' && latest
raise "#{ version_class.name } create event already exists"
end

attrs = snapshot_attributes

return latest if @event_type == 'update' && latest && same_snapshot?(latest, attrs)

version_class.create!(base_attributes(latest).merge(record_key => @record).merge(attrs))
end
end

private

def latest_version = versions.order(version_no: :desc).first

def versions = @record.public_send(version_association)

def base_attributes latest
{ version_no: (latest&.version_no || 0) + 1,
event_type: @event_type,
created_at: Time.current,
created_by_user: @created_by_user }
end

def same_snapshot?(version, attrs) = attrs.all? { |k, v| version.public_send(k) == v }

def validate_event_type!
return if EVENT_TYPES.include?(@event_type)

raise ArgumentError, "Invalid event_type: #{ @event_type }"
end

def version_class = raise NotImplementedError
def version_association = raise NotImplementedError
def record_key = raise NotImplementedError
def snapshot_attributes = raise NotImplementedError

def record_class = @record.class
end

+ 3
- 3
backend/db/migrate/20260409123700_create_post_versions.rb View File

@@ -2,15 +2,15 @@ require 'set'


class CreatePostVersions < ActiveRecord::Migration[8.0]
class Post < ApplicationRecord
class Post < ActiveRecord::Base
self.table_name = 'posts'
end

class PostTag < ApplicationRecord
class PostTag < ActiveRecord::Base
self.table_name = 'post_tags'
end

class PostVersion < ApplicationRecord
class PostVersion < ActiveRecord::Base
self.table_name = 'post_versions'
end



+ 156
- 0
backend/db/migrate/20260419035400_create_tag_versions.rb View File

@@ -0,0 +1,156 @@
class CreateTagVersions < ActiveRecord::Migration[8.0]
class Tag < ActiveRecord::Base
self.table_name = 'tags'
end

class TagName < ActiveRecord::Base
self.table_name = 'tag_names'
end

class TagImplication < ActiveRecord::Base
self.table_name = 'tag_implications'
end

class TagVersion < ActiveRecord::Base
self.table_name = 'tag_versions'
end

class NicoTagVersion < ActiveRecord::Base
self.table_name = 'nico_tag_versions'
end

class NicoTagRelation < ActiveRecord::Base
self.table_name = 'nico_tag_relations'
end

def up
create_table :tag_versions do |t|
t.references :tag, null: false, foreign_key: true, index: false
t.integer :version_no, null: false
t.string :event_type, null: false
t.string :name, null: false
t.string :category, null: false
t.text :aliases, null: false
t.text :parent_tag_ids, null: false
t.datetime :created_at, null: false
t.references :created_by_user, foreign_key: { to_table: :users }, index: false

t.index [:tag_id, :version_no], unique: true
t.index :created_at
t.index [:tag_id, :created_at], order: { created_at: :desc }
t.index [:created_by_user_id, :created_at], order: { created_at: :desc }
t.check_constraint 'version_no > 0',
name: 'tag_versions_version_no_positive'
end

create_table :nico_tag_versions do |t|
t.references :tag, null: false, foreign_key: true, index: false
t.integer :version_no, null: false
t.string :event_type, null: false
t.string :name, null: false
t.text :linked_tags, null: false
t.datetime :created_at, null: false
t.references :created_by_user, foreign_key: { to_table: :users }, index: false

t.index [:tag_id, :version_no], unique: true
t.index :created_at
t.index [:tag_id, :created_at], order: { created_at: :desc }
t.index [:created_by_user_id, :created_at], order: { created_at: :desc }
t.check_constraint 'version_no > 0',
name: 'nico_tag_versions_version_no_positive'
end

TagVersion.reset_column_information
say_with_time 'Backfilling tag_versions' do
Tag.where(discarded_at: nil)
.where.not(category: 'nico')
.find_in_batches(batch_size: 500) do |tags|
tag_ids = tags.map(&:id)

tag_implication_rows_by_tag_id =
TagImplication
.where(tag_id: tag_ids)
.pluck(:tag_id, :parent_tag_id)
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
h[row[0]] << row[1]
end

tag_name_rows_by_tag_id =
TagName
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
.where(tags: { id: tag_ids })
.pluck('tags.id', 'tag_names.name')
.each_with_object({ }) do |row, h|
h[row[0]] = row[1]
end

tag_alias_rows_by_tag_id =
TagName
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.canonical_id')
.where(tags: { id: tag_ids })
.where(tag_names: { discarded_at: nil })
.pluck('tags.id', 'tag_names.name')
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
h[row[0]] << row[1]
end

TagVersion.insert_all(tags.map { |tag|
{ tag_id: tag.id,
version_no: 1,
event_type: 'create',
name: tag_name_rows_by_tag_id[tag.id],
category: tag.category,
aliases: tag_alias_rows_by_tag_id[tag.id].sort.join(' '),
parent_tag_ids: tag_implication_rows_by_tag_id[tag.id].sort.join(' '),
created_at: tag.created_at,
created_by_user_id: nil }
})
end
end

NicoTagVersion.reset_column_information
say_with_time 'Backfilling nico_tag_versions' do
Tag.where(discarded_at: nil, category: 'nico')
.find_in_batches(batch_size: 500) do |tags|
tag_ids = tags.map(&:id)

tag_name_rows_by_tag_id =
TagName
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
.where(tags: { id: tag_ids })
.pluck('tags.id', 'tag_names.name')
.each_with_object({ }) do |row, h|
h[row[0]] = row[1]
end

nico_tag_relation_rows_by_tag_id =
NicoTagRelation
.joins('INNER JOIN tags nico_tags ON nico_tags.id = nico_tag_relations.nico_tag_id')
.joins('INNER JOIN tags linked_tags ON linked_tags.id = nico_tag_relations.tag_id')
.joins('INNER JOIN tag_names ON tag_names.id = linked_tags.tag_name_id')
.where(nico_tags: { id: tag_ids })
.where(linked_tags: { discarded_at: nil })
.where(tag_names: { discarded_at: nil })
.pluck('nico_tags.id', 'tag_names.name')
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
h[row[0]] << row[1]
end

NicoTagVersion.insert_all(tags.map { |tag|
{ tag_id: tag.id,
version_no: 1,
event_type: 'create',
name: tag_name_rows_by_tag_id[tag.id],
linked_tags: nico_tag_relation_rows_by_tag_id[tag.id].sort.join(' '),
created_at: tag.created_at,
created_by_user_id: nil }
})
end
end
end

def down
drop_table :nico_tag_versions
drop_table :tag_versions
end
end

+ 37
- 1
backend/db/schema.rb View File

@@ -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_04_09_123700) do
ActiveRecord::Schema[8.0].define(version: 2026_04_19_035400) 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
@@ -104,6 +104,21 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
t.index ["tag_id"], name: "index_nico_tag_relations_on_tag_id"
end

create_table "nico_tag_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "name", null: false
t.text "linked_tags", null: false
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_at"], name: "index_nico_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end

create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "target_post_id", null: false
@@ -216,6 +231,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id"
end

create_table "tag_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "name", null: false
t.string "category", null: false
t.text "aliases", null: false
t.text "parent_tag_ids", null: false
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_at"], name: "index_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true
t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive"
end

create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false
t.string "category", default: "general", null: false
@@ -377,6 +409,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
add_foreign_key "materials", "users", column: "updated_by_user_id"
add_foreign_key "nico_tag_relations", "tags"
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
add_foreign_key "nico_tag_versions", "tags"
add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id"
add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tags", "posts"
@@ -394,6 +428,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
add_foreign_key "tag_names", "tag_names", column: "canonical_id"
add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
add_foreign_key "tag_versions", "tags"
add_foreign_key "tag_versions", "users", column: "created_by_user_id"
add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres"
add_foreign_key "theatre_comments", "users"


+ 5
- 0
backend/lib/tasks/sync_nico.rake View File

@@ -115,6 +115,10 @@ namespace :nico do
datum['tags'].each do |raw|
name = TagNameSanitisationRule.sanitise("nico:#{ raw }")
tag = Tag.find_or_create_by_tag_name!(name, category: :nico)

event_type = tag.nico_tag_versions.exists? ? :update : :create
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil)

desired_nico_tag_based_ids << tag.id

# 新たに記載される外部タグと連携される内部タグを記載
@@ -149,6 +153,7 @@ namespace :nico do
if post_created
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
elsif post_changed || kept_tag_ids != desired_all_tag_ids.to_set
PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
end
end


+ 74
- 0
backend/spec/models/version_record_spec.rb View File

@@ -0,0 +1,74 @@
require 'rails_helper'

RSpec.describe VersionRecord, type: :model do
let!(:tag) { create(:tag, name: 'version_record_tag') }
let!(:nico_tag) { create(:tag, :nico, name: 'nico:version_record_tag') }

it 'makes TagVersion read only after create' do
version = TagVersion.create!(
tag: tag,
version_no: 1,
event_type: 'create',
name: tag.name,
category: tag.category,
aliases: '',
parent_tag_ids: '',
created_at: Time.current,
created_by_user: nil
)

expect {
version.update!(name: 'changed')
}.to raise_error(ActiveRecord::ReadOnlyRecord)
end

it 'prevents TagVersion destroy' do
version = TagVersion.create!(
tag: tag,
version_no: 1,
event_type: 'create',
name: tag.name,
category: tag.category,
aliases: '',
parent_tag_ids: '',
created_at: Time.current,
created_by_user: nil
)

expect {
version.destroy!
}.to raise_error(ActiveRecord::ReadOnlyRecord)
end

it 'makes NicoTagVersion read only after create' do
version = NicoTagVersion.create!(
tag: nico_tag,
version_no: 1,
event_type: 'create',
name: nico_tag.name,
linked_tags: '',
created_at: Time.current,
created_by_user: nil
)

expect {
version.update!(name: 'nico:changed')
}.to raise_error(ActiveRecord::ReadOnlyRecord)
end

it 'prevents NicoTagVersion destroy' do
version = NicoTagVersion.create!(
tag: nico_tag,
version_no: 1,
event_type: 'create',
name: nico_tag.name,
linked_tags: '',
created_at: Time.current,
created_by_user: nil
)

expect {
version.destroy!
}.to raise_error(ActiveRecord::ReadOnlyRecord)
end
end

+ 55
- 0
backend/spec/requests/nico_tags_spec.rb View File

@@ -14,6 +14,7 @@ RSpec.describe 'NicoTags', type: :request do

describe 'PATCH /tags/nico/:id' do
let(:member) { create(:user, :member) }
let(:admin) { create(:user, :admin) }
let(:nico_tag) { create(:tag, :nico) }

it '401 when not logged in' do
@@ -34,5 +35,59 @@ RSpec.describe 'NicoTags', type: :request do
patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' }
expect(response).to have_http_status(:bad_request)
end

it '200 and updates linked tags while recording tag versions' do
sign_in_as(admin)

nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)

linked_a_name = TagName.create!(name: 'nico_linked_a')
linked_a = Tag.create!(tag_name: linked_a_name, category: :general)

linked_b_name = TagName.create!(name: 'nico_linked_b')
linked_b = Tag.create!(tag_name: linked_b_name, category: :general)

TagVersioning.ensure_snapshot!(nico_tag, created_by_user: admin)

expect {
patch "/tags/nico/#{nico_tag.id}", params: {
tags: " #{linked_a.name}\n#{linked_b.name} "
}
}.to change(TagVersion, :count).by(2)
.and change(NicoTagVersion, :count).by(1)

expect(response).to have_http_status(:ok)

names = json.map { |t| t['name'] }
expect(names).to match_array(['nico_linked_a', 'nico_linked_b'])

linked_versions = TagVersion.where(tag: [linked_a, linked_b]).order(:tag_id)
expect(linked_versions.map(&:event_type)).to eq(['create', 'create'])
expect(linked_versions.map(&:created_by_user_id)).to all(eq(admin.id))

versions = nico_tag.reload.nico_tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.last.linked_tags.split).to match_array([
'nico_linked_a',
'nico_linked_b'
])
expect(versions.last.created_by_user_id).to eq(admin.id)
end

it '400 when linked tag normalises to nico tag' do
sign_in_as(member)

other_nico = create(:tag, :nico, name: 'nico:linked_ng')
TagName.create!(name: 'linked_ng_alias', canonical: other_nico.tag_name)

TagVersioning.ensure_snapshot!(nico_tag, created_by_user: member)

expect {
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
}.not_to change(NicoTagVersion, :count)

expect(response).to have_http_status(:bad_request)
end
end
end

+ 55
- 6
backend/spec/requests/posts_spec.rb View File

@@ -1,8 +1,8 @@
include ActiveSupport::Testing::TimeHelpers

require 'rails_helper'
require 'set'

include ActiveSupport::Testing::TimeHelpers

RSpec.describe 'Posts API', type: :request do
# create / update で thumbnail.attach は走るが、
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
@@ -1082,15 +1082,16 @@ RSpec.describe 'Posts API', type: :request do

it 'does not create a new version on PUT /posts/:id when snapshot is unchanged' do
sign_in_as(member)
create_post_version_for!(post_record)

expect do
PostTag.create!(post: post_record, tag: Tag.no_deerjikist)
create_post_version_for!(post_record.reload)

expect {
put "/posts/#{post_record.id}", params: {
title: post_record.title,
tags: 'spec_tag'
}
end.not_to change(PostVersion, :count)

}.not_to change(PostVersion, :count)
expect(response).to have_http_status(:ok)

version = post_record.reload.post_versions.order(:version_no).last
@@ -1130,4 +1131,52 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
end
end

describe 'tag versioning from post write actions' do
let(:member) { create(:user, :member) }

it 'creates tag snapshot for normalised tags on POST /posts' do
sign_in_as(member)

expect {
post '/posts', params: {
title: 'tag versioned post',
url: 'https://example.com/tag-versioned-post',
tags: 'spec_tag',
thumbnail: dummy_upload
}
}.to change { tag.reload.tag_versions.count }.by(1)

expect(response).to have_http_status(:created)

version = tag.reload.tag_versions.order(:version_no).last
expect(version.version_no).to eq(1)
expect(version.event_type).to eq('create')
expect(version.name).to eq('spec_tag')
expect(version.category).to eq('general')
expect(version.created_by_user_id).to eq(member.id)
end

it 'creates tag snapshot for normalised tags on PUT /posts/:id' do
sign_in_as(member)

tag_name2 = TagName.create!(name: 'spec_tag_2')
tag2 = Tag.create!(tag_name: tag_name2, category: :general)

expect {
put "/posts/#{post_record.id}", params: {
title: 'updated title',
tags: 'spec_tag_2'
}
}.to change { tag2.reload.tag_versions.count }.by(1)

expect(response).to have_http_status(:ok)

version = tag2.reload.tag_versions.order(:version_no).last
expect(version.version_no).to eq(1)
expect(version.event_type).to eq('create')
expect(version.name).to eq('spec_tag_2')
expect(version.created_by_user_id).to eq(member.id)
end
end
end

+ 78
- 6
backend/spec/requests/tag_children_spec.rb View File

@@ -58,15 +58,47 @@ RSpec.describe "TagChildren", type: :request do
end
end

context "when Tag.find raises (invalid ids) it still returns 204" do
context "when Tag.find raises (invalid ids)" do
before { stub_current_user(admin) }

let(:parent_id) { -1 }
let(:child_id) { -1 }

it "returns 204 (rescue nil)" do
it "returns 404" do
do_request
expect(response).to have_http_status(:no_content)
expect(response).to have_http_status(:not_found)
end
end

context 'when parent is nico' do
before { stub_current_user(admin) }

let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }

it 'returns 400 and does not create relation' do
expect {
do_request
}.not_to change(TagImplication, :count)

expect(response).to have_http_status(:bad_request)
end
end

context 'when child is nico' do
before { stub_current_user(admin) }

let!(:child) { create(:tag, :nico, name: 'nico:child_ng') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }

it 'returns 400 and does not create relation' do
expect {
do_request
}.not_to change(TagImplication, :count)

expect(response).to have_http_status(:bad_request)
end
end
end
@@ -116,17 +148,57 @@ RSpec.describe "TagChildren", type: :request do

expect(response).to have_http_status(:no_content)
end

it 'records create and update versions for child tag' do
expect {
do_request
}.to change(TagVersion, :count).by(2)

expect(response).to have_http_status(:no_content)

versions = child.reload.tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.first.parent_tag_ids.split).to include(parent.id.to_s)
expect(versions.second.parent_tag_ids).to eq('')
expect(versions.second.created_by_user_id).to eq(admin.id)
end
end

context "when Tag.find raises (invalid ids) it still returns 204" do
context "when Tag.find raises (invalid ids)" do
before { stub_current_user(admin) }

let(:parent_id) { -1 }
let(:child_id) { -1 }

it "returns 204 (rescue nil)" do
it "returns 404" do
do_request
expect(response).to have_http_status(:no_content)
expect(response).to have_http_status(:not_found)
end
end

context 'when parent is nico' do
before { stub_current_user(admin) }

let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng_delete') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }

it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end
end

context 'when child is nico' do
before { stub_current_user(admin) }

let!(:child) { create(:tag, :nico, name: 'nico:child_ng_delete') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }

it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end
end
end


+ 64
- 3
backend/spec/requests/tags_spec.rb View File

@@ -364,9 +364,70 @@ RSpec.describe 'Tags API', type: :request do
expect(response.status).to be_in([404, 500])
end

it 'バリデーションで update! が失敗したら(通常は 422 か 500)' do
patch "/tags/#{ tag.id }", params: { name: 'new', category: 'nico' }
expect(response.status).to be_in([422, 500])
it 'nico category への変更は 422 を返す' do
patch "/tags/#{tag.id}", params: { name: 'new', category: 'nico' }

expect(response).to have_http_status(:unprocessable_entity)
expect(tag.reload.name).to eq('spec_tag')
expect(tag.category).to eq('general')
end

it 'creates initial and update tag versions when name and category change' do
expect {
patch "/tags/#{tag.id}", params: { name: 'new_tag_name', category: 'meme' }
}.to change(TagVersion, :count).by(2)

expect(response).to have_http_status(:ok)

versions = tag.reload.tag_versions.order(:version_no)

expect(versions.map(&:event_type)).to eq(['create', 'update'])

expect(versions.first.name).to eq('spec_tag')
expect(versions.first.category).to eq('general')
expect(versions.first.aliases.split).to include('unko')

expect(versions.second.name).to eq('new_tag_name')
expect(versions.second.category).to eq('meme')
expect(versions.second.created_by_user_id).to eq(member_user.id)
end

it 'returns 422 when changing normal tag category to nico' do
expect {
patch "/tags/#{tag.id}", params: { category: 'nico' }
}.not_to change(TagVersion, :count)

expect(response).to have_http_status(:unprocessable_entity)
expect(tag.reload.category).to eq('general')
end

it 'creates nico tag versions when updating nico tag name' do
nico_tag_name = TagName.create!(name: 'nico:tags_spec_source')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)

expect {
patch "/tags/#{nico_tag.id}", params: { name: 'nico:tags_spec_renamed' }
}.to change(NicoTagVersion, :count).by(2)

expect(response).to have_http_status(:ok)

versions = nico_tag.reload.nico_tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.first.name).to eq('nico:tags_spec_source')
expect(versions.second.name).to eq('nico:tags_spec_renamed')
expect(versions.second.created_by_user_id).to eq(member_user.id)
end

it 'returns 422 when changing nico tag category to normal category' do
nico_tag_name = TagName.create!(name: 'nico:category_change_ng')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)

expect {
patch "/tags/#{nico_tag.id}", params: { category: 'general' }
}.not_to change(NicoTagVersion, :count)

expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.category).to eq('nico')
end
end
end


+ 104
- 0
backend/spec/tasks/nico_sync_spec.rb View File

@@ -214,4 +214,108 @@ RSpec.describe "nico:sync" do
expect(version.event_type).to eq('create')
expect(version.tags).to eq(snapshot_tags(post.reload))
end

it '新規 nico tag に nico tag version を作る' do
Tag.bot
Tag.tagme
Tag.niconico
Tag.video
Tag.no_deerjikist

stub_python([{
'code' => 'sm9',
'title' => 't',
'tags' => ['AAA'],
'uploaded_at' => '2026-01-01 12:34:56'
}])

allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))

expect {
run_rake_task('nico:sync')
}.to change(NicoTagVersion, :count).by(1)

nico_tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'nico:AAA' })
version = nico_tag.nico_tag_versions.order(:version_no).last

expect(version.version_no).to eq(1)
expect(version.event_type).to eq('create')
expect(version.name).to eq('nico:AAA')
expect(version.created_by_user).to be_nil
end

it '既存 post に version が無い場合は create snapshot を補う' do
post = Post.create!(
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil
)

kept_general = create_tag!('spec_kept_without_version', category: 'general')
PostTag.create!(post: post, tag: kept_general)

Tag.bot
Tag.tagme
Tag.no_deerjikist

stub_python([{
'code' => 'sm9',
'title' => 'changed title',
'tags' => ['AAA'],
'uploaded_at' => '2026-01-01 12:34:56'
}])

allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))

expect {
run_rake_task('nico:sync')
}.to change { post.reload.post_versions.count }.by(1)

versions = post.reload.post_versions.order(:version_no)

expect(versions.map(&:event_type)).to eq(['create'])
expect(versions.first.title).to eq('changed title')
expect(versions.first.tags).to eq(snapshot_tags(post.reload))
end

it '既存 version がある post には update version を作る' do
post = Post.create!(
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil
)

kept_general = create_tag!('spec_kept_with_version', category: 'general')
PostTag.create!(post: post, tag: kept_general)

PostVersionRecorder.record!(
post: post,
event_type: :create,
created_by_user: nil
)

Tag.bot
Tag.tagme
Tag.no_deerjikist

stub_python([{
'code' => 'sm9',
'title' => 'changed title',
'tags' => ['AAA'],
'uploaded_at' => '2026-01-01 12:34:56'
}])

allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))

expect {
run_rake_task('nico:sync')
}.to change { post.reload.post_versions.count }.by(1)

versions = post.reload.post_versions.order(:version_no)

expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.first.title).to eq('old')
expect(versions.second.title).to eq('changed title')
expect(versions.second.tags).to eq(snapshot_tags(post.reload))
end
end

Loading…
Cancel
Save