feat: 別名を検索に展開(#20) (#243)
#20 #20 テスト・ケースのみ追記 #20 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: #243
This commit was merged in pull request #243.
This commit is contained in:
@@ -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'
|
||||||
|
|||||||
@@ -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)
|
with_nico = !(params[:nico].to_s.strip.downcase.in?(['0', 'false', 'off', 'no']))
|
||||||
.where('(tags.category = ? AND tag_names.name LIKE ?) OR tag_names.name LIKE ?',
|
|
||||||
'nico', "nico:#{ q }%", "#{ q }%")
|
alias_rows =
|
||||||
.order(Arel.sql('post_count DESC, tag_names.name ASC'))
|
TagName
|
||||||
.limit(20))
|
.where('name LIKE ?', "#{ q }%")
|
||||||
render json: tags.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki])
|
.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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+33
-25
@@ -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',
|
has_many :reversed_nico_tag_relations,
|
||||||
foreign_key: :tag_id,
|
class_name: 'NicoTagRelation', foreign_key: :tag_id, dependent: :destroy
|
||||||
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',
|
has_many :reversed_tag_implications,
|
||||||
foreign_key: :tag_id,
|
class_name: 'TagImplication', foreign_key: :tag_id, dependent: :destroy
|
||||||
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',
|
'gen:' => :general,
|
||||||
'djk:' => 'deerjikist',
|
'djk:' => :deerjikist,
|
||||||
'meme:' => 'meme',
|
'meme:' => :meme,
|
||||||
'chr:' => 'character',
|
'chr:' => :character,
|
||||||
'mtr:' => 'material',
|
'mtr:' => :material,
|
||||||
'meta:' => 'meta' }.freeze
|
'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)
|
name = TagName.canonicalise(name.delete_prefix(pf)).first
|
||||||
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
|
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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user