#243 feat: 別名を検索に展開(#20)

Merged
みてるぞ merged 3 commits from feature/020 into main 2 weeks ago
  1. +6
    -0
      backend/app/controllers/posts_controller.rb
  2. +37
    -6
      backend/app/controllers/tags_controller.rb
  3. +4
    -2
      backend/app/models/post.rb
  4. +28
    -20
      backend/app/models/tag.rb
  5. +0
    -6
      backend/app/models/tag_alias.rb
  6. +33
    -0
      backend/app/models/tag_name.rb
  7. +40
    -0
      backend/spec/requests/posts_spec.rb
  8. +15
    -0
      backend/spec/requests/tags_spec.rb

+ 6
- 0
backend/app/controllers/posts_controller.rb View File

@@ -97,6 +97,8 @@ class PostsController < ApplicationController
tags = Tag.normalise_tags(tag_names) tags = Tag.normalise_tags(tag_names)
tags = Tag.expand_parent_tags(tags) tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags) sync_post_tags!(post, tags)

post.reload
render json: post.as_json(include: { tags: { only: [:id, :category, :post_count], render json: post.as_json(include: { tags: { only: [:id, :category, :post_count],
methods: [:name, :has_wiki] } }), methods: [:name, :has_wiki] } }),
status: :created status: :created
@@ -136,6 +138,8 @@ class PostsController < ApplicationController
Tag.normalise_tags(tag_names, with_tagme: false) Tag.normalise_tags(tag_names, with_tagme: false)
tags = Tag.expand_parent_tags(tags) tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags) sync_post_tags!(post, tags)

post.reload
json = post.as_json json = post.as_json
json['tags'] = build_tag_tree_for(post.tags) json['tags'] = build_tag_tree_for(post.tags)
render json:, status: :ok render json:, status: :ok
@@ -197,6 +201,8 @@ class PostsController < ApplicationController
end end


def filter_posts_by_tags tag_names, match_type def filter_posts_by_tags tag_names, match_type
tag_names = TagName.canonicalise(tag_names)

posts = Post.joins(tags: :tag_name) posts = Post.joins(tags: :tag_name)


if match_type == 'any' if match_type == 'any'


+ 37
- 6
backend/app/controllers/tags_controller.rb View File

@@ -16,12 +16,43 @@ class TagsController < ApplicationController
q = params[:q].to_s.strip q = params[:q].to_s.strip
return render json: [] if q.blank? return render json: [] if q.blank?


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 }%")
.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])
with_nico = !(params[:nico].to_s.strip.downcase.in?(['0', 'false', 'off', 'no']))

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 end


def show def show


+ 4
- 2
backend/app/models/post.rb View File

@@ -8,8 +8,10 @@ class Post < ApplicationRecord
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post 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 :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :tags, through: :active_post_tags has_many :tags, through: :active_post_tags
has_many :user_post_views, dependent: :destroy
has_many :post_similarities

has_many :user_post_views, dependent: :delete_all
has_many :post_similarities, dependent: :delete_all

has_one_attached :thumbnail has_one_attached :thumbnail


before_validation :normalise_url before_validation :normalise_url


+ 28
- 20
backend/app/models/tag.rb View File

@@ -3,53 +3,53 @@ class Tag < ApplicationRecord
; ;
end 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 :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :posts, through: :active_post_tags 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 :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy
has_many :linked_tags, through: :nico_tag_relations, source: :tag 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 :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag


has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy
has_many :children, through: :tag_implications, source: :tag 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 has_many :parents, through: :reversed_tag_implications, source: :parent_tag


has_many :tag_similarities, dependent: :delete_all

belongs_to :tag_name belongs_to :tag_name
delegate :name, to: :tag_name, allow_nil: true delegate :name, to: :tag_name, allow_nil: true
validates :tag_name, presence: true validates :tag_name, presence: true


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


validates :category, presence: true, inclusion: { in: Tag.categories.keys } validates :category, presence: true, inclusion: { in: Tag.categories.keys }


validate :nico_tag_name_must_start_with_nico validate :nico_tag_name_must_start_with_nico
validate :tag_name_must_be_canonical


scope :nico_tags, -> { where(category: :nico) } scope :nico_tags, -> { where(category: :nico) }


CATEGORY_PREFIXES = { 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 def name= val
(self.tag_name ||= build_tag_name).name = val (self.tag_name ||= build_tag_name).name = val
@@ -60,11 +60,11 @@ class Tag < ApplicationRecord
end end


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


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


def self.normalise_tags tag_names, with_tagme: true, deny_nico: true def self.normalise_tags tag_names, with_tagme: true, deny_nico: true
@@ -74,8 +74,8 @@ class Tag < ApplicationRecord


tags = tag_names.map do |name| tags = tag_names.map do |name|
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.start_with?(p) } || ['', nil] 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|
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 if cat && tag.category != cat
tag.update!(category: cat) tag.update!(category: cat)
end end
@@ -111,6 +111,8 @@ class Tag < ApplicationRecord


def self.find_or_create_by_tag_name! name, category: def self.find_or_create_by_tag_name! name, category:
tn = TagName.find_or_create_by!(name: name.to_s.strip) 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| Tag.find_or_create_by!(tag_name_id: tn.id) do |t|
t.category = category t.category = category
end end
@@ -127,4 +129,10 @@ class Tag < ApplicationRecord
errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.' errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.'
end end
end end

def tag_name_must_be_canonical
if tag_name&.canonical_id?
errors.add :tag_name, 'tag_names へは実体を示す必要があります.'
end
end
end end

+ 0
- 6
backend/app/models/tag_alias.rb View File

@@ -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

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

@@ -6,4 +6,37 @@ class TagName < ApplicationRecord
has_many :aliases, class_name: 'TagName', foreign_key: :canonical_id has_many :aliases, class_name: 'TagName', foreign_key: :canonical_id


validates :name, presence: true, length: { maximum: 255 }, uniqueness: true validates :name, presence: true, length: { maximum: 255 }, uniqueness: true

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

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

def canonical_must_not_be_present_with_tag_or_wiki_page
if canonical_id? && (tag || wiki_page)
errors.add :canonical, 'タグもしくは Wiki の参照がある名前はエーリアスになれません.'
end
end
end end

+ 40
- 0
backend/spec/requests/posts_spec.rb View File

@@ -30,6 +30,7 @@ RSpec.describe 'Posts API', type: :request do
let!(:tag) { Tag.create!(tag_name:, category: "general") } let!(:tag) { Tag.create!(tag_name:, category: "general") }
let!(:tag_name2) { TagName.create!(name: 'unko') } let!(:tag_name2) { TagName.create!(name: 'unko') }
let!(:tag2) { Tag.create!(tag_name: tag_name2, category: 'deerjikist') } 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 let!(:hit_post) do
Post.create!(uploaded_user: user, title: "hello spec world", Post.create!(uploaded_user: user, title: "hello spec world",
@@ -86,6 +87,25 @@ RSpec.describe 'Posts API', type: :request do
end end
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 it "returns empty posts when nothing matches" do
get "/posts", params: { tags: "no_such_keyword_12345" } get "/posts", params: { tags: "no_such_keyword_12345" }


@@ -135,6 +155,7 @@ RSpec.describe 'Posts API', type: :request do


describe 'POST /posts' do describe 'POST /posts' do
let(:member) { create(:user, :member) } let(:member) { create(:user, :member) }
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }


it '401 when not logged in' do it '401 when not logged in' do
sign_out sign_out
@@ -167,6 +188,25 @@ RSpec.describe 'Posts API', type: :request do
expect(json['tags'][0]).to have_key('name') expect(json['tags'][0]).to have_key('name')
end 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 が正しいこと)
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 context "when nico tag already exists in tags" do
before do before do
Tag.find_or_create_by!(tag_name: TagName.find_or_create_by!(name: 'nico:nico_tag'), Tag.find_or_create_by!(tag_name: TagName.find_or_create_by!(name: 'nico:nico_tag'),


+ 15
- 0
backend/spec/requests/tags_spec.rb View File

@@ -5,6 +5,7 @@ require 'rails_helper'
RSpec.describe 'Tags API', type: :request do RSpec.describe 'Tags API', type: :request do
let!(:tn) { TagName.create!(name: 'spec_tag') } let!(:tn) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name: tn, category: 'general') } let!(:tag) { Tag.create!(tag_name: tn, category: 'general') }
let!(:alias_tn) { TagName.create!(name: 'unko', canonical: tn) }


describe 'GET /tags' do describe 'GET /tags' do
it 'returns tags with name' 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).to be_an(Array)
expect(json.map { |t| t['name'] }).to include('spec_tag') 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
end end




Loading…
Cancel
Save