Cleanup search classes and serializers (#13645)

This commit is contained in:
Alex 2021-05-05 09:20:59 -04:00 committed by GitHub
parent 41cc771839
commit a0ddb6a848
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 845 additions and 744 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +0,0 @@
module Search
class UsernameSerializer < ApplicationSerializer
attributes :id,
:name,
:profile_image_90,
:username
end
end

View 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

View 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

View 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

View 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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View 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

View 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

View 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

View file

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

View file

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

View file

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

View file

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

View 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

View 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

View 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

View file

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

View file

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

View 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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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