From 089727c153d67eff53c25503debd14e6be922160 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 23 Sep 2026 14:40:59 +0900 Subject: [PATCH] #419 --- .../controllers/post_versions_controller.rb | 4 +- backend/app/controllers/posts_controller.rb | 32 ++- backend/app/controllers/tags_controller.rb | 214 ++++++++++++++---- backend/spec/requests/tags_spec.rb | 21 +- 4 files changed, 207 insertions(+), 64 deletions(-) diff --git a/backend/app/controllers/post_versions_controller.rb b/backend/app/controllers/post_versions_controller.rb index 811227b..0ce1670 100644 --- a/backend/app/controllers/post_versions_controller.rb +++ b/backend/app/controllers/post_versions_controller.rb @@ -31,9 +31,9 @@ class PostVersionsController < ApplicationController q = q.where('post_versions.post_id = ?', post_id) if post_id if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id))) q = q.where('JSON_CONTAINS(post_versions.tags_json,' + - "JSON_OBJECT('external_tag_id', #{ external_tag_id })) " + + "JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " + 'OR JSON_CONTAINS(prev.tags_json,' + - "JSON_OBJECT('external_tag_id', #{ external_tag_id }))") + "JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id }))") elsif tag_id q = q.where("JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('tag_id', #{ tag_id })) " + "OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('tag_id', #{ tag_id }))") diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index e2e109d..b9023b0 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -49,7 +49,7 @@ class PostsController < ApplicationController filtered_posts .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) - .preload(:uploaded_user, :parents, :children, + .preload(:external_tags, :uploaded_user, :parents, :children, post_tags: [:sections, { tag: [:deerjikists, :materials, { tag_name: :wiki_page }] }]) .with_attached_thumbnail @@ -102,12 +102,14 @@ class PostsController < ApplicationController end def random - post = filtered_posts.preload(:uploaded_user, :parents, :children, - post_tags: [:sections, { tag: [:deerjikists, :materials, - { tag_name: :wiki_page }] }]) - .with_attached_thumbnail - .order('RAND()') - .first + post = + filtered_posts + .preload(:uploaded_user, :parents, :children, + post_tags: [:sections, { tag: [:deerjikists, :materials, + { tag_name: :wiki_page }] }]) + .with_attached_thumbnail + .order('RAND()') + .first return head :not_found unless post render json: PostRepr.base(post, current_user) @@ -426,8 +428,20 @@ class PostsController < ApplicationController end end - def tagged_post_ids_for(name) = - Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id) + def tagged_post_ids_for(name) + posts_by_internal_tags = + Post + .joins(tags: :tag_name) + .where(tag_names: { name: }) + .select(:id) + + posts_by_external_tags = + Post + .joins(:external_tags) + .where("CONCAT(external_tags.platform, ':', external_tags.name) = ?", name) + + (posts_by_internal_tags + posts_by_external_tags).uniq(&:id) + end def sync_post_tags! post, desired_tags, sections desired_tags.each do |t| diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index ba94dce..e689891 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -34,49 +34,155 @@ class TagsController < ApplicationController offset = (page - 1) * limit - q = + tags = if post_id.present? - Tag.joins(:posts, :tag_name) + Tag.joins(:posts, :tag_name).where(posts: { id: post_id }) else Tag.joins(:tag_name) end - .includes(:tag_name, :materials, tag_name: :wiki_page) - q = q.where(posts: { id: post_id }) if post_id.present? - q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name - q = q.where(category:) if category - q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0] - q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1] - q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0] - q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1] - q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0] - q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1] - if deprecated_given - q = deprecated ? q.where.not(deprecated_at: nil) : q.where(deprecated_at: nil) + external_tags = + if post_id.present? + ExternalTag.joins(:posts).where(posts: { id: post_id }) + else + ExternalTag.all + end + + if name + tags = tags.where('tag_names.name LIKE ?', "%#{ name }%") + external_tags = + external_tags.where("CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?", + "%#{ name }%") end + if category == 'nico' + tags = tags.none + elsif category + tags = tags.where(category:) + external_tags = external_tags.none + end + + if post_count_between[0] + tags = tags.where('tags.post_count >= ?', post_count_between[0]) + external_tags = external_tags.where('external_tags.post_count >= ?', post_count_between[0]) + end + + if post_count_between[1] + tags = tags.where('tags.post_count <= ?', post_count_between[1]) + external_tags = external_tags.where('external_tags.post_count <= ?', post_count_between[1]) + end + + if created_between[0] + tags = tags.where('tags.created_at >= ?', created_between[0]) + external_tags = external_tags.where('external_tags.created_at >= ?', created_between[0]) + end + + if created_between[1] + tags = tags.where('tags.created_at <= ?', created_between[1]) + external_tags = external_tags.where('external_tags.created_at <= ?', created_between[1]) + end + + if updated_between[0] + tags = tags.where('tags.updated_at >= ?', updated_between[0]) + external_tags = external_tags.where('external_tags.created_at >= ?', updated_between[0]) + end + + if updated_between[1] + tags = tags.where('tags.updated_at <= ?', updated_between[1]) + external_tags = external_tags.where('external_tags.created_at <= ?', updated_between[1]) + end + + if deprecated_given + if deprecated + tags = tags.where.not(deprecated_at: nil) + external_tags = external_tags.none + else + tags = tags.where(deprecated_at: nil) + end + end + + tag_sql = tags.select("'tag' AS source", + 'tags.id', + 'tag_names.name', + 'tags.category', + 'tags.post_count', + 'tags.created_at', + 'tags.updated_at', + 'tags.deprecated_at').to_sql + + external_tag_sql = external_tags.select( + "'external_tag' AS source", + 'external_tags.id', + "CONCAT(external_tags.platform, ':', external_tags.name) AS name", + "'nico' AS category", + 'external_tags.post_count', + 'external_tags.created_at', + 'external_tags.created_at AS updated_at', + 'NULL AS deprecated_at').to_sql + + union_sql = "#{ tag_sql } UNION ALL #{ external_tag_sql }" + sort_sql = - case order[0] - when 'name' - 'tag_names.name' - when 'category' - 'CASE tags.category ' + + if order[0] == 'category' + 'CASE category ' + "WHEN 'deerjikist' THEN 0 " + "WHEN 'meme' THEN 1 " + "WHEN 'character' THEN 2 " + "WHEN 'general' THEN 3 " + "WHEN 'material' THEN 4 " + "WHEN 'meta' THEN 5 " + - "WHEN 'nico' THEN 6 END" + "WHEN 'nico' THEN 6 " + + 'END' else - "tags.#{ order[0] }" + order[0] end - tags = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }")) - .limit(limit) - .offset(offset) - .to_a - render json: { tags: TagRepr.many(tags), count: q.size } + connection = ApplicationRecord.connection + + count = connection.select_value(<<~SQL) + SELECT + COUNT(0) + FROM + (#{ union_sql }) legacy_tags + SQL + + rows = connection.select_all(<<~SQL).to_a + SELECT + source + , id + FROM + (#{ union_sql }) legacy_tags + ORDER BY + #{ sort_sql } #{ order[1] } + , id #{ order[1] } + , source #{ order[1] } + LIMIT + #{ limit } + OFFSET + #{ offset } + SQL + + tag_ids = rows.filter { _1['source'] == 'tag' }.map { _1['id'] } + external_tag_ids = rows.filter { _1['source'] == 'external_tag' }.map { _1['id'] } + + tags_by_id = + Tag + .joins(:tag_name) + .includes(:tag_name, :materials, tag_name: :wiki_page) + .where(id: tag_ids) + .index_by(&:id) + external_tags_by_id = + ExternalTag + .where(id: external_tag_ids) + .index_by(&:id) + + render json: { tags: rows.map { |row| + if row['source'] == 'tag' + TagRepr.base(tags_by_id.fetch(row['id'])) + else + external_tag_json(external_tags_by_id.fetch(row['id'])) + end + }, count: } end def with_depth @@ -128,12 +234,7 @@ class TagsController < ApplicationController canonical_hit = base.where('tag_names.name LIKE ?', prefix) - internal_tags = - if with_nico - canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq)) - else - canonical_hit - end + internal_tags = canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq)) internal_rows = internal_tags @@ -153,16 +254,7 @@ class TagsController < ApplicationController .where("CONCAT(platform, ':', name) LIKE ? OR name LIKE ?", prefix, prefix) .order(post_count: :desc, name: :asc) .limit(20) - .map { |tag| - { 'id' => tag.id, - 'name' => "#{ tag.platform }:#{ tag.name }", - 'category' => 'nico', - 'deprecated_at' => nil, - 'created_at' => tag.created_at, - 'updated_at' => tag.created_at, - 'post_count' => tag.post_count, - 'matched_alias' => nil } - } + .map { external_tag_json(_1) } rows = (internal_rows + external_rows) @@ -187,14 +279,20 @@ class TagsController < ApplicationController name = params[:name].to_s.strip return render_bad_request('name は必須です.') if name.blank? - tag = Tag.joins(:tag_name) - .includes(:tag_name, :materials, tag_name: :wiki_page) - .find_by(tag_names: { name: }) - if tag - render json: TagRepr.base(tag) - else - head :not_found - end + tag = + Tag + .joins(:tag_name) + .includes(:tag_name, :materials, tag_name: :wiki_page) + .find_by(tag_names: { name: }) + return render json: TagRepr.base(tag) if tag + + platform, external_name = name.split(':', 2) + return head :not_found unless external_name + + external_tag = ExternalTag.find_by(platform:, name: external_name) + return head :not_found unless external_tag + + render json: ExternalTagRepr.base(external_tag) end def deerjikists @@ -805,4 +903,20 @@ class TagsController < ApplicationController render_validation_error fields: end + + def external_tag_json tag + { 'id' => tag.id, + 'name' => "#{ tag.platform }:#{ tag.name }", + 'category' => 'nico', + 'deprecated_at' => nil, + 'created_at' => tag.created_at, + 'updated_at' => tag.created_at, + 'post_count' => tag.post_count, + 'matched_alias' => nil, + 'aliases' => [], + 'parents' => [], + 'has_wiki' => false, + 'material_id' => nil, + 'has_deerjikists' => false } + end end diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 44aeea6..c9c36fe 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -200,9 +200,24 @@ RSpec.describe 'Tags API', type: :request do end context 'with mixed legacy pagination' do - let!(:first_tag) { create(:tag, name: 'a_mixed_page', category: :meme) } - let!(:middle_tag) { create(:tag, name: 'm_mixed_page', category: :meta) } - let!(:last_tag) { create(:tag, name: 'z_mixed_page', category: :general) } + let!(:first_tag) do + create(:tag, + tag_name: create(:tag_name, name: 'a_mixed_page'), + category: :meme) + end + + let!(:middle_tag) do + create(:tag, + tag_name: create(:tag_name, name: 'm_mixed_page'), + category: :meta) + end + + let!(:last_tag) do + create(:tag, + tag_name: create(:tag_name, name: 'z_mixed_page'), + category: :general) + end + let!(:first_external) do create(:external_tag, id: first_tag.id, name: 'a_mixed_page') end