docbrown/app/services/articles/feeds/variant_query.rb
Jeremy Friesen 0d0464be2f
Allowing VariantQuery for Feed Generation (#17382)
* Allowing VariantQuery for Feed Generation

Apologies for the breadth of this pull request, I had considered many
small commits, but felt that would've been more effort for the value
provided.

This commit includes the following:

- Documentation updates to the feed variant (though not the final pass)
- Renaming and adding RelevancyLevers that help differentiate
- Adding RelevancyLever#range to provide documentation
- Reducing redundent controller logic by making a
  `Articles::Feeds.feed_for` method.
- Adding some configuration validation for RelevancyLevers
- Adding constants for better clarification
- Testing unhappy paths for feed configuration
- Adjusting the module namespace of some objects
- Exposing top-level configurations for variants (along with their
  defaults)
- Creating the VariantyQuery that at present inherits from the
  `Articles::Feeds::WeightedQueryStrategy`

As implemented, we can deploy this code to production without using the
new VariantQuery.  Once we toggle on the
`:feed_uses_variant_query_feature` FeatureFlag, it will switch to using
the VariantQuery.  The VariantQuery's two variants and the
internal configuration of `Articles::Feeds::WeightedQueryStrategy`
produce the same query.

The goal of this factor is to allow for a quick on and off toggle of the
feed query; to ensure that what we introduce remains performant.

- Closes forem/forem#17272
- Closes forem/forem#17276
- Closes forem/forem#17216

In addition, I will be recording a code-walkthrough and linking that
recording to the pull request.

* Apply suggestions from code review

Co-authored-by: Mac Siri <krairit.siri@gmail.com>

Co-authored-by: Mac Siri <krairit.siri@gmail.com>
2022-04-21 11:07:09 -04:00

113 lines
4 KiB
Ruby

module Articles
module Feeds
# @note In the current implementation this is inheriting from WeightedQueryStrategy; going
# forward, we want to move away from the less flexible WeightedQueryStrategy. However, as
# we roll this out, we want both to be utilized. In part so we can have a quick fallback
# to a known working state (e.g. WeightedQueryStrategy). Assuming we move forward with
# this implementation, we will break the inheritance, copy the relevant methods over, and
# remove the WeightedQueryStrategy.
class VariantQuery < WeightedQueryStrategy
# @param variant [Symbol, #to_sym] the name of the variant query we're building.
# @param assembler [Articles::Feeds::VariantAssembler, #call] responsible for converting the
# given variant to a config suitable for building a VariantQuery.
# @param kwargs [Hash] named parameters to pass along to the #initialize method.
#
# @return [Articles::Feeds::VariantQuery]
#
# @see #initialize
def self.build_for(variant:, assembler: VariantAssembler, **kwargs)
config = assembler.call(variant: variant)
new(config: config, **kwargs)
end
# Let's make sure that folks initialize this with a variant configuration.
private_class_method :new
Config = Struct.new(
:variant,
:levers, # Array <Articles::Feeds::RelevancyLever::Configured>
:order_by, # Articles::Feeds::OrderByLever
:max_days_since_published,
:default_user_experience_level,
:negative_reaction_threshold,
:positive_reaction_threshold,
keyword_init: true,
)
# @param config [Articles::Feeds::VariantQuery::Config]
# @param user [User,NilClass]
# @param number_of_articles [Integer, #to_i]
# @param page [Integer, #to_i]
# @param tag [NilClass] not used
#
# rubocop:disable Lint/MissingSuper
def initialize(config:, user: nil, number_of_articles: 50, page: 1, tag: nil)
@user = user
@number_of_articles = number_of_articles
@page = page
@tag = tag
@config = config
@oldest_published_at = Articles::Feeds.oldest_published_at_to_consider_for(
user: @user,
days_since_published: max_days_since_published,
)
configure!
end
# rubocop:enable Lint/MissingSuper
delegate(
:max_days_since_published,
:negative_reaction_threshold,
:positive_reaction_threshold,
:default_user_experience_level,
to: :config,
)
attr_reader :config
private
def final_order_logic(articles)
articles.order(config.order_by.to_sql)
end
def configure!
@relevance_score_components = []
# By default we always need to group by the articles.id
# column. And as we add scoring methods to the query, we need
# to add additional group_by clauses based on the chosen
# scoring method.
@group_by_fields = Set.new
@group_by_fields << "articles.id"
@joins = Set.new
# Ensure that we honor a user's block requests.
unless @user.nil?
@joins << "LEFT OUTER JOIN user_blocks
ON user_blocks.blocked_id = articles.user_id
AND user_blocks.blocked_id IS NULL
AND user_blocks.blocker_id = :user_id"
end
config.levers.each do |lever|
# Don't attempt to use this factor if we don't have user.
next if lever.user_required? && @user.nil?
# This scoring method requires a group by clause.
@group_by_fields << lever.group_by_fragment if lever.group_by_fragment.present?
@joins += lever.joins_fragments if lever.joins_fragments.present?
@relevance_score_components << build_score_element_from(
clause: lever.select_fragment,
cases: lever.cases,
fallback: lever.fallback,
)
end
end
end
end
end