From 8273fed69f5879430bf84d0200630dfcc46cf696 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 28 Jan 2026 01:06:21 +0900 Subject: [PATCH 1/3] #20 --- backend/app/controllers/tags_controller.rb | 7 ++- backend/app/models/post.rb | 4 +- backend/app/models/tag.rb | 52 ++++++++++++---------- backend/app/models/tag_alias.rb | 6 --- backend/app/models/tag_name.rb | 17 +++++++ 5 files changed, 53 insertions(+), 33 deletions(-) delete mode 100644 backend/app/models/tag_alias.rb diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 475f84e..83e6c72 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -16,9 +16,12 @@ class TagsController < ApplicationController q = params[:q].to_s.strip return render json: [] if q.blank? + with_nico = !(params[:nico].to_s.strip.downcase.in?(['0', 'false', 'off', 'no'])) + tags = (Tag.joins(:tag_name).includes(:tag_name) - .where('(tags.category = ? AND tag_names.name LIKE ?) OR tag_names.name LIKE ?', - 'nico', "nico:#{ q }%", "#{ q }%") + .where(((with_nico ? '(tags.category = ? AND tag_names.name LIKE ?) OR ' : '') + + 'tag_names.name LIKE ?'), + *(with_nico ? ['nico', "nico:#{ q }%"] : []), "#{ q }%") .order(Arel.sql('post_count DESC, tag_names.name ASC')) .limit(20)) render json: tags.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki]) diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 1bd0723..18e873f 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -8,8 +8,10 @@ class Post < ApplicationRecord has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' has_many :tags, through: :active_post_tags + has_many :user_post_views, dependent: :destroy - has_many :post_similarities + has_many :post_similarities, dependent: :destroy + has_one_attached :thumbnail before_validation :normalise_url diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 491a0e3..7952b58 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -3,53 +3,51 @@ class Tag < ApplicationRecord ; end - has_many :post_tags, dependent: :delete_all, inverse_of: :tag + 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' has_many :posts, through: :active_post_tags - has_many :tag_aliases, dependent: :destroy has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy has_many :linked_tags, through: :nico_tag_relations, source: :tag - has_many :reversed_nico_tag_relations, class_name: 'NicoTagRelation', - foreign_key: :tag_id, - dependent: :destroy + has_many :reversed_nico_tag_relations, + class_name: 'NicoTagRelation', foreign_key: :tag_id, dependent: :destroy has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy has_many :children, through: :tag_implications, source: :tag - has_many :reversed_tag_implications, class_name: 'TagImplication', - foreign_key: :tag_id, - dependent: :destroy + has_many :reversed_tag_implications, + class_name: 'TagImplication', foreign_key: :tag_id, dependent: :destroy has_many :parents, through: :reversed_tag_implications, source: :parent_tag belongs_to :tag_name delegate :name, to: :tag_name, allow_nil: true validates :tag_name, presence: true - enum :category, { deerjikist: 'deerjikist', - meme: 'meme', - character: 'character', - general: 'general', - material: 'material', - nico: 'nico', - meta: 'meta' } + enum :category, deerjikist: 'deerjikist', + meme: 'meme', + character: 'character', + general: 'general', + material: 'material', + nico: 'nico', + meta: 'meta' validates :category, presence: true, inclusion: { in: Tag.categories.keys } validate :nico_tag_name_must_start_with_nico + validate :tag_name_must_be_canonical scope :nico_tags, -> { where(category: :nico) } CATEGORY_PREFIXES = { - 'gen:' => 'general', - 'djk:' => 'deerjikist', - 'meme:' => 'meme', - 'chr:' => 'character', - 'mtr:' => 'material', - 'meta:' => 'meta' }.freeze + 'gen:' => :general, + 'djk:' => :deerjikist, + 'meme:' => :meme, + 'chr:' => :character, + 'mtr:' => :material, + 'meta:' => :meta }.freeze def name= val (self.tag_name ||= build_tag_name).name = val @@ -60,11 +58,11 @@ class Tag < ApplicationRecord end def self.tagme - @tagme ||= find_or_create_by_tag_name!('タグ希望', category: 'meta') + @tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta) end def self.bot - @bot ||= find_or_create_by_tag_name!('bot操作', category: 'meta') + @bot ||= find_or_create_by_tag_name!('bot操作', category: :meta) end def self.normalise_tags tag_names, with_tagme: true, deny_nico: true @@ -75,7 +73,7 @@ class Tag < ApplicationRecord tags = tag_names.map do |name| pf, cat = CATEGORY_PREFIXES.find { |p, _| name.start_with?(p) } || ['', nil] name = name.delete_prefix(pf) - find_or_create_by_tag_name!(name, category: (cat || 'general')).tap do |tag| + find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| if cat && tag.category != cat tag.update!(category: cat) end @@ -127,4 +125,10 @@ class Tag < ApplicationRecord errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.' end end + + def tag_name_must_be_canonical + if tag_name&.canonical_id + errors.add :tag_name, 'tag_names へは実体を示す必要があります.' + end + end end diff --git a/backend/app/models/tag_alias.rb b/backend/app/models/tag_alias.rb deleted file mode 100644 index f695886..0000000 --- a/backend/app/models/tag_alias.rb +++ /dev/null @@ -1,6 +0,0 @@ -class TagAlias < ApplicationRecord - belongs_to :tag - - validates :tag_id, presence: true - validates :name, presence: true, length: { maximum: 255 }, uniqueness: true -end diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index 2efd022..9276e56 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -6,4 +6,21 @@ class TagName < ApplicationRecord has_many :aliases, class_name: 'TagName', foreign_key: :canonical_id validates :name, presence: true, length: { maximum: 255 }, uniqueness: true + + validate :canonical_must_be_canonical + validate :alias_name_must_not_have_prefix + + private + + def canonical_must_be_canonical + if canonical&.canonical_id? + errors.add :canonical, 'canonical は実体を示す必要があります.' + end + end + + def alias_name_must_not_have_prefix + if canonical_id? && name.to_s.include?(':') + errors.add :name, 'エーリアス名にプレフィクスを含むことはできません.' + end + end end -- 2.34.1 From 888842451d172d83158c139b894ed67fa95e4cdb Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 28 Jan 2026 01:51:55 +0900 Subject: [PATCH 2/3] =?UTF-8?q?#20=20=E3=83=86=E3=82=B9=E3=83=88=E3=83=BB?= =?UTF-8?q?=E3=82=B1=E3=83=BC=E3=82=B9=E3=81=AE=E3=81=BF=E8=BF=BD=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/spec/requests/posts_spec.rb | 40 +++++++++++++++++++++++++++++ backend/spec/requests/tags_spec.rb | 15 +++++++++++ 2 files changed, 55 insertions(+) diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 07523b1..24e790a 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -30,6 +30,7 @@ RSpec.describe 'Posts API', type: :request do let!(:tag) { Tag.create!(tag_name:, category: "general") } let!(:tag_name2) { TagName.create!(name: 'unko') } let!(:tag2) { Tag.create!(tag_name: tag_name2, category: 'deerjikist') } + let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) } let!(:hit_post) do Post.create!(uploaded_user: user, title: "hello spec world", @@ -86,6 +87,25 @@ RSpec.describe 'Posts API', type: :request do end end + it "filters posts by q (hit case by alias)" do + get "/posts", params: { tags: "manko" } + + expect(response).to have_http_status(:ok) + posts = json.fetch('posts') + ids = posts.map { |p| p['id'] } + + expect(ids).to include(hit_post.id) + expect(ids).not_to include(miss_post.id) + expect(json['count']).to be_an(Integer) + + posts.each do |p| + expect(p['tags']).to be_an(Array) + p['tags'].each do |t| + expect(t).to include('name', 'category', 'has_wiki') + end + end + end + it "returns empty posts when nothing matches" do get "/posts", params: { tags: "no_such_keyword_12345" } @@ -167,6 +187,26 @@ RSpec.describe 'Posts API', type: :request do expect(json['tags'][0]).to have_key('name') end + it '201 and creates post + tags when member and tags have aliases' do + sign_in_as(member) + + post '/posts', params: { + title: 'new post', + url: 'https://example.com/new', + tags: 'manko', # 既存タグ名を投げる + thumbnail: dummy_upload + } + + expect(response).to have_http_status(:created) + expect(json).to include('id', 'title', 'url') + + # tags が name を含むこと(API 側の serialization が正しいこと) + expect(json).to have_key('tags') + expect(json['tags']).to be_an(Array) + expect(json['tags'][0]).to have_key('name') + expect(json['tags'][0]['name']).to eq('spec_tag') + end + context "when nico tag already exists in tags" do before do Tag.find_or_create_by!(tag_name: TagName.find_or_create_by!(name: 'nico:nico_tag'), diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 11cce46..c9aad4f 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -5,6 +5,7 @@ require 'rails_helper' RSpec.describe 'Tags API', type: :request do let!(:tn) { TagName.create!(name: 'spec_tag') } let!(:tag) { Tag.create!(tag_name: tn, category: 'general') } + let!(:alias_tn) { TagName.create!(name: 'unko', canonical: tn) } describe 'GET /tags' do it 'returns tags with name' do @@ -56,6 +57,20 @@ RSpec.describe 'Tags API', type: :request do expect(json).to be_an(Array) expect(json.map { |t| t['name'] }).to include('spec_tag') + t = json.find { |t| t['name'] == 'spec_tag' } + expect(t).to have_key('matched_alias') + expect(t['matched_alias']).to be(nil) + end + + it 'returns matching canonical tags by q with aliases' do + get '/tags/autocomplete', params: { q: 'unk' } + + expect(response).to have_http_status(:ok) + + expect(json).to be_an(Array) + expect(json.map { |t| t['name'] }).to include('spec_tag') + t = json.find { |t| t['name'] == 'spec_tag' } + expect(t['matched_alias']).to eq('unko') end end -- 2.34.1 From 4832f9d99a30d92709d11ba8b91616d66cab5f6d Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 28 Jan 2026 23:44:17 +0900 Subject: [PATCH 3/3] #20 --- backend/app/controllers/posts_controller.rb | 6 +++ backend/app/controllers/tags_controller.rb | 42 +++++++++++++++++---- backend/app/models/post.rb | 4 +- backend/app/models/tag.rb | 8 +++- backend/app/models/tag_name.rb | 16 ++++++++ backend/spec/requests/posts_spec.rb | 8 ++-- 6 files changed, 69 insertions(+), 15 deletions(-) diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index dde9f15..57340eb 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -97,6 +97,8 @@ class PostsController < ApplicationController tags = Tag.normalise_tags(tag_names) tags = Tag.expand_parent_tags(tags) sync_post_tags!(post, tags) + + post.reload render json: post.as_json(include: { tags: { only: [:id, :category, :post_count], methods: [:name, :has_wiki] } }), status: :created @@ -136,6 +138,8 @@ class PostsController < ApplicationController Tag.normalise_tags(tag_names, with_tagme: false) tags = Tag.expand_parent_tags(tags) sync_post_tags!(post, tags) + + post.reload json = post.as_json json['tags'] = build_tag_tree_for(post.tags) render json:, status: :ok @@ -197,6 +201,8 @@ class PostsController < ApplicationController end def filter_posts_by_tags tag_names, match_type + tag_names = TagName.canonicalise(tag_names) + posts = Post.joins(tags: :tag_name) if match_type == 'any' diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 83e6c72..d5e561f 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -18,13 +18,41 @@ class TagsController < ApplicationController with_nico = !(params[:nico].to_s.strip.downcase.in?(['0', 'false', 'off', 'no'])) - tags = (Tag.joins(:tag_name).includes(:tag_name) - .where(((with_nico ? '(tags.category = ? AND tag_names.name LIKE ?) OR ' : '') + - 'tag_names.name LIKE ?'), - *(with_nico ? ['nico', "nico:#{ q }%"] : []), "#{ q }%") - .order(Arel.sql('post_count DESC, tag_names.name ASC')) - .limit(20)) - render json: tags.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki]) + alias_rows = + TagName + .where('name LIKE ?', "#{ q }%") + .where.not(canonical_id: nil) + .pluck(:canonical_id, :name) + + matched_alias_by_tag_name_id = { } + canonical_ids = [] + + alias_rows.each do |canonical_id, alias_name| + canonical_ids << canonical_id + matched_alias_by_tag_name_id[canonical_id] ||= alias_name + end + + base = Tag.joins(:tag_name).includes(:tag_name) + + canonical_hit = + base + .where(((with_nico ? '(tags.category = ? AND tag_names.name LIKE ?) OR ' : '') + + 'tag_names.name LIKE ?'), + *(with_nico ? ['nico', "nico:#{ q }%"] : []), "#{ q }%") + + tags = + if canonical_ids.present? + canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq)) + else + canonical_hit + end + + tags = tags.order(Arel.sql('post_count DESC, tag_names.name')).limit(20).to_a + + render json: tags.map { |tag| + tag.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki]) + .merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id]) + } end def show diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 18e873f..e25575f 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -9,8 +9,8 @@ class Post < ApplicationRecord has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' has_many :tags, through: :active_post_tags - has_many :user_post_views, dependent: :destroy - has_many :post_similarities, dependent: :destroy + has_many :user_post_views, dependent: :delete_all + has_many :post_similarities, dependent: :delete_all has_one_attached :thumbnail diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 7952b58..e772e9c 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -22,6 +22,8 @@ class Tag < ApplicationRecord class_name: 'TagImplication', foreign_key: :tag_id, dependent: :destroy has_many :parents, through: :reversed_tag_implications, source: :parent_tag + has_many :tag_similarities, dependent: :delete_all + belongs_to :tag_name delegate :name, to: :tag_name, allow_nil: true validates :tag_name, presence: true @@ -72,7 +74,7 @@ class Tag < ApplicationRecord tags = tag_names.map do |name| pf, cat = CATEGORY_PREFIXES.find { |p, _| name.start_with?(p) } || ['', nil] - name = name.delete_prefix(pf) + name = TagName.canonicalise(name.delete_prefix(pf)).first find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| if cat && tag.category != cat tag.update!(category: cat) @@ -109,6 +111,8 @@ class Tag < ApplicationRecord def self.find_or_create_by_tag_name! name, category: tn = TagName.find_or_create_by!(name: name.to_s.strip) + tn = tn.canonical if tn.canonical_id? + Tag.find_or_create_by!(tag_name_id: tn.id) do |t| t.category = category end @@ -127,7 +131,7 @@ class Tag < ApplicationRecord end def tag_name_must_be_canonical - if tag_name&.canonical_id + if tag_name&.canonical_id? errors.add :tag_name, 'tag_names へは実体を示す必要があります.' end end diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index 9276e56..225343d 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -9,6 +9,16 @@ class TagName < ApplicationRecord validate :canonical_must_be_canonical validate :alias_name_must_not_have_prefix + validate :canonical_must_not_be_present_with_tag_or_wiki_page + + def self.canonicalise names + names = Array(names).map { |n| n.to_s.strip }.reject(&:blank?) + return [] if names.blank? + + tns = TagName.includes(:canonical).where(name: names).index_by(&:name) + + names.map { |name| tns[name]&.canonical&.name || name }.uniq + end private @@ -23,4 +33,10 @@ class TagName < ApplicationRecord errors.add :name, 'エーリアス名にプレフィクスを含むことはできません.' end end + + def canonical_must_not_be_present_with_tag_or_wiki_page + if canonical_id? && (tag || wiki_page) + errors.add :canonical, 'タグもしくは Wiki の参照がある名前はエーリアスになれません.' + end + end end diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 24e790a..7ccdb52 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -155,6 +155,7 @@ RSpec.describe 'Posts API', type: :request do describe 'POST /posts' do let(:member) { create(:user, :member) } + let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) } it '401 when not logged in' do sign_out @@ -201,10 +202,9 @@ RSpec.describe 'Posts API', type: :request do expect(json).to include('id', 'title', 'url') # tags が name を含むこと(API 側の serialization が正しいこと) - expect(json).to have_key('tags') - expect(json['tags']).to be_an(Array) - expect(json['tags'][0]).to have_key('name') - expect(json['tags'][0]['name']).to eq('spec_tag') + names = json.fetch('tags').map { |t| t['name'] } + expect(names).to include('spec_tag') + expect(names).not_to include('manko') end context "when nico tag already exists in tags" do -- 2.34.1