Cleanup search classes and serializers (#13645)
This commit is contained in:
parent
41cc771839
commit
a0ddb6a848
47 changed files with 845 additions and 744 deletions
|
|
@ -60,7 +60,7 @@ class SearchController < ApplicationController
|
|||
].freeze
|
||||
|
||||
def tags
|
||||
result = Search::Postgres::Tag.search_documents(params[:name])
|
||||
result = Search::Tag.search_documents(params[:name])
|
||||
|
||||
render json: { result: result }
|
||||
end
|
||||
|
|
@ -73,7 +73,7 @@ class SearchController < ApplicationController
|
|||
[current_user.id]
|
||||
end
|
||||
|
||||
result = Search::Postgres::ChatChannelMembership.search_documents(
|
||||
result = Search::ChatChannelMembership.search_documents(
|
||||
user_ids: user_ids,
|
||||
page: chat_channel_params[:page],
|
||||
per_page: chat_channel_params[:per_page],
|
||||
|
|
@ -83,7 +83,7 @@ class SearchController < ApplicationController
|
|||
end
|
||||
|
||||
def listings
|
||||
result = Search::Postgres::Listing.search_documents(
|
||||
result = Search::Listing.search_documents(
|
||||
category: listing_params[:category],
|
||||
page: listing_params[:page],
|
||||
per_page: listing_params[:per_page],
|
||||
|
|
@ -94,7 +94,7 @@ class SearchController < ApplicationController
|
|||
end
|
||||
|
||||
def usernames
|
||||
result = Search::Postgres::Username.search_documents(params[:username])
|
||||
result = Search::Username.search_documents(params[:username])
|
||||
|
||||
render json: { result: result }
|
||||
end
|
||||
|
|
@ -135,7 +135,7 @@ class SearchController < ApplicationController
|
|||
per_page: params[:per_page],
|
||||
)
|
||||
elsif class_name.Comment?
|
||||
Search::Postgres::Comment.search_documents(
|
||||
Search::Comment.search_documents(
|
||||
page: feed_params[:page],
|
||||
per_page: feed_params[:per_page],
|
||||
sort_by: feed_params[:sort_by],
|
||||
|
|
@ -143,7 +143,7 @@ class SearchController < ApplicationController
|
|||
term: feed_params[:search_fields],
|
||||
)
|
||||
elsif class_name.PodcastEpisode?
|
||||
Search::Postgres::PodcastEpisode.search_documents(
|
||||
Search::PodcastEpisode.search_documents(
|
||||
page: feed_params[:page],
|
||||
per_page: feed_params[:per_page],
|
||||
sort_by: feed_params[:sort_by],
|
||||
|
|
@ -151,7 +151,7 @@ class SearchController < ApplicationController
|
|||
term: feed_params[:search_fields],
|
||||
)
|
||||
elsif class_name.User?
|
||||
Search::Postgres::User.search_documents(
|
||||
Search::User.search_documents(
|
||||
term: feed_params[:search_fields],
|
||||
page: feed_params[:page],
|
||||
per_page: feed_params[:per_page],
|
||||
|
|
@ -168,7 +168,7 @@ class SearchController < ApplicationController
|
|||
def reactions
|
||||
# [@rhymes] we're recyling the existing params as we want to change the frontend as
|
||||
# little as possible, we might simplify in the future
|
||||
result = Search::Postgres::ReadingList.search_documents(
|
||||
result = Search::ReadingList.search_documents(
|
||||
current_user,
|
||||
page: reaction_params[:page],
|
||||
per_page: reaction_params[:per_page],
|
||||
|
|
@ -183,7 +183,7 @@ class SearchController < ApplicationController
|
|||
private
|
||||
|
||||
def search_postgres_article
|
||||
Search::Postgres::Article.search_documents(
|
||||
Search::Article.search_documents(
|
||||
term: feed_params[:search_fields],
|
||||
user_id: feed_params[:user_id],
|
||||
sort_by: feed_params[:sort_by],
|
||||
|
|
|
|||
|
|
@ -2,26 +2,54 @@ module Search
|
|||
class CommentSerializer < ApplicationSerializer
|
||||
attribute :id, &:search_id
|
||||
|
||||
attributes :path, :public_reactions_count
|
||||
attributes :path do |comment|
|
||||
user = comment.user
|
||||
|
||||
if user
|
||||
"/#{user.username}/comment/#{comment.id_code_generated}"
|
||||
else
|
||||
"/404.html"
|
||||
end
|
||||
end
|
||||
|
||||
attributes :public_reactions_count
|
||||
|
||||
attribute :body_text, &:body_markdown
|
||||
attribute :class_name do |comment|
|
||||
comment.class.name
|
||||
end
|
||||
attribute :hotness_score, &:score
|
||||
attribute :published do |comment|
|
||||
comment.commentable&.published
|
||||
end
|
||||
attribute :published_at, &:created_at
|
||||
attribute :readable_publish_date_string, &:readable_publish_date
|
||||
attribute :title do |comment|
|
||||
comment.commentable&.title
|
||||
|
||||
attribute :class_name, -> { "Comment" }
|
||||
|
||||
attribute :highlight do |comment|
|
||||
{
|
||||
body_text: [comment.pg_search_highlight]
|
||||
}
|
||||
rescue PgSearch::PgSearchHighlightNotSelected
|
||||
# This is needed because in Search::Comment we only call the
|
||||
# search if a term is provided. This means if a user searches with a
|
||||
# blank term, we skip the line that executes .with_pg_search_highlight.
|
||||
# Skipping this AND trying to call .pg_search_highlight raises an error
|
||||
# which we ignore here - basically filling in highlights if they're
|
||||
# there.
|
||||
{
|
||||
body_text: []
|
||||
}
|
||||
end
|
||||
|
||||
attribute :hotness_score, &:score
|
||||
attribute :published, -> { true }
|
||||
attribute :published_at, &:created_at
|
||||
attribute :readable_publish_date_string, &:readable_publish_date
|
||||
attribute :title, &:commentable_title
|
||||
|
||||
# NOTE: not using the `NestedUserSerializer` to avoid hitting Redis to
|
||||
# fetch the cached value
|
||||
attribute :user do |comment|
|
||||
NestedUserSerializer.new(comment.user).serializable_hash.dig(
|
||||
:data, :attributes
|
||||
)
|
||||
user = comment.user
|
||||
|
||||
if user
|
||||
user.slice(:name, :profile_image_90, :username).symbolize_keys
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
module Search
|
||||
# TODO: [@atsmith813] Rename this to just ListingSerializer once Elasticsearch
|
||||
# is fully removed
|
||||
class ListingResultSerializer < ApplicationSerializer
|
||||
attributes :id,
|
||||
:body_markdown,
|
||||
:bumped_at,
|
||||
:category,
|
||||
:contact_via_connect,
|
||||
:expires_at,
|
||||
:originally_published_at,
|
||||
:location,
|
||||
:processed_html,
|
||||
:published,
|
||||
:slug,
|
||||
:title,
|
||||
:user_id
|
||||
|
||||
attribute :tags do |listing|
|
||||
listing.cached_tag_list.to_s.split(", ")
|
||||
end
|
||||
|
||||
attribute :author do |listing|
|
||||
ListingAuthorSerializer.new(listing.author)
|
||||
.serializable_hash
|
||||
.dig(:data, :attributes)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -14,10 +14,12 @@ module Search
|
|||
:title,
|
||||
:user_id
|
||||
|
||||
attribute :tags, &:tag_list
|
||||
attribute :tags do |listing|
|
||||
listing.cached_tag_list.to_s.split(", ")
|
||||
end
|
||||
|
||||
attribute :author do |cl|
|
||||
ListingAuthorSerializer.new(cl.author)
|
||||
attribute :author do |listing|
|
||||
ListingAuthorSerializer.new(listing.author)
|
||||
.serializable_hash
|
||||
.dig(:data, :attributes)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,27 +1,35 @@
|
|||
module Search
|
||||
class PodcastEpisodeSerializer < ApplicationSerializer
|
||||
def self.podcast_image_url(podcast_episode)
|
||||
Images::Profile.call(podcast_episode.podcast.profile_image_url, length: 90)
|
||||
end
|
||||
|
||||
attribute :id, &:search_id
|
||||
attribute :body_text, &:body
|
||||
|
||||
attributes :body_text, :class_name, :comments_count, :hotness_score, :path,
|
||||
:public_reactions_count, :published, :published_at, :quote,
|
||||
:reactions_count, :search_score, :subtitle, :summary, :title,
|
||||
:website_url
|
||||
attributes :comments_count, :path, :published_at, :quote, :reactions_count,
|
||||
:subtitle, :summary, :title, :website_url
|
||||
|
||||
attribute :main_image do |pde|
|
||||
Images::Profile.call(pde.podcast.profile_image_url, length: 90)
|
||||
attribute :class_name, -> { "PodcastEpisode" }
|
||||
attribute :highlight, -> { { body_text: [] } } # We don't display highlights in the UI for Podcasts
|
||||
attribute :hotness_score, -> { 0 }
|
||||
|
||||
attribute :main_image do |podcast_episode|
|
||||
podcast_image_url(podcast_episode)
|
||||
end
|
||||
|
||||
attribute :podcast do |podcast_episode|
|
||||
{
|
||||
slug: podcast_episode.podcast_slug,
|
||||
image_url: podcast_image_url(podcast_episode),
|
||||
title: podcast_episode.title
|
||||
}
|
||||
end
|
||||
|
||||
attribute :public_reactions_count, -> { 0 }
|
||||
attribute :published, -> { true }
|
||||
attribute :search_score, -> { 0 }
|
||||
attribute :slug, &:podcast_slug
|
||||
|
||||
attribute :tags do |pde|
|
||||
pde.tags.map do |tag|
|
||||
{ name: tag.name, keywords_for_search: tag.keywords_for_search }
|
||||
end
|
||||
end
|
||||
|
||||
attribute :user do |pde|
|
||||
NestedUserSerializer.new(pde.podcast.creator).serializable_hash.dig(
|
||||
:data, :attributes
|
||||
)
|
||||
end
|
||||
attribute :user, -> { {} } # User data is not used in the UX for Podcasts
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
module Search
|
||||
# TODO[@atsmith813]: Rename this to CommentSerializer once Elasticsearch is removed
|
||||
class PostgresCommentSerializer < ApplicationSerializer
|
||||
attribute :id, &:search_id
|
||||
|
||||
attributes :path do |comment|
|
||||
user = comment.user
|
||||
|
||||
if user
|
||||
"/#{user.username}/comment/#{comment.id_code_generated}"
|
||||
else
|
||||
"/404.html"
|
||||
end
|
||||
end
|
||||
|
||||
attributes :public_reactions_count
|
||||
|
||||
attribute :body_text, &:body_markdown
|
||||
|
||||
attribute :class_name, -> { "Comment" }
|
||||
|
||||
attribute :highlight do |comment|
|
||||
{
|
||||
body_text: [comment.pg_search_highlight]
|
||||
}
|
||||
rescue PgSearch::PgSearchHighlightNotSelected
|
||||
# This is needed because in Search::Postgres::Comment we only call the
|
||||
# search if a term is provided. This means if a user searches with a
|
||||
# blank term, we skip the line that executes .with_pg_search_highlight.
|
||||
# Skipping this AND trying to call .pg_search_highlight raises an error
|
||||
# which we ignore here - basically filling in highlights if they're
|
||||
# there.
|
||||
end
|
||||
|
||||
attribute :hotness_score, &:score
|
||||
attribute :published, -> { true }
|
||||
attribute :published_at, &:created_at
|
||||
attribute :readable_publish_date_string, &:readable_publish_date
|
||||
attribute :title, &:commentable_title
|
||||
|
||||
# NOTE: not using the `NestedUserSerializer` to avoid hitting Redis to
|
||||
# fetch the cached value
|
||||
attribute :user do |comment|
|
||||
user = comment.user
|
||||
|
||||
if user
|
||||
user.slice(:name, :profile_image_90, :username).symbolize_keys
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
module Search
|
||||
# TODO[@atsmith813]: Rename this to PodcastEpisodeSerializer once Elasticsearch is removed
|
||||
class PostgresPodcastEpisodeSerializer < ApplicationSerializer
|
||||
def self.podcast_image_url(podcast_episode)
|
||||
Images::Profile.call(podcast_episode.podcast.profile_image_url, length: 90)
|
||||
end
|
||||
|
||||
attribute :id, &:search_id
|
||||
attribute :body_text, &:body
|
||||
|
||||
attributes :comments_count, :path, :published_at, :quote, :reactions_count,
|
||||
:subtitle, :summary, :title, :website_url
|
||||
|
||||
attribute :class_name, -> { "PodcastEpisode" }
|
||||
attribute :highlight, -> { { body_text: [] } } # We don't display highlights in the UI for Podcasts
|
||||
attribute :hotness_score, -> { 0 }
|
||||
|
||||
attribute :main_image do |podcast_episode|
|
||||
podcast_image_url(podcast_episode)
|
||||
end
|
||||
|
||||
attribute :podcast do |podcast_episode|
|
||||
{
|
||||
slug: podcast_episode.podcast_slug,
|
||||
image_url: podcast_image_url(podcast_episode),
|
||||
title: podcast_episode.title
|
||||
}
|
||||
end
|
||||
|
||||
attribute :public_reactions_count, -> { 0 }
|
||||
attribute :published, -> { true }
|
||||
attribute :search_score, -> { 0 }
|
||||
attribute :slug, &:podcast_slug
|
||||
attribute :user, -> { {} } # User data is not used in the UX for Podcasts
|
||||
end
|
||||
end
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
module Search
|
||||
# to be renamed when ES is gone
|
||||
class PostgresUserSerializer < ApplicationSerializer
|
||||
class SimpleUserSerializer < ApplicationSerializer
|
||||
attribute :class_name, -> { "User" }
|
||||
attributes :id, :path
|
||||
attribute :title, &:name
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
module Search
|
||||
class UsernameSerializer < ApplicationSerializer
|
||||
attributes :id,
|
||||
:name,
|
||||
:profile_image_90,
|
||||
:username
|
||||
end
|
||||
end
|
||||
43
app/services/search/article.rb
Normal file
43
app/services/search/article.rb
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
module Search
|
||||
class Article
|
||||
DEFAULT_SORT_BY = "hotness_score DESC, comments_count DESC".freeze
|
||||
|
||||
def self.search_documents(
|
||||
term: nil,
|
||||
user_id: nil,
|
||||
sort_by: nil,
|
||||
sort_direction: nil,
|
||||
page: nil,
|
||||
per_page: nil
|
||||
)
|
||||
relation = Homepage::ArticlesQuery.call(user_id: user_id, page: page, per_page: per_page)
|
||||
|
||||
relation = relation.search_articles(term) if term.present?
|
||||
|
||||
relation = sort(relation, sort_by, sort_direction)
|
||||
|
||||
tag_flares = Homepage::FetchTagFlares.call(relation)
|
||||
|
||||
# including user and organization as the last step as they are not needed
|
||||
# by the query that fetches tag flares, they are only needed by the serializer
|
||||
relation = relation.includes(:user, :organization)
|
||||
|
||||
serialize(relation, tag_flares)
|
||||
end
|
||||
|
||||
def self.sort(relation, sort_by, sort_direction)
|
||||
return relation.reorder(sort_by => sort_direction) if sort_by&.to_sym == :published_at
|
||||
|
||||
relation.reorder(DEFAULT_SORT_BY)
|
||||
end
|
||||
private_class_method :sort
|
||||
|
||||
def self.serialize(articles, tag_flares)
|
||||
Homepage::ArticleSerializer
|
||||
.new(articles, params: { tag_flares: tag_flares }, is_collection: true)
|
||||
.serializable_hash[:data]
|
||||
.pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
59
app/services/search/chat_channel_membership.rb
Normal file
59
app/services/search/chat_channel_membership.rb
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
module Search
|
||||
class ChatChannelMembership
|
||||
ATTRIBUTES = %w[
|
||||
chat_channel_memberships.id
|
||||
chat_channel_memberships.chat_channel_id
|
||||
chat_channel_memberships.last_opened_at
|
||||
chat_channel_memberships.status
|
||||
chat_channel_memberships.user_id
|
||||
chat_channels.channel_name
|
||||
chat_channels.discoverable
|
||||
chat_channels.last_message_at
|
||||
chat_channels.slug
|
||||
chat_channels.status
|
||||
users.name
|
||||
users.profile_image
|
||||
users.username
|
||||
].freeze
|
||||
private_constant :ATTRIBUTES
|
||||
|
||||
# TODO: @mstruve: When we want to allow people like admins to search ALL
|
||||
# memberships this will need to change
|
||||
PERMITTED_STATUSES = %w[
|
||||
active
|
||||
joining_request
|
||||
].freeze
|
||||
|
||||
DEFAULT_PER_PAGE = 30
|
||||
private_constant :DEFAULT_PER_PAGE
|
||||
|
||||
MAX_PER_PAGE = 60 # to avoid querying too many items, we set a maximum amount for a page
|
||||
private_constant :MAX_PER_PAGE
|
||||
|
||||
def self.search_documents(user_ids:, page: 0, per_page: DEFAULT_PER_PAGE)
|
||||
# NOTE: [@rhymes/atsmith813] we should eventually update the frontend
|
||||
# to start from page 1
|
||||
page = page.to_i + 1
|
||||
per_page = [(per_page || DEFAULT_PER_PAGE).to_i, MAX_PER_PAGE].min
|
||||
|
||||
relation = ::ChatChannelMembership
|
||||
.includes(:user, chat_channel: :messages)
|
||||
.where("chat_channel_memberships.status": PERMITTED_STATUSES)
|
||||
.where("chat_channel_memberships.user_id": user_ids)
|
||||
.select(*ATTRIBUTES)
|
||||
.order("chat_channels.last_message_at desc")
|
||||
|
||||
results = relation.page(page).per(per_page)
|
||||
|
||||
serialize(results)
|
||||
end
|
||||
|
||||
def self.serialize(results)
|
||||
Search::ChatChannelMembershipSerializer
|
||||
.new(results, is_collection: true)
|
||||
.serializable_hash[:data]
|
||||
.pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
75
app/services/search/comment.rb
Normal file
75
app/services/search/comment.rb
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
module Search
|
||||
class Comment
|
||||
ATTRIBUTES = [
|
||||
"COALESCE(articles.published, false) AS commentable_published",
|
||||
"COALESCE(articles.title, '') AS commentable_title",
|
||||
"comments.body_markdown",
|
||||
"comments.commentable_id",
|
||||
"comments.commentable_type",
|
||||
"comments.created_at",
|
||||
"comments.id AS id",
|
||||
"comments.public_reactions_count",
|
||||
"comments.score",
|
||||
"comments.user_id",
|
||||
].freeze
|
||||
private_constant :ATTRIBUTES
|
||||
|
||||
DEFAULT_PER_PAGE = 60
|
||||
private_constant :DEFAULT_PER_PAGE
|
||||
|
||||
DEFAULT_SORT_BY = "comments.score".freeze
|
||||
private_constant :DEFAULT_SORT_BY
|
||||
|
||||
DEFAULT_SORT_DIRECTION = :desc
|
||||
private_constant :DEFAULT_SORT_DIRECTION
|
||||
|
||||
MAX_PER_PAGE = 120 # to avoid querying too many items, we set a maximum amount for a page
|
||||
private_constant :MAX_PER_PAGE
|
||||
|
||||
def self.search_documents(
|
||||
page: 0,
|
||||
per_page: DEFAULT_PER_PAGE,
|
||||
sort_by: DEFAULT_SORT_BY,
|
||||
sort_direction: DEFAULT_SORT_DIRECTION,
|
||||
term: nil
|
||||
)
|
||||
sort_by ||= DEFAULT_SORT_BY
|
||||
# The UI and serializer rename created_at (the actual DB column name) to
|
||||
# published_at
|
||||
sort_by = "comments.created_at" if sort_by == "published_at"
|
||||
|
||||
sort_direction ||= DEFAULT_SORT_DIRECTION
|
||||
|
||||
# NOTE: [@rhymes/atsmith813] we should eventually update the frontend
|
||||
# to start from page 1
|
||||
page = page.to_i + 1
|
||||
per_page = [(per_page || DEFAULT_PER_PAGE).to_i, MAX_PER_PAGE].min
|
||||
|
||||
relation = ::Comment
|
||||
.includes(:user)
|
||||
.where(
|
||||
deleted: false,
|
||||
hidden_by_commentable_user: false,
|
||||
commentable_type: "Article",
|
||||
)
|
||||
.joins("join articles on articles.id = comments.commentable_id")
|
||||
.where("articles.published": true)
|
||||
|
||||
relation = relation.search_comments(term).with_pg_search_highlight if term.present?
|
||||
|
||||
relation = relation.select(*ATTRIBUTES).reorder("#{sort_by}": sort_direction)
|
||||
|
||||
results = relation.page(page).per(per_page)
|
||||
|
||||
serialize(results)
|
||||
end
|
||||
|
||||
def self.serialize(results)
|
||||
Search::CommentSerializer
|
||||
.new(results, is_collection: true)
|
||||
.serializable_hash[:data]
|
||||
.pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
57
app/services/search/listing.rb
Normal file
57
app/services/search/listing.rb
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
module Search
|
||||
class Listing
|
||||
ATTRIBUTES = %i[
|
||||
id
|
||||
body_markdown
|
||||
bumped_at
|
||||
cached_tag_list
|
||||
classified_listing_category_id
|
||||
contact_via_connect
|
||||
expires_at
|
||||
organization_id
|
||||
originally_published_at
|
||||
location
|
||||
processed_html
|
||||
published
|
||||
slug
|
||||
title
|
||||
user_id
|
||||
].freeze
|
||||
private_constant :ATTRIBUTES
|
||||
|
||||
DEFAULT_PER_PAGE = 75
|
||||
private_constant :DEFAULT_PER_PAGE
|
||||
|
||||
MAX_PER_PAGE = 150 # to avoid querying too many items, we set a maximum amount for a page
|
||||
private_constant :MAX_PER_PAGE
|
||||
|
||||
def self.search_documents(category: nil, page: 0, per_page: DEFAULT_PER_PAGE, term: nil)
|
||||
# NOTE: [@rhymes/atsmith813] we should eventually update the frontend
|
||||
# to start from page 1
|
||||
page = page.to_i + 1
|
||||
per_page = [(per_page || DEFAULT_PER_PAGE).to_i, MAX_PER_PAGE].min
|
||||
|
||||
relation = ::Listing
|
||||
.includes(:user, :organization, :listing_category)
|
||||
.where(published: true)
|
||||
|
||||
relation = relation.where("classified_listing_categories.slug": category) if category.present?
|
||||
|
||||
relation = relation.search_listings(term) if term.present?
|
||||
|
||||
relation = relation.select(*ATTRIBUTES).order(bumped_at: :desc)
|
||||
|
||||
results = relation.page(page).per(per_page)
|
||||
|
||||
serialize(results)
|
||||
end
|
||||
|
||||
def self.serialize(results)
|
||||
Search::ListingSerializer
|
||||
.new(results, is_collection: true)
|
||||
.serializable_hash[:data]
|
||||
.pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
60
app/services/search/podcast_episode.rb
Normal file
60
app/services/search/podcast_episode.rb
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
module Search
|
||||
class PodcastEpisode
|
||||
ATTRIBUTES = %w[
|
||||
body
|
||||
comments_count
|
||||
id
|
||||
podcast_id
|
||||
processed_html
|
||||
published_at
|
||||
quote
|
||||
reactions_count
|
||||
slug
|
||||
subtitle
|
||||
summary
|
||||
title
|
||||
website_url
|
||||
].freeze
|
||||
private_constant :ATTRIBUTES
|
||||
|
||||
DEFAULT_PER_PAGE = 60
|
||||
private_constant :DEFAULT_PER_PAGE
|
||||
|
||||
MAX_PER_PAGE = 120 # to avoid querying too many items, we set a maximum amount for a page
|
||||
private_constant :MAX_PER_PAGE
|
||||
|
||||
def self.search_documents(page: 0, per_page: DEFAULT_PER_PAGE, sort_by: nil, sort_direction: nil, term: nil)
|
||||
# NOTE: [@rhymes/atsmith813] we should eventually update the frontend
|
||||
# to start from page 1
|
||||
page = page.to_i + 1
|
||||
per_page = [(per_page || DEFAULT_PER_PAGE).to_i, MAX_PER_PAGE].min
|
||||
|
||||
relation = ::PodcastEpisode
|
||||
.reachable
|
||||
.where(podcast_id: Podcast.published)
|
||||
.includes(:podcast)
|
||||
.references(:podcasts)
|
||||
relation = relation.search_podcast_episodes(term) if term.present?
|
||||
relation = relation.select(*ATTRIBUTES)
|
||||
relation = sort(relation, sort_by, sort_direction)
|
||||
results = relation.page(page).per(per_page)
|
||||
|
||||
serialize(results)
|
||||
end
|
||||
|
||||
def self.sort(relation, sort_by, sort_direction)
|
||||
return relation.reorder(sort_by => sort_direction) if sort_by && sort_direction
|
||||
|
||||
relation.reorder(nil)
|
||||
end
|
||||
private_class_method :sort
|
||||
|
||||
def self.serialize(results)
|
||||
Search::PodcastEpisodeSerializer
|
||||
.new(results, is_collection: true)
|
||||
.serializable_hash[:data]
|
||||
.pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
module Search
|
||||
module Postgres
|
||||
class Article
|
||||
DEFAULT_SORT_BY = "hotness_score DESC, comments_count DESC".freeze
|
||||
|
||||
def self.search_documents(
|
||||
term: nil,
|
||||
user_id: nil,
|
||||
sort_by: nil,
|
||||
sort_direction: nil,
|
||||
page: nil,
|
||||
per_page: nil
|
||||
)
|
||||
relation = Homepage::ArticlesQuery.call(user_id: user_id, page: page, per_page: per_page)
|
||||
|
||||
relation = relation.search_articles(term) if term.present?
|
||||
|
||||
relation = sort(relation, sort_by, sort_direction)
|
||||
|
||||
tag_flares = Homepage::FetchTagFlares.call(relation)
|
||||
|
||||
# including user and organization as the last step as they are not needed
|
||||
# by the query that fetches tag flares, they are only needed by the serializer
|
||||
relation = relation.includes(:user, :organization)
|
||||
|
||||
serialize(relation, tag_flares)
|
||||
end
|
||||
|
||||
def self.sort(relation, sort_by, sort_direction)
|
||||
return relation.reorder(sort_by => sort_direction) if sort_by&.to_sym == :published_at
|
||||
|
||||
relation.reorder(DEFAULT_SORT_BY)
|
||||
end
|
||||
private_class_method :sort
|
||||
|
||||
def self.serialize(articles, tag_flares)
|
||||
Homepage::ArticleSerializer
|
||||
.new(articles, params: { tag_flares: tag_flares }, is_collection: true)
|
||||
.serializable_hash[:data]
|
||||
.pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
module Search
|
||||
module Postgres
|
||||
class ChatChannelMembership
|
||||
ATTRIBUTES = %w[
|
||||
chat_channel_memberships.id
|
||||
chat_channel_memberships.chat_channel_id
|
||||
chat_channel_memberships.last_opened_at
|
||||
chat_channel_memberships.status
|
||||
chat_channel_memberships.user_id
|
||||
chat_channels.channel_name
|
||||
chat_channels.discoverable
|
||||
chat_channels.last_message_at
|
||||
chat_channels.slug
|
||||
chat_channels.status
|
||||
users.name
|
||||
users.profile_image
|
||||
users.username
|
||||
].freeze
|
||||
private_constant :ATTRIBUTES
|
||||
|
||||
# TODO: @mstruve: When we want to allow people like admins to search ALL
|
||||
# memberships this will need to change
|
||||
PERMITTED_STATUSES = %w[
|
||||
active
|
||||
joining_request
|
||||
].freeze
|
||||
|
||||
DEFAULT_PER_PAGE = 30
|
||||
private_constant :DEFAULT_PER_PAGE
|
||||
|
||||
MAX_PER_PAGE = 60 # to avoid querying too many items, we set a maximum amount for a page
|
||||
private_constant :MAX_PER_PAGE
|
||||
|
||||
def self.search_documents(user_ids:, page: 0, per_page: DEFAULT_PER_PAGE)
|
||||
# NOTE: [@rhymes/atsmith813] we should eventually update the frontend
|
||||
# to start from page 1
|
||||
page = page.to_i + 1
|
||||
per_page = [(per_page || DEFAULT_PER_PAGE).to_i, MAX_PER_PAGE].min
|
||||
|
||||
relation = ::ChatChannelMembership
|
||||
.includes(:user, chat_channel: :messages)
|
||||
.where("chat_channel_memberships.status": PERMITTED_STATUSES)
|
||||
.where("chat_channel_memberships.user_id": user_ids)
|
||||
.select(*ATTRIBUTES)
|
||||
.order("chat_channels.last_message_at desc")
|
||||
|
||||
results = relation.page(page).per(per_page)
|
||||
|
||||
serialize(results)
|
||||
end
|
||||
|
||||
def self.serialize(results)
|
||||
Search::ChatChannelMembershipSerializer
|
||||
.new(results, is_collection: true)
|
||||
.serializable_hash[:data]
|
||||
.pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
module Search
|
||||
module Postgres
|
||||
class Comment
|
||||
ATTRIBUTES = [
|
||||
"COALESCE(articles.published, false) AS commentable_published",
|
||||
"COALESCE(articles.title, '') AS commentable_title",
|
||||
"comments.body_markdown",
|
||||
"comments.commentable_id",
|
||||
"comments.commentable_type",
|
||||
"comments.created_at",
|
||||
"comments.id AS id",
|
||||
"comments.public_reactions_count",
|
||||
"comments.score",
|
||||
"comments.user_id",
|
||||
].freeze
|
||||
private_constant :ATTRIBUTES
|
||||
|
||||
DEFAULT_PER_PAGE = 60
|
||||
private_constant :DEFAULT_PER_PAGE
|
||||
|
||||
DEFAULT_SORT_BY = "comments.score".freeze
|
||||
private_constant :DEFAULT_SORT_BY
|
||||
|
||||
DEFAULT_SORT_DIRECTION = :desc
|
||||
private_constant :DEFAULT_SORT_DIRECTION
|
||||
|
||||
MAX_PER_PAGE = 120 # to avoid querying too many items, we set a maximum amount for a page
|
||||
private_constant :MAX_PER_PAGE
|
||||
|
||||
def self.search_documents(
|
||||
page: 0,
|
||||
per_page: DEFAULT_PER_PAGE,
|
||||
sort_by: DEFAULT_SORT_BY,
|
||||
sort_direction: DEFAULT_SORT_DIRECTION,
|
||||
term: nil
|
||||
)
|
||||
sort_by ||= DEFAULT_SORT_BY
|
||||
# The UI and serializer rename created_at (the actual DB column name) to
|
||||
# published_at
|
||||
sort_by = "comments.created_at" if sort_by == "published_at"
|
||||
|
||||
sort_direction ||= DEFAULT_SORT_DIRECTION
|
||||
|
||||
# NOTE: [@rhymes/atsmith813] we should eventually update the frontend
|
||||
# to start from page 1
|
||||
page = page.to_i + 1
|
||||
per_page = [(per_page || DEFAULT_PER_PAGE).to_i, MAX_PER_PAGE].min
|
||||
|
||||
relation = ::Comment
|
||||
.includes(:user)
|
||||
.where(
|
||||
deleted: false,
|
||||
hidden_by_commentable_user: false,
|
||||
commentable_type: "Article",
|
||||
)
|
||||
.joins("join articles on articles.id = comments.commentable_id")
|
||||
.where("articles.published": true)
|
||||
|
||||
relation = relation.search_comments(term).with_pg_search_highlight if term.present?
|
||||
|
||||
relation = relation.select(*ATTRIBUTES).reorder("#{sort_by}": sort_direction)
|
||||
|
||||
results = relation.page(page).per(per_page)
|
||||
|
||||
serialize(results)
|
||||
end
|
||||
|
||||
def self.serialize(results)
|
||||
Search::PostgresCommentSerializer
|
||||
.new(results, is_collection: true)
|
||||
.serializable_hash[:data]
|
||||
.pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
module Search
|
||||
module Postgres
|
||||
class Listing
|
||||
ATTRIBUTES = %i[
|
||||
id
|
||||
body_markdown
|
||||
bumped_at
|
||||
cached_tag_list
|
||||
classified_listing_category_id
|
||||
contact_via_connect
|
||||
expires_at
|
||||
organization_id
|
||||
originally_published_at
|
||||
location
|
||||
processed_html
|
||||
published
|
||||
slug
|
||||
title
|
||||
user_id
|
||||
].freeze
|
||||
private_constant :ATTRIBUTES
|
||||
|
||||
DEFAULT_PER_PAGE = 75
|
||||
private_constant :DEFAULT_PER_PAGE
|
||||
|
||||
MAX_PER_PAGE = 150 # to avoid querying too many items, we set a maximum amount for a page
|
||||
private_constant :MAX_PER_PAGE
|
||||
|
||||
def self.search_documents(category: nil, page: 0, per_page: DEFAULT_PER_PAGE, term: nil)
|
||||
# NOTE: [@rhymes/atsmith813] we should eventually update the frontend
|
||||
# to start from page 1
|
||||
page = page.to_i + 1
|
||||
per_page = [(per_page || DEFAULT_PER_PAGE).to_i, MAX_PER_PAGE].min
|
||||
|
||||
relation = ::Listing
|
||||
.includes(:user, :organization, :listing_category)
|
||||
.where(published: true)
|
||||
|
||||
relation = relation.where("classified_listing_categories.slug": category) if category.present?
|
||||
|
||||
relation = relation.search_listings(term) if term.present?
|
||||
|
||||
relation = relation.select(*ATTRIBUTES).order(bumped_at: :desc)
|
||||
|
||||
results = relation.page(page).per(per_page)
|
||||
|
||||
serialize(results)
|
||||
end
|
||||
|
||||
def self.serialize(results)
|
||||
Search::ListingResultSerializer
|
||||
.new(results, is_collection: true)
|
||||
.serializable_hash[:data]
|
||||
.pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
module Search
|
||||
module Postgres
|
||||
class PodcastEpisode
|
||||
ATTRIBUTES = %w[
|
||||
body
|
||||
comments_count
|
||||
id
|
||||
podcast_id
|
||||
processed_html
|
||||
published_at
|
||||
quote
|
||||
reactions_count
|
||||
slug
|
||||
subtitle
|
||||
summary
|
||||
title
|
||||
website_url
|
||||
].freeze
|
||||
private_constant :ATTRIBUTES
|
||||
|
||||
DEFAULT_PER_PAGE = 60
|
||||
private_constant :DEFAULT_PER_PAGE
|
||||
|
||||
MAX_PER_PAGE = 120 # to avoid querying too many items, we set a maximum amount for a page
|
||||
private_constant :MAX_PER_PAGE
|
||||
|
||||
def self.search_documents(page: 0, per_page: DEFAULT_PER_PAGE, sort_by: nil, sort_direction: nil, term: nil)
|
||||
# NOTE: [@rhymes/atsmith813] we should eventually update the frontend
|
||||
# to start from page 1
|
||||
page = page.to_i + 1
|
||||
per_page = [(per_page || DEFAULT_PER_PAGE).to_i, MAX_PER_PAGE].min
|
||||
|
||||
relation = ::PodcastEpisode
|
||||
.reachable
|
||||
.where(podcast_id: Podcast.published)
|
||||
.includes(:podcast)
|
||||
.references(:podcasts)
|
||||
relation = relation.search_podcast_episodes(term) if term.present?
|
||||
relation = relation.select(*ATTRIBUTES)
|
||||
relation = sort(relation, sort_by, sort_direction)
|
||||
results = relation.page(page).per(per_page)
|
||||
|
||||
serialize(results)
|
||||
end
|
||||
|
||||
def self.sort(relation, sort_by, sort_direction)
|
||||
return relation.reorder(sort_by => sort_direction) if sort_by && sort_direction
|
||||
|
||||
relation.reorder(nil)
|
||||
end
|
||||
private_class_method :sort
|
||||
|
||||
def self.serialize(results)
|
||||
Search::PostgresPodcastEpisodeSerializer
|
||||
.new(results, is_collection: true)
|
||||
.serializable_hash[:data]
|
||||
.pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
module Search
|
||||
module Postgres
|
||||
class ReadingList
|
||||
ATTRIBUTES = [
|
||||
"articles.cached_tag_list",
|
||||
"articles.crossposted_at",
|
||||
"articles.path",
|
||||
"articles.published_at",
|
||||
"articles.reading_time",
|
||||
"articles.title",
|
||||
"articles.user_id",
|
||||
"reactions.id AS reaction_id",
|
||||
"reactions.user_id AS reaction_user_id",
|
||||
].freeze
|
||||
REACTION_ATTRIBUTES = %i[id reactable_id user_id].freeze
|
||||
USER_ATTRIBUTES = %i[id name profile_image username].freeze
|
||||
|
||||
DEFAULT_STATUSES = %w[confirmed valid].freeze
|
||||
|
||||
DEFAULT_PER_PAGE = 60
|
||||
MAX_PER_PAGE = 100 # to avoid querying too many items, we set a maximum amount for a page
|
||||
|
||||
def self.search_documents(user, term: nil, statuses: [], tags: [], page: 0, per_page: DEFAULT_PER_PAGE)
|
||||
return {} unless user
|
||||
|
||||
statuses = statuses.presence || DEFAULT_STATUSES
|
||||
tags = tags.presence || []
|
||||
|
||||
# TODO: [@rhymes] we should eventually update the frontend
|
||||
# to start from page 1
|
||||
page = page.to_i + 1
|
||||
per_page = [(per_page || DEFAULT_PER_PAGE).to_i, MAX_PER_PAGE].min
|
||||
|
||||
result = find_articles(
|
||||
user: user,
|
||||
term: term,
|
||||
statuses: statuses,
|
||||
tags: tags,
|
||||
page: page,
|
||||
per_page: per_page,
|
||||
)
|
||||
|
||||
# NOTE: [@rhymes] an earlier version used `Article.includes(:user)`
|
||||
# to preload users, unfortunately it's not possible in Rails to specify
|
||||
# which fields of the included relation's table to select ahead of time.
|
||||
# The `users` table is massive (115 columns on March 2021) and thus we
|
||||
# shouldn't load it all in memory just to select a few fields.
|
||||
# For these reasons I decided to avoid preloading altogether and issue
|
||||
# an additional SQL query to load User objects
|
||||
# (see https://github.com/forem/forem/pull/4744#discussion_r345698674
|
||||
# and https://github.com/rails/rails/issues/15185#issuecomment-351868335
|
||||
# for additional context)
|
||||
user_ids = result[:items].pluck(:user_id)
|
||||
users = find_users(user_ids)
|
||||
|
||||
{
|
||||
items: serialize(result[:items], users),
|
||||
total: result[:total]
|
||||
}
|
||||
end
|
||||
|
||||
def self.find_articles(user:, term:, statuses:, tags:, page:, per_page:)
|
||||
# [@jgaskins, @rhymes] as `reactions` is potentially a big table, adding pagination
|
||||
# to an INNER JOIN (eg. `joins(:reactions)`) exponentially decreases the performance,
|
||||
# incrementing query time as the database has to scan all the rows just to discard
|
||||
# them right after if they lie outside the bounds of the `OFFSET`.
|
||||
# Even though it should have had a similar performance, we realized that a subquery
|
||||
# enabled PostgreSQL query planner to drastically decrease the planned time (ca. 145x)
|
||||
reaction_query_sql = user.reactions.readinglist
|
||||
.where(status: statuses, reactable_type: "Article")
|
||||
.order(created_at: :desc)
|
||||
.select(*REACTION_ATTRIBUTES)
|
||||
.to_sql
|
||||
|
||||
relation = ::Article.joins(
|
||||
"INNER JOIN (#{reaction_query_sql}) reactions ON reactions.reactable_id = articles.id",
|
||||
)
|
||||
|
||||
relation = relation.search_articles(term) if term.present?
|
||||
|
||||
relation = relation.cached_tagged_with(tags) if tags.any?
|
||||
|
||||
# here we issue a COUNT(*) after all the conditions are applied,
|
||||
# because we need to fetch the total number of articles, pre pagination
|
||||
total = relation.count
|
||||
|
||||
relation = relation.select(*ATTRIBUTES)
|
||||
relation = relation.page(page).per(per_page)
|
||||
|
||||
{
|
||||
items: relation,
|
||||
total: total
|
||||
}
|
||||
end
|
||||
private_class_method :find_articles
|
||||
|
||||
def self.find_users(user_ids)
|
||||
::User
|
||||
.where(id: user_ids)
|
||||
.select(*USER_ATTRIBUTES)
|
||||
.index_by(&:id)
|
||||
end
|
||||
private_class_method :find_users
|
||||
|
||||
def self.serialize(articles, users)
|
||||
Search::ReadingListArticleSerializer
|
||||
.new(articles, params: { users: users }, is_collection: true)
|
||||
.serializable_hash[:data]
|
||||
.pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
module Search
|
||||
module Postgres
|
||||
class Tag
|
||||
ATTRIBUTES = %i[id name hotness_score rules_html supported short_summary].freeze
|
||||
|
||||
def self.search_documents(term)
|
||||
results = ::Tag.search_by_name(term).where(supported: true).reorder(hotness_score: :desc).select(*ATTRIBUTES)
|
||||
serialize(results)
|
||||
end
|
||||
|
||||
def self.serialize(results)
|
||||
Search::TagSerializer.new(results, is_collection: true).serializable_hash[:data].pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
module Search
|
||||
module Postgres
|
||||
class User
|
||||
ATTRIBUTES = %i[
|
||||
id
|
||||
name
|
||||
profile_image
|
||||
username
|
||||
].freeze
|
||||
|
||||
DEFAULT_PER_PAGE = 60
|
||||
MAX_PER_PAGE = 100
|
||||
|
||||
# User.search_score used to take employer related fields into account, but they have since been moved to profile
|
||||
# and removed from fields that are searched against.
|
||||
HOTNESS_SCORE_ORDER = Arel.sql(%{
|
||||
(((articles_count + comments_count + reactions_count + badge_achievements_count) * 10) * reputation_modifier)
|
||||
DESC
|
||||
}.squish).freeze
|
||||
|
||||
def self.search_documents(term: nil, sort_by: :nil, sort_direction: :desc, page: 0, per_page: DEFAULT_PER_PAGE)
|
||||
# NOTE: we should eventually update the frontend
|
||||
# to start from page 1
|
||||
page = page.to_i + 1
|
||||
per_page = [(per_page || DEFAULT_PER_PAGE).to_i, MAX_PER_PAGE].min
|
||||
|
||||
relation = ::User
|
||||
|
||||
relation = filter_suspended_users(relation)
|
||||
|
||||
relation = relation.search_by_name_and_username(term) if term.present?
|
||||
|
||||
relation = relation.select(*ATTRIBUTES)
|
||||
|
||||
relation = sort(relation, sort_by, sort_direction)
|
||||
|
||||
relation = relation.page(page).per(per_page)
|
||||
|
||||
serialize(relation)
|
||||
end
|
||||
|
||||
# `User.without_role` generates a subquery + 2 inner joins.
|
||||
# Given that the number of suspended users will, hopefully, be a tiny percentage
|
||||
# of regular users, and the `rolify`'s gem approach is not particularly efficient,
|
||||
# we simplified the subquery and added a precondition to skip that query entirely,
|
||||
# when a community has no suspended users.
|
||||
# NOTE: An alternative approach that could be explored is to
|
||||
# preload the user ids of all suspended users and use those with `.where.not(id: ...)`
|
||||
def self.filter_suspended_users(relation)
|
||||
suspended = UserRole.joins(:role).where(roles: { name: :suspended })
|
||||
|
||||
return relation unless suspended.exists?
|
||||
|
||||
relation.where.not(id: suspended.select(:user_id))
|
||||
end
|
||||
private_class_method :filter_suspended_users
|
||||
|
||||
def self.sort(relation, sort_by, sort_direction)
|
||||
return relation.reorder(sort_by => sort_direction) if sort_by&.to_sym == :created_at
|
||||
|
||||
relation.reorder(HOTNESS_SCORE_ORDER)
|
||||
end
|
||||
private_class_method :sort
|
||||
|
||||
def self.serialize(users)
|
||||
Search::PostgresUserSerializer
|
||||
.new(users, is_collection: true)
|
||||
.serializable_hash[:data]
|
||||
.pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
module Search
|
||||
module Postgres
|
||||
class Username
|
||||
MAX_RESULTS = 6
|
||||
|
||||
ATTRIBUTES = %i[
|
||||
id
|
||||
name
|
||||
profile_image
|
||||
username
|
||||
].freeze
|
||||
|
||||
def self.search_documents(term)
|
||||
results = ::User.search_by_name_and_username(term).limit(MAX_RESULTS).select(*ATTRIBUTES)
|
||||
|
||||
serialize(results)
|
||||
end
|
||||
|
||||
def self.serialize(results)
|
||||
Search::UsernameSerializer
|
||||
.new(results, is_collection: true)
|
||||
.serializable_hash[:data]
|
||||
.pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
end
|
||||
112
app/services/search/reading_list.rb
Normal file
112
app/services/search/reading_list.rb
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
module Search
|
||||
class ReadingList
|
||||
ATTRIBUTES = [
|
||||
"articles.cached_tag_list",
|
||||
"articles.crossposted_at",
|
||||
"articles.path",
|
||||
"articles.published_at",
|
||||
"articles.reading_time",
|
||||
"articles.title",
|
||||
"articles.user_id",
|
||||
"reactions.id AS reaction_id",
|
||||
"reactions.user_id AS reaction_user_id",
|
||||
].freeze
|
||||
REACTION_ATTRIBUTES = %i[id reactable_id user_id].freeze
|
||||
USER_ATTRIBUTES = %i[id name profile_image username].freeze
|
||||
|
||||
DEFAULT_STATUSES = %w[confirmed valid].freeze
|
||||
|
||||
DEFAULT_PER_PAGE = 60
|
||||
MAX_PER_PAGE = 100 # to avoid querying too many items, we set a maximum amount for a page
|
||||
|
||||
def self.search_documents(user, term: nil, statuses: [], tags: [], page: 0, per_page: DEFAULT_PER_PAGE)
|
||||
return {} unless user
|
||||
|
||||
statuses = statuses.presence || DEFAULT_STATUSES
|
||||
tags = tags.presence || []
|
||||
|
||||
# TODO: [@rhymes] we should eventually update the frontend
|
||||
# to start from page 1
|
||||
page = page.to_i + 1
|
||||
per_page = [(per_page || DEFAULT_PER_PAGE).to_i, MAX_PER_PAGE].min
|
||||
|
||||
result = find_articles(
|
||||
user: user,
|
||||
term: term,
|
||||
statuses: statuses,
|
||||
tags: tags,
|
||||
page: page,
|
||||
per_page: per_page,
|
||||
)
|
||||
|
||||
# NOTE: [@rhymes] an earlier version used `Article.includes(:user)`
|
||||
# to preload users, unfortunately it's not possible in Rails to specify
|
||||
# which fields of the included relation's table to select ahead of time.
|
||||
# The `users` table is massive (115 columns on March 2021) and thus we
|
||||
# shouldn't load it all in memory just to select a few fields.
|
||||
# For these reasons I decided to avoid preloading altogether and issue
|
||||
# an additional SQL query to load User objects
|
||||
# (see https://github.com/forem/forem/pull/4744#discussion_r345698674
|
||||
# and https://github.com/rails/rails/issues/15185#issuecomment-351868335
|
||||
# for additional context)
|
||||
user_ids = result[:items].pluck(:user_id)
|
||||
users = find_users(user_ids)
|
||||
|
||||
{
|
||||
items: serialize(result[:items], users),
|
||||
total: result[:total]
|
||||
}
|
||||
end
|
||||
|
||||
def self.find_articles(user:, term:, statuses:, tags:, page:, per_page:)
|
||||
# [@jgaskins, @rhymes] as `reactions` is potentially a big table, adding pagination
|
||||
# to an INNER JOIN (eg. `joins(:reactions)`) exponentially decreases the performance,
|
||||
# incrementing query time as the database has to scan all the rows just to discard
|
||||
# them right after if they lie outside the bounds of the `OFFSET`.
|
||||
# Even though it should have had a similar performance, we realized that a subquery
|
||||
# enabled PostgreSQL query planner to drastically decrease the planned time (ca. 145x)
|
||||
reaction_query_sql = user.reactions.readinglist
|
||||
.where(status: statuses, reactable_type: "Article")
|
||||
.order(created_at: :desc)
|
||||
.select(*REACTION_ATTRIBUTES)
|
||||
.to_sql
|
||||
|
||||
relation = ::Article.joins(
|
||||
"INNER JOIN (#{reaction_query_sql}) reactions ON reactions.reactable_id = articles.id",
|
||||
)
|
||||
|
||||
relation = relation.search_articles(term) if term.present?
|
||||
|
||||
relation = relation.cached_tagged_with(tags) if tags.any?
|
||||
|
||||
# here we issue a COUNT(*) after all the conditions are applied,
|
||||
# because we need to fetch the total number of articles, pre pagination
|
||||
total = relation.count
|
||||
|
||||
relation = relation.select(*ATTRIBUTES)
|
||||
relation = relation.page(page).per(per_page)
|
||||
|
||||
{
|
||||
items: relation,
|
||||
total: total
|
||||
}
|
||||
end
|
||||
private_class_method :find_articles
|
||||
|
||||
def self.find_users(user_ids)
|
||||
::User
|
||||
.where(id: user_ids)
|
||||
.select(*USER_ATTRIBUTES)
|
||||
.index_by(&:id)
|
||||
end
|
||||
private_class_method :find_users
|
||||
|
||||
def self.serialize(articles, users)
|
||||
Search::ReadingListArticleSerializer
|
||||
.new(articles, params: { users: users }, is_collection: true)
|
||||
.serializable_hash[:data]
|
||||
.pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
15
app/services/search/tag.rb
Normal file
15
app/services/search/tag.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
module Search
|
||||
class Tag
|
||||
ATTRIBUTES = %i[id name hotness_score rules_html supported short_summary].freeze
|
||||
|
||||
def self.search_documents(term)
|
||||
results = ::Tag.search_by_name(term).where(supported: true).reorder(hotness_score: :desc).select(*ATTRIBUTES)
|
||||
serialize(results)
|
||||
end
|
||||
|
||||
def self.serialize(results)
|
||||
Search::TagSerializer.new(results, is_collection: true).serializable_hash[:data].pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
72
app/services/search/user.rb
Normal file
72
app/services/search/user.rb
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
module Search
|
||||
class User
|
||||
ATTRIBUTES = %i[
|
||||
id
|
||||
name
|
||||
profile_image
|
||||
username
|
||||
].freeze
|
||||
|
||||
DEFAULT_PER_PAGE = 60
|
||||
MAX_PER_PAGE = 100
|
||||
|
||||
# User.search_score used to take employer related fields into account, but they have since been moved to profile
|
||||
# and removed from fields that are searched against.
|
||||
HOTNESS_SCORE_ORDER = Arel.sql(%{
|
||||
(((articles_count + comments_count + reactions_count + badge_achievements_count) * 10) * reputation_modifier)
|
||||
DESC
|
||||
}.squish).freeze
|
||||
|
||||
def self.search_documents(term: nil, sort_by: :nil, sort_direction: :desc, page: 0, per_page: DEFAULT_PER_PAGE)
|
||||
# NOTE: we should eventually update the frontend
|
||||
# to start from page 1
|
||||
page = page.to_i + 1
|
||||
per_page = [(per_page || DEFAULT_PER_PAGE).to_i, MAX_PER_PAGE].min
|
||||
|
||||
relation = ::User
|
||||
|
||||
relation = filter_suspended_users(relation)
|
||||
|
||||
relation = relation.search_by_name_and_username(term) if term.present?
|
||||
|
||||
relation = relation.select(*ATTRIBUTES)
|
||||
|
||||
relation = sort(relation, sort_by, sort_direction)
|
||||
|
||||
relation = relation.page(page).per(per_page)
|
||||
|
||||
serialize(relation)
|
||||
end
|
||||
|
||||
# `User.without_role` generates a subquery + 2 inner joins.
|
||||
# Given that the number of suspended users will, hopefully, be a tiny percentage
|
||||
# of regular users, and the `rolify`'s gem approach is not particularly efficient,
|
||||
# we simplified the subquery and added a precondition to skip that query entirely,
|
||||
# when a community has no suspended users.
|
||||
# NOTE: An alternative approach that could be explored is to
|
||||
# preload the user ids of all suspended users and use those with `.where.not(id: ...)`
|
||||
def self.filter_suspended_users(relation)
|
||||
suspended = UserRole.joins(:role).where(roles: { name: :suspended })
|
||||
|
||||
return relation unless suspended.exists?
|
||||
|
||||
relation.where.not(id: suspended.select(:user_id))
|
||||
end
|
||||
private_class_method :filter_suspended_users
|
||||
|
||||
def self.sort(relation, sort_by, sort_direction)
|
||||
return relation.reorder(sort_by => sort_direction) if sort_by&.to_sym == :created_at
|
||||
|
||||
relation.reorder(HOTNESS_SCORE_ORDER)
|
||||
end
|
||||
private_class_method :sort
|
||||
|
||||
def self.serialize(users)
|
||||
Search::SimpleUserSerializer
|
||||
.new(users, is_collection: true)
|
||||
.serializable_hash[:data]
|
||||
.pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
26
app/services/search/username.rb
Normal file
26
app/services/search/username.rb
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
module Search
|
||||
class Username
|
||||
MAX_RESULTS = 6
|
||||
|
||||
ATTRIBUTES = %i[
|
||||
id
|
||||
name
|
||||
profile_image
|
||||
username
|
||||
].freeze
|
||||
|
||||
def self.search_documents(term)
|
||||
results = ::User.search_by_name_and_username(term).limit(MAX_RESULTS).select(*ATTRIBUTES)
|
||||
|
||||
serialize(results)
|
||||
end
|
||||
|
||||
def self.serialize(results)
|
||||
Search::NestedUserSerializer
|
||||
.new(results, is_collection: true)
|
||||
.serializable_hash[:data]
|
||||
.pluck(:attributes)
|
||||
end
|
||||
private_class_method :serialize
|
||||
end
|
||||
end
|
||||
|
|
@ -11,7 +11,7 @@ describe Rack::Attack, type: :request, throttle: true do
|
|||
describe "search_throttle" do
|
||||
it "throttles /search endpoints based on IP" do
|
||||
Timecop.freeze do
|
||||
allow(Search::Postgres::Username).to receive(:search_documents).and_return({})
|
||||
allow(Search::Username).to receive(:search_documents).and_return({})
|
||||
|
||||
valid_responses = Array.new(5).map do
|
||||
get "/search/usernames", headers: { "HTTP_FASTLY_CLIENT_IP" => "5.6.7.8" }
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ RSpec.describe "Search", type: :request, proper_status: true do
|
|||
|
||||
it "returns json" do
|
||||
sign_in authorized_user
|
||||
allow(Search::Postgres::ChatChannelMembership).to receive(:search_documents).and_return(
|
||||
allow(Search::ChatChannelMembership).to receive(:search_documents).and_return(
|
||||
mock_documents,
|
||||
)
|
||||
get "/search/chat_channels"
|
||||
|
|
@ -143,24 +143,24 @@ RSpec.describe "Search", type: :request, proper_status: true do
|
|||
end
|
||||
|
||||
context "when searching for articles" do
|
||||
it "calls Search::Postgres::Article without a class_name" do
|
||||
allow(Search::Postgres::Article).to receive(:search_documents)
|
||||
it "calls Search::Article without a class_name" do
|
||||
allow(Search::Article).to receive(:search_documents)
|
||||
|
||||
get search_feed_content_path
|
||||
|
||||
expect(Search::Postgres::Article).to have_received(:search_documents)
|
||||
expect(Search::Article).to have_received(:search_documents)
|
||||
end
|
||||
|
||||
it "calls Search::Postgres::Article with class_name=Article" do
|
||||
allow(Search::Postgres::Article).to receive(:search_documents)
|
||||
it "calls Search::Article with class_name=Article" do
|
||||
allow(Search::Article).to receive(:search_documents)
|
||||
|
||||
get search_feed_content_path(class_name: "Article")
|
||||
|
||||
expect(Search::Postgres::Article).to have_received(:search_documents)
|
||||
expect(Search::Article).to have_received(:search_documents)
|
||||
end
|
||||
|
||||
it "supports the search params", :aggregate_failures do
|
||||
allow(Search::Postgres::Article).to receive(:search_documents).and_call_original
|
||||
allow(Search::Article).to receive(:search_documents).and_call_original
|
||||
|
||||
article = create(:article)
|
||||
|
||||
|
|
@ -171,7 +171,7 @@ RSpec.describe "Search", type: :request, proper_status: true do
|
|||
|
||||
expect(response.parsed_body["result"].first["id"]).to eq(article.id)
|
||||
|
||||
expect(Search::Postgres::Article).to have_received(:search_documents)
|
||||
expect(Search::Article).to have_received(:search_documents)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Search::ChatChannelMembershipSerializer do
|
||||
let(:ccm) { create(:chat_channel_membership) }
|
||||
|
||||
it "serializes a ChatChannelMembership" do
|
||||
data_hash = described_class.new(ccm).serializable_hash.dig(:data, :attributes)
|
||||
expect(data_hash.keys).to include(
|
||||
:id, :status, :viewable_by, :chat_channel_id, :last_opened_at, :channel_text,
|
||||
:channel_last_message_at, :channel_status, :channel_type, :channel_username, :channel_name,
|
||||
:channel_image, :channel_modified_slug, :channel_discoverable, :channel_messages_count
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,14 +1,62 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Search::CommentSerializer do
|
||||
let(:user) { create(:user) }
|
||||
let(:article) { create(:article, user: user) }
|
||||
let(:comment) { create(:comment, user: user, commentable: article) }
|
||||
let(:comment) do
|
||||
# This block is a workaround to allow the serializer to work. The search uses a custom query and
|
||||
# select to build the associations to pass to the serializer. We need commentable_published and
|
||||
# commentable_title. Since #select is an ActiveRecord method, we can't call it on a FactoryBot
|
||||
# object. So, instead, we query the same as we do in Search::Comment to get what we need.
|
||||
c = create(:comment)
|
||||
|
||||
it "serializes an comment" do
|
||||
Comment
|
||||
.includes(:user)
|
||||
.where(
|
||||
deleted: false,
|
||||
hidden_by_commentable_user: false,
|
||||
commentable_type: "Article",
|
||||
id: c.id,
|
||||
)
|
||||
.joins("join articles on articles.id = comments.commentable_id")
|
||||
.where("articles.published": true)
|
||||
.select(
|
||||
"COALESCE(articles.published, false) AS commentable_published",
|
||||
"COALESCE(articles.title, '') AS commentable_title",
|
||||
"comments.body_markdown",
|
||||
"comments.commentable_id",
|
||||
"comments.commentable_type",
|
||||
"comments.created_at",
|
||||
"comments.id AS id",
|
||||
"comments.public_reactions_count",
|
||||
"comments.score",
|
||||
"comments.user_id",
|
||||
)
|
||||
.first
|
||||
end
|
||||
|
||||
it "serializes a Comment" do
|
||||
data_hash = described_class.new(comment).serializable_hash.dig(:data, :attributes)
|
||||
user_data = Search::NestedUserSerializer.new(user).serializable_hash.dig(:data, :attributes)
|
||||
expect(data_hash[:user]).to eq(user_data)
|
||||
expect(data_hash.keys).to include(:id, :body_text, :hotness_score, :title)
|
||||
expect(data_hash.keys).to include(
|
||||
:id, :path, :public_reactions_count, :body_text, :class_name, :highlight, :hotness_score,
|
||||
:published, :published_at, :readable_publish_date_string, :title, :user
|
||||
)
|
||||
end
|
||||
|
||||
it "serializes the comment path" do
|
||||
path = described_class.new(comment).serializable_hash.dig(:data, :attributes, :path)
|
||||
expect(path).to eq(comment.path)
|
||||
end
|
||||
|
||||
it "serializes highlight" do
|
||||
highlight_hash = described_class.new(comment).serializable_hash.dig(:data, :attributes, :highlight)
|
||||
expect(highlight_hash.keys).to include(:body_text)
|
||||
end
|
||||
|
||||
it "serializes the user" do
|
||||
user_hash = described_class.new(comment).serializable_hash.dig(:data, :attributes, :user)
|
||||
expect(user_hash.keys).to include(:name, :profile_image_90, :username)
|
||||
user = comment.user
|
||||
expect(user_hash[:name]).to eq(user.name)
|
||||
expect(user_hash[:username]).to eq(user.username)
|
||||
expect(user_hash[:profile_image_90]).to eq(user.profile_image_90)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
13
spec/serializers/search/listing_author_serializer_spec.rb
Normal file
13
spec/serializers/search/listing_author_serializer_spec.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Search::ListingAuthorSerializer do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
it "serializes a ListingAuthor" do
|
||||
data_hash = described_class.new(user).serializable_hash.dig(:data, :attributes)
|
||||
expect(data_hash.keys).to include(:username, :name, :profile_image_90)
|
||||
expect(data_hash[:username]).to eq(user.username)
|
||||
expect(data_hash[:name]).to eq(user.name)
|
||||
expect(data_hash[:profile_image_90]).to eq(user.profile_image_90)
|
||||
end
|
||||
end
|
||||
28
spec/serializers/search/listing_serializer_spec.rb
Normal file
28
spec/serializers/search/listing_serializer_spec.rb
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Search::ListingSerializer do
|
||||
let(:listing) { create(:listing) }
|
||||
|
||||
it "serializes a Listing" do
|
||||
data_hash = described_class.new(listing).serializable_hash.dig(:data, :attributes)
|
||||
expect(data_hash.keys).to include(
|
||||
:id, :body_markdown, :bumped_at, :category, :contact_via_connect, :expires_at,
|
||||
:originally_published_at, :location, :processed_html, :published, :slug, :title, :user_id,
|
||||
:tags, :author
|
||||
)
|
||||
end
|
||||
|
||||
it "serializes tags" do
|
||||
tags = described_class.new(listing).serializable_hash.dig(:data, :attributes, :tags)
|
||||
expect(tags).to eq(listing.cached_tag_list.to_s.split(", "))
|
||||
end
|
||||
|
||||
it "serializes a ListingAuthor" do
|
||||
author_hash = described_class.new(listing).serializable_hash.dig(:data, :attributes, :author)
|
||||
expect(author_hash.keys).to include(:username, :name, :profile_image_90)
|
||||
user = listing.user
|
||||
expect(author_hash[:username]).to eq(user.username)
|
||||
expect(author_hash[:name]).to eq(user.name)
|
||||
expect(author_hash[:profile_image_90]).to eq(user.profile_image_90)
|
||||
end
|
||||
end
|
||||
14
spec/serializers/search/nested_user_serializer_spec.rb
Normal file
14
spec/serializers/search/nested_user_serializer_spec.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Search::NestedUserSerializer do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
it "serializes a User" do
|
||||
data_hash = described_class.new(user).serializable_hash.dig(:data, :attributes)
|
||||
expect(data_hash.keys).to include(:id, :username, :name, :profile_image_90)
|
||||
expect(data_hash[:id]).to eq(user.id)
|
||||
expect(data_hash[:username]).to eq(user.username)
|
||||
expect(data_hash[:name]).to eq(user.name)
|
||||
expect(data_hash[:profile_image_90]).to eq(user.profile_image_90)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,14 +1,22 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Search::PodcastEpisodeSerializer do
|
||||
let(:user) { create(:user) }
|
||||
let(:podcast) { create(:podcast, creator_id: user.id) }
|
||||
let(:podcast_ep) { create(:podcast_episode, podcast: podcast) }
|
||||
let(:pce) { create(:podcast_episode) }
|
||||
|
||||
it "serializes a podcast episode" do
|
||||
data_hash = described_class.new(podcast_ep).serializable_hash.dig(:data, :attributes)
|
||||
user_data = Search::NestedUserSerializer.new(user).serializable_hash.dig(:data, :attributes)
|
||||
expect(data_hash[:user]).to eq(user_data)
|
||||
expect(data_hash.keys).to include(:id, :body_text)
|
||||
it "serializes a PodcastEpisode" do
|
||||
data_hash = described_class.new(pce).serializable_hash.dig(:data, :attributes)
|
||||
expect(data_hash.keys).to include(
|
||||
:id, :body_text, :comments_count, :path, :published_at, :quote, :reactions_count, :subtitle,
|
||||
:summary, :title, :website_url, :class_name, :highlight, :hotness_score, :main_image,
|
||||
:podcast, :public_reactions_count, :published, :search_score, :slug, :user
|
||||
)
|
||||
end
|
||||
|
||||
it "serializes podcast" do
|
||||
podcast = described_class.new(pce).serializable_hash.dig(:data, :attributes, :podcast)
|
||||
expect(podcast.keys).to include(:slug, :image_url, :title)
|
||||
expect(podcast[:slug]).to eq(pce.podcast_slug)
|
||||
expect(podcast[:image_url]).to eq(Images::Profile.call(pce.podcast.profile_image_url, length: 90))
|
||||
expect(podcast[:title]).to eq(pce.title)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Search::ReadingListArticleSerializer do
|
||||
let(:reaction) { create(:reaction) }
|
||||
|
||||
# article and users are workarounds to allow the serializer to work. The search uses a custom
|
||||
# query and select to build the associations to pass to the serializer. We need reaction_id,
|
||||
# reaction_user_id, and selected attributes. Since #select is an ActiveRecord method, we can't
|
||||
# call it on a FactoryBot object. So, instead, we query the same as we do in Search::ReadingList
|
||||
# to get what we need.
|
||||
let(:article) do
|
||||
Article
|
||||
.includes(:reactions)
|
||||
.where(id: reaction.reactable.id)
|
||||
.select(
|
||||
"articles.cached_tag_list",
|
||||
"articles.crossposted_at",
|
||||
"articles.path",
|
||||
"articles.published_at",
|
||||
"articles.reading_time",
|
||||
"articles.title",
|
||||
"articles.user_id",
|
||||
"reactions.id AS reaction_id",
|
||||
"reactions.user_id AS reaction_user_id",
|
||||
)
|
||||
.references(:reactions)
|
||||
.first
|
||||
end
|
||||
|
||||
let(:users) do
|
||||
User
|
||||
.where(id: article.user_id)
|
||||
.select(:id, :name, :profile_image, :username)
|
||||
.index_by(&:id)
|
||||
end
|
||||
|
||||
it "serializes an Article" do
|
||||
data_hash = described_class.new(article, params: { users: users }).serializable_hash.dig(:data, :attributes)
|
||||
expect(data_hash.keys).to include(:id, :user_id, :reactable)
|
||||
end
|
||||
|
||||
it "serializes the reactable" do
|
||||
reactable = described_class.new(article, params: { users: users }).serializable_hash.dig(:data, :attributes,
|
||||
:reactable)
|
||||
expect(reactable.keys).to include(:path, :readable_publish_date_string, :reading_time, :tag_list, :tags, :title,
|
||||
:user)
|
||||
end
|
||||
end
|
||||
25
spec/serializers/search/simple_user_serializer_spec.rb
Normal file
25
spec/serializers/search/simple_user_serializer_spec.rb
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Search::SimpleUserSerializer do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
it "serializes a User" do
|
||||
data_hash = described_class.new(user).serializable_hash.dig(:data, :attributes)
|
||||
expect(data_hash.keys).to include(:class_name, :id, :title, :user)
|
||||
end
|
||||
|
||||
it "has the correct class_name" do
|
||||
class_name = described_class.new(user).serializable_hash.dig(:data, :attributes, :class_name)
|
||||
expect(class_name).to eq("User")
|
||||
end
|
||||
|
||||
it "serializers the user key" do
|
||||
user_hash = described_class.new(user).serializable_hash.dig(:data, :attributes, :user)
|
||||
expect(user_hash.keys).to include(:username, :name, :profile_image_90)
|
||||
expect(user_hash[:username]).to eq(user.username)
|
||||
expect(user_hash[:profile_image_90]).to eq(user.profile_image_90)
|
||||
|
||||
# currently the frontend expects this to be the username, despite the attribute name
|
||||
expect(user_hash[:name]).to eq(user.username)
|
||||
end
|
||||
end
|
||||
10
spec/serializers/search/tag_serializer_spec.rb
Normal file
10
spec/serializers/search/tag_serializer_spec.rb
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Search::TagSerializer do
|
||||
let(:tag) { create(:tag) }
|
||||
|
||||
it "serializes a Tag" do
|
||||
data_hash = described_class.new(tag).serializable_hash.dig(:data, :attributes)
|
||||
expect(data_hash.keys).to include(:id, :name, :hotness_score, :supported, :short_summary, :rules_html)
|
||||
end
|
||||
end
|
||||
|
|
@ -2,7 +2,7 @@ require "rails_helper"
|
|||
|
||||
# rubocop:disable Rails/PluckId
|
||||
# This spec uses `pluck` on an array of hashes, but Rubocop can't tell the difference.
|
||||
RSpec.describe Search::Postgres::Article, type: :service do
|
||||
RSpec.describe Search::Article, type: :service do
|
||||
describe "::search_documents" do
|
||||
it "returns an empty result if there are no articles" do
|
||||
expect(described_class.search_documents).to be_empty
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Search::Postgres::ChatChannelMembership, type: :service do
|
||||
RSpec.describe Search::ChatChannelMembership, type: :service do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe "::search_documents" do
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Search::Postgres::Comment, type: :service do
|
||||
RSpec.describe Search::Comment, type: :service do
|
||||
let(:comment) { create(:comment) }
|
||||
|
||||
describe "::search_documents" do
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Search::Postgres::Listing, type: :service do
|
||||
RSpec.describe Search::Listing, type: :service do
|
||||
let(:listing) { create(:listing, title: "Matches Nothing", body_markdown: "Matches Nothing, and Nothing Matches.") }
|
||||
|
||||
describe "::search_documents" do
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
require "rails_helper"
|
||||
|
||||
# rubocop:disable RSpec/ExampleLength
|
||||
RSpec.describe Search::Postgres::PodcastEpisode, type: :service do
|
||||
RSpec.describe Search::PodcastEpisode, type: :service do
|
||||
let(:podcast_episode) { create(:podcast_episode) }
|
||||
|
||||
describe "::search_documents" do
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Search::Postgres::ReadingList, type: :service do
|
||||
RSpec.describe Search::ReadingList, type: :service do
|
||||
let(:user) { create(:user) }
|
||||
let(:article) { create(:article) }
|
||||
let(:article_not_in_reading_list) { create(:article) }
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Search::Postgres::Tag, type: :service do
|
||||
RSpec.describe Search::Tag, type: :service do
|
||||
describe "::search_documents" do
|
||||
it "does not find non supported tags" do
|
||||
tag = create(:tag, supported: false)
|
||||
|
|
@ -2,7 +2,7 @@ require "rails_helper"
|
|||
|
||||
# rubocop:disable Rails/PluckId
|
||||
# This spec uses `pluck` on an array of hashes, but Rubocop can't tell the difference.
|
||||
RSpec.describe Search::Postgres::User, type: :service do
|
||||
RSpec.describe Search::User, type: :service do
|
||||
describe "::search_documents" do
|
||||
it "returns an empty result if there are no users" do
|
||||
expect(described_class.search_documents).to be_empty
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Search::Postgres::Username, type: :service do
|
||||
RSpec.describe Search::Username, type: :service do
|
||||
it "defines necessary constants" do
|
||||
expect(described_class::ATTRIBUTES).not_to be_nil
|
||||
expect(described_class::MAX_RESULTS).not_to be_nil
|
||||
Loading…
Add table
Reference in a new issue