コミットを比較

...

2 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 83cd476325 #386 2026-06-30 12:47:02 +09:00
みてるぞ acd062f289 タグ循環の禁止 (#332) (#382)
Reviewed-on: #382
2026-06-30 01:20:55 +09:00
7個のファイルの変更192行の追加12行の削除
+10 -8
ファイルの表示
@@ -89,15 +89,13 @@ class MaterialsController < ApplicationController
begin
Material.transaction do
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
tag = resolve_material_tag!(tag_name_raw)
material = Material.new(tag:, url:,
created_by_user: current_user,
updated_by_user: current_user)
material.file.attach(uploaded_blob) if uploaded_blob
material.save!
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: current_user)
@@ -139,10 +137,7 @@ class MaterialsController < ApplicationController
begin
Material.transaction do
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
tag = resolve_material_tag!(tag_name_raw)
material.assign_attributes(tag:, url:, updated_by_user: current_user)
if uploaded_blob
material.file.attach(uploaded_blob)
@@ -150,6 +145,7 @@ class MaterialsController < ApplicationController
material.file.detach
end
material.save!
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :update,
created_by_user: current_user)
@@ -240,6 +236,12 @@ class MaterialsController < ApplicationController
nil
end
def resolve_material_tag! tag_name_raw
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag || Tag.create!(tag_name:, category: :material)
end
def material_index_needs_tag_name? filters
filters[:q].present? || filters[:sort] == 'tag_name'
end
+24
ファイルの表示
@@ -6,6 +6,7 @@ class TagImplication < ApplicationRecord
validates :parent_tag_id, presence: true
validate :parent_tag_mustnt_be_itself
validate :parent_tag_mustnt_create_cycle
private
@@ -14,4 +15,27 @@ class TagImplication < ApplicationRecord
errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.'
end
end
def parent_tag_mustnt_create_cycle
return if tag_id.blank? || parent_tag_id.blank?
return if errors[:parent_tag_id].present?
seen = { }
stack = [parent_tag_id]
until stack.empty?
current_id = stack.pop
next if seen[current_id]
seen[current_id] = true
if current_id == tag_id
errors.add :parent_tag_id, '親タグに子孫タグを指定すると循環します.'
errors.add :base, 'タグの親子関係が循環します.'
return
end
stack.concat(TagImplication.where(tag_id: current_id).pluck(:parent_tag_id))
end
end
end
+36
ファイルの表示
@@ -0,0 +1,36 @@
require 'rails_helper'
RSpec.describe TagImplication, type: :model do
it 'rejects a parent tag that would create a cycle' do
child = create(:tag, name: 'tag_implication_cycle_child')
parent = create(:tag, name: 'tag_implication_cycle_parent')
described_class.create!(tag: child, parent_tag: parent)
implication = described_class.new(tag: parent, parent_tag: child)
expect(implication).not_to be_valid
expect(implication.errors[:parent_tag_id]).to include(
'親タグに子孫タグを指定すると循環します.'
)
expect(implication.errors[:base]).to be_present
end
it 'terminates even when existing data already contains a cycle' do
child = create(:tag, name: 'tag_implication_existing_cycle_child')
parent = create(:tag, name: 'tag_implication_existing_cycle_parent')
ancestor = create(:tag, name: 'tag_implication_existing_cycle_ancestor')
described_class.create!(tag: parent, parent_tag: ancestor)
described_class.insert_all!(
[
{ tag_id: ancestor.id, parent_tag_id: parent.id,
created_at: Time.current, updated_at: Time.current }
]
)
implication = described_class.new(tag: child, parent_tag: parent)
expect(implication).to be_valid
end
end
+11 -1
ファイルの表示
@@ -54,7 +54,17 @@ RSpec.describe Tag, type: :model do
first = create(:tag, name: 'expand_cycle_first')
second = create(:tag, name: 'expand_cycle_second')
TagImplication.create!(tag: first, parent_tag: second)
TagImplication.create!(tag: second, parent_tag: first)
now = Time.current
TagImplication.insert_all!(
[
{
tag_id: second.id,
parent_tag_id: first.id,
created_at: now,
updated_at: now
}
]
)
expect(described_class.expand_parent_tags([first])).to contain_exactly(first, second)
end
+89
ファイルの表示
@@ -287,6 +287,25 @@ RSpec.describe 'Materials API', type: :request do
expect(json.dig('export_paths', 'legacy_drive')).to eq('伊地知ニジカ/created.png')
end
it 'creates a create tag_version for a newly created material tag' do
expect do
post '/materials', params: {
tag: 'material_create_versioned_tag',
file: dummy_upload(filename: 'created.png')
}
end.to change(TagVersion, :count).by(1)
expect(response).to have_http_status(:created)
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_create_versioned_tag' })
version = tag.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
expect(version.name).to eq('material_create_versioned_tag')
expect(version.category).to eq('material')
expect(version.created_by_user).to eq(member_user)
end
it 'snapshots attached file metadata and sha256' do
post '/materials', params: {
tag: 'material_create_file_version',
@@ -466,6 +485,73 @@ RSpec.describe 'Materials API', type: :request do
expect(json.dig('tag', 'name')).to eq('material_update_new')
end
it 'creates a create tag_version when update creates a new tag' do
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_versioned_tag',
file: dummy_upload(filename: 'updated.png')
}
end.to change(Tag, :count).by(1)
.and change(TagName, :count).by(1)
.and change(TagVersion, :count).by(1)
expect(response).to have_http_status(:ok)
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_update_versioned_tag' })
version = tag.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
expect(version.name).to eq('material_update_versioned_tag')
expect(version.category).to eq('material')
expect(version.created_by_user).to eq(member_user)
end
it 'backfills a create tag_version for an existing material tag without history' do
existing_tag =
Tag.create!(tag_name: TagName.create!(name: 'material_update_existing_no_history'),
category: :material)
expect(existing_tag.tag_versions).to be_empty
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_existing_no_history',
file: dummy_upload(filename: 'updated.png')
}
end.to change(TagVersion, :count).by(1)
expect(response).to have_http_status(:ok)
version = existing_tag.reload.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
expect(version.name).to eq('material_update_existing_no_history')
expect(version.category).to eq('material')
expect(version.created_by_user).to eq(member_user)
end
it 'backfills a create tag_version for an existing character tag without history' do
existing_tag =
Tag.create!(tag_name: TagName.create!(name: 'material_update_character_no_history'),
category: :character)
expect(existing_tag.tag_versions).to be_empty
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_character_no_history',
file: dummy_upload(filename: 'updated.png')
}
end.to change(TagVersion, :count).by(1)
expect(response).to have_http_status(:ok)
version = existing_tag.reload.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
expect(version.name).to eq('material_update_character_no_history')
expect(version.category).to eq('character')
expect(version.created_by_user).to eq(member_user)
end
it 'detaches the existing file without purging blob when url replaces file' do
old_blob_id = material.file.blob.id
@@ -494,6 +580,7 @@ RSpec.describe 'Materials API', type: :request do
it 'does not increase version for the same snapshot update' do
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user)
TagVersioning.ensure_snapshot!(tag, created_by_user: member_user)
expect do
put "/materials/#{ material.id }", params: {
@@ -501,6 +588,8 @@ RSpec.describe 'Materials API', type: :request do
}
end.not_to change(MaterialVersion, :count)
expect(tag.reload.tag_versions.count).to eq(1)
expect(response).to have_http_status(:ok)
expect(material.reload.version_no).to eq(1)
end
+11
ファイルの表示
@@ -56,6 +56,17 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:no_content)
end
it 'returns 422 and does not create relation when the new link makes a cycle' do
TagImplication.create!(tag: parent, parent_tag: child)
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(TagImplication.where(tag: child, parent_tag: parent)).not_to exist
end
end
context "when Tag.find raises (invalid ids)" do
+11 -3
ファイルの表示
@@ -797,7 +797,17 @@ RSpec.describe 'Tags API', type: :request do
)
TagImplication.create!(tag: first, parent_tag: root_material)
TagImplication.create!(tag: second, parent_tag: first)
TagImplication.create!(tag: first, parent_tag: second)
now = Time.current
TagImplication.insert_all!(
[
{
tag_id: first.id,
parent_tag_id: second.id,
created_at: now,
updated_at: now
}
]
)
get '/tags/with-depth', params: { parent: root_material.id }
@@ -1364,8 +1374,6 @@ RSpec.describe 'Tags API', type: :request do
end
it 'parent_tags に指定すると循環する tag は 422 にする' do
pending '#332 で対応予定'
child = Tag.create!(
tag_name: TagName.create!(name: 'put_cycle_child'),
category: :general