hensei-api/app/services/party_query_builder.rb
Justin Edmund a042847aab
Migrate to Query Builder (#179)
* Moved queries into PartyQueryBuilder service

PartyQueryBuilder supersedes PartyQueryingConcern as it is also used for UsersController (and is our fix for profiles being broken)

* Implement PartyQueryBuilder in controllers

* Update summon_transformer.rb

This should fix the transformer so that we properly capture summons and subaura summons

* Update parties_controller_spec.rb

* Add NewRelic license key

* Add Sentry

Why not?
2025-02-12 23:43:02 -08:00

221 lines
7.7 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# frozen_string_literal: true
# PartyQueryBuilder is responsible for building an ActiveRecord query for parties
# by applying a series of filters, includes, and excludes based on request parameters.
# It is used to build both the general parties query and specialized queries (like
# for a users profile) while keeping the filtering logic DRY.
#
# Usage:
# base_query = Party.includes(:user, :job, ... ) # a starting query
# query_builder = PartyQueryBuilder.new(base_query, params: params, current_user: current_user, options: { default_status: 'active' })
# final_query = query_builder.build
#
class PartyQueryBuilder
# Initialize with a base query, a params hash, and the current user.
# Options may include default filters like :default_status, default counts, and max values.
def initialize(base_query, params:, current_user:, options: {})
@base_query = base_query
@params = params
@current_user = current_user
@options = options
end
# Builds the final ActiveRecord query by applying filters, includes, and excludes.
#
# Edge cases handled:
# - If a parameter is missing or blank, default values are used.
# - If no recency is provided, no date range is applied.
# - If includes/excludes parameters are missing, those methods are skipped.
#
# Also applies a default status filter (if provided via options) using a dedicated callback.
def build
query = @base_query
query = apply_filters(query)
query = apply_default_status(query) if @options[:default_status]
query = apply_privacy_settings(query)
query = apply_includes(query, @params[:includes]) if @params[:includes].present?
query = apply_excludes(query, @params[:excludes]) if @params[:excludes].present?
query.order(created_at: :desc)
end
private
# Applies filtering conditions to the given query.
# Combines generic filters (like element, raid_id, created_at) with object count ranges.
#
# Example edge case: If the request does not specify 'characters_count',
# then the default (e.g. 3) will be used, with the upper bound coming from a constant.
def apply_filters(query)
conditions = build_filters
query = query.where(conditions)
# If name_quality filtering is enabled via params, apply it.
query = query.where(name_quality) if @params[:name_quality].present?
query.where(
weapons_count: build_count(@params[:weapons_count], default_weapons_count)..max_weapons,
characters_count: build_count(@params[:characters_count], default_characters_count)..max_characters,
summons_count: build_count(@params[:summons_count], default_summons_count)..max_summons
)
end
# Example callback method: if no explicit status filter is provided, we may want
# to force the query to include only records with a given default status.
# This method encapsulates that behavior.
def apply_default_status(query)
query.where(status: @options[:default_status])
end
# Applies privacy settings based on whether the current user is an admin.
def apply_privacy_settings(query)
# If the options say to skip privacy filtering (e.g. when viewing your own profile),
# then return the query unchanged.
return query if @options[:skip_privacy]
# Otherwise, if not admin, only show public parties.
return query if @current_user&.admin?
query.where('visibility = ?', 1)
end
# Builds a hash of filtering conditions from the params.
#
# Uses guard clauses to ignore keys when a parameter is missing.
def build_filters
{
element: (@params[:element].present? ? @params[:element].to_i : nil),
raid_id: @params[:raid],
created_at: build_date_range,
full_auto: build_option(@params[:full_auto]),
auto_guard: build_option(@params[:auto_guard]),
charge_attack: build_option(@params[:charge_attack])
}.compact
end
# Returns a date range based on the 'recency' parameter.
# If recency is not provided, returns nil so no date filter is applied.
def build_date_range
return nil unless @params[:recency].present?
start_time = DateTime.current - @params[:recency].to_i.seconds
start_time.beginning_of_day..DateTime.current
end
# Returns the count from the parameter or a default value if the parameter is blank.
def build_count(value, default_value)
value.blank? ? default_value : value.to_i
end
# Processes an option parameter.
# Returns the integer value unless the value is blank or equal to -1.
def build_option(value)
value.to_i unless value.blank? || value.to_i == -1
end
# Applies "includes" filtering to the query based on a comma-separated string.
# For each provided ID, it adds a condition using an EXISTS subquery.
#
# Edge case example: If an ID does not start with a known prefix,
# grid_table_and_object_table returns [nil, nil] and the condition is skipped.
def apply_includes(query, includes)
includes.split(',').each do |id|
grid_table, object_table = grid_table_and_object_table(id)
next unless grid_table && object_table
condition = <<-SQL.squish
EXISTS (
SELECT 1 FROM #{grid_table}
JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id
WHERE #{object_table}.granblue_id = ? AND #{grid_table}.party_id = parties.id
)
SQL
query = query.where(condition, id)
end
query
end
# Applies "excludes" filtering to the query based on a comma-separated string.
# Works similarly to apply_includes, but with a NOT EXISTS clause.
def apply_excludes(query, excludes)
excludes.split(',').each do |id|
grid_table, object_table = grid_table_and_object_table(id)
next unless grid_table && object_table
condition = <<-SQL.squish
NOT EXISTS (
SELECT 1 FROM #{grid_table}
JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id
WHERE #{object_table}.granblue_id = ? AND #{grid_table}.party_id = parties.id
)
SQL
query = query.where(condition, id)
end
query
end
# Maps an IDs first character to the corresponding grid table and object table names.
#
# For example:
# '3...' => %w[grid_characters characters]
# '2...' => %w[grid_summons summons]
# '1...' => %w[grid_weapons weapons]
# Returns [nil, nil] for unknown prefixes.
def grid_table_and_object_table(id)
case id[0]
when '3'
%w[grid_characters characters]
when '2'
%w[grid_summons summons]
when '1'
%w[grid_weapons weapons]
else
[nil, nil]
end
end
# Default values and maximum limits for counts.
def default_weapons_count
@options[:default_weapons_count] || 5;
end
def default_characters_count
@options[:default_characters_count] || 3;
end
def default_summons_count
@options[:default_summons_count] || 2;
end
def max_weapons
@options[:max_weapons] || 13;
end
def max_characters
@options[:max_characters] || 5;
end
def max_summons
@options[:max_summons] || 8;
end
# Stub method for name quality filtering.
# In your application, this might be defined in a helper or concern.
def name_quality
# Example: exclude parties with names like 'Untitled' (edge case)
"name NOT LIKE 'Untitled%'"
end
# Stub method for user quality filtering.
# Adjust as needed for your actual implementation.
def user_quality
'user_id IS NOT NULL'
end
# Stub method for original filtering.
def original
'source_party_id IS NULL'
end
# Stub method for privacy filtering.
# Here we assume that if the current user is not an admin, only public parties (visibility = 1) are returned.
def privacy
return nil if @current_user && @current_user.admin?
'visibility = 1'
end
end