hensei-api/app/services/party_query_builder.rb
Justin Edmund 3cdd925162
Fix filters and add processors (#181)
* Update test csvs

* Fix count filters and refactor apply_filters

* Update party_querying_concern.rb

* +tests/-debug logs

* Make party association optional in Job

* Updates for weapon series

- Change to new series numbers
- Add static method for querying whether the weapon's element is changeable
- Add a new method to return a text slug for the weapon's series

* Add and update test data

- Updates canonical.rb for loading multiple types of data with multiple types of associations
- Adds test data for Guidebooks, Job Accessories, Job Skills, and Jobs
- Updates test data for Weapons and Summons

* Migrations

- Adds series of migrations for changing the weapon's series to the values used by Cygames
- Shuffled around some foreign keys

* Implement BaseProcessor

Processors are in charge of processing deck data straight from Granblue.

* Implement CharacterProcessor

Process character data from deck

* Implement WeaponProcessor

Process weapon data from deck

* Implement JobProcessor

Process job, job skill, and job accessory data from deck

* Implement SummonProcessor

Process summon data from deck

* Update SummonProcessor to work like the others

* ImportController should use processors

* Process element for changeable weapons
2025-02-17 23:51:50 -08:00

274 lines
9.8 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)
query = apply_base_filters(query)
query = apply_name_quality_filter(query)
query = apply_count_filters(query)
query
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
# Applies base filtering conditions from build_filters to the query.
# @param query [ActiveRecord::QueryMethods::WhereChain] The current query.
# @return [ActiveRecord::Relation] The query with base filters applied.
def apply_base_filters(query)
query.where(build_filters)
end
# Applies the name quality filter to the query if the parameter is present.
# @param query [ActiveRecord::QueryMethods::WhereChain] The current query.
# @return [ActiveRecord::Relation] The query with the name quality filter applied.
def apply_name_quality_filter(query)
@params[:name_quality].present? ? query.where(name_quality) : query
end
# Applies count filters to the query based on provided parameters or default options.
# If apply_defaults is set in options, default ranges are applied.
# Otherwise, count ranges are built from provided parameters.
# @param query [ | ActiveRecord::QueryMethods::WhereChain] The current query.
# @return [ActiveRecord::Relation] The query with count filters applied.
def apply_count_filters(query)
if @options[:apply_defaults]
query.where(
weapons_count: default_weapons_count..max_weapons,
characters_count: default_characters_count..max_characters,
summons_count: default_summons_count..max_summons
)
elsif count_filter_provided?
query.where(build_count_conditions)
else
query
end
end
# Determines if any count filter parameters have been provided.
# @return [Boolean] True if any count filters are provided, false otherwise.
def count_filter_provided?
@params.key?(:weapons_count) || @params.key?(:characters_count) || @params.key?(:summons_count)
end
# Builds a hash of count conditions based on the count filter parameters.
# @return [Hash] A hash with keys :weapons_count, :characters_count, and :summons_count.
def build_count_conditions
{
weapons_count: build_range(@params[:weapons_count], max_weapons),
characters_count: build_range(@params[:characters_count], max_characters),
summons_count: build_range(@params[:summons_count], max_summons)
}
end
# Constructs a range for a given count parameter.
# @param param_value [String, nil] The count filter parameter value.
# @param max_value [Integer] The maximum allowed value for the count.
# @return [Range] A range from the provided count (or 0 if blank) to the max_value.
def build_range(param_value, max_value)
param_value.present? ? param_value.to_i..max_value : 0..max_value
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