Moved queries into PartyQueryBuilder service

PartyQueryBuilder supersedes PartyQueryingConcern as it is also used for UsersController (and is our fix for profiles being broken)
This commit is contained in:
Justin Edmund 2025-02-12 23:11:20 -08:00
parent d6300f7aeb
commit 924767457e
4 changed files with 434 additions and 227 deletions

View file

@ -4,30 +4,28 @@ module PartyQueryingConcern
extend ActiveSupport::Concern
include PartyConstants
# Builds the base query for parties with all required associations.
def build_parties_query(favorites: false)
query = Party.includes(
# Returns the common base query for Parties including all necessary associations.
def build_common_base_query
Party.includes(
{ raid: :group },
:job,
:user,
:skill0, :skill1, :skill2, :skill3,
:guidebook1, :guidebook2, :guidebook3,
:skill0,
:skill1,
:skill2,
:skill3,
:guidebook1,
:guidebook2,
:guidebook3,
{ characters: :character },
{ weapons: :weapon },
{ summons: :summon }
)
query = if favorites
query.joins(:favorites)
.where(favorites: { user_id: current_user.id })
.distinct.order(created_at: :desc)
else
query.order(visibility: :asc, created_at: :desc)
end
query = apply_filters(query)
query = apply_privacy_settings(query, favorites: false)
query = apply_includes(query, params[:includes]) if params[:includes].present?
query = apply_excludes(query, params[:excludes]) if params[:excludes].present?
query
end
# Uses PartyQueryBuilder to apply additional filters (includes, excludes, date ranges, etc.)
def build_filtered_query(base_query)
PartyQueryBuilder.new(base_query, params: params, current_user: current_user).build
end
# Renders paginated parties using PartyBlueprint.
@ -39,115 +37,12 @@ module PartyQueryingConcern
meta: {
count: parties.total_entries,
total_pages: parties.total_pages,
per_page: PartyConstants::COLLECTION_PER_PAGE
per_page: COLLECTION_PER_PAGE
},
current_user: current_user
)
end
# Applies filters to the query.
def apply_filters(query)
conditions = build_filters
query = query.where(conditions)
query = query.where(name_quality) if params[:name_quality].present?
query.where(
weapons_count: build_count(params[:weapons_count], PartyConstants::DEFAULT_MIN_WEAPONS)..PartyConstants::MAX_WEAPONS,
characters_count: build_count(params[:characters_count], PartyConstants::DEFAULT_MIN_CHARACTERS)..PartyConstants::MAX_CHARACTERS,
summons_count: build_count(params[:summons_count], PartyConstants::DEFAULT_MIN_SUMMONS)..PartyConstants::MAX_SUMMONS
)
end
# Applies privacy settings based on whether the current user is an admin.
def apply_privacy_settings(query, favorites: false)
return query if admin_mode
if favorites.present?
query.where('visibility < 3')
else
query.where(visibility: 1)
end
end
# Builds filtering conditions from request parameters.
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]),
characters_count: build_count(params[:characters_count], PartyConstants::DEFAULT_MIN_CHARACTERS)..PartyConstants::MAX_CHARACTERS,
summons_count: build_count(params[:summons_count], PartyConstants::DEFAULT_MIN_SUMMONS)..PartyConstants::MAX_SUMMONS,
weapons_count: build_count(params[:weapons_count], PartyConstants::DEFAULT_MIN_WEAPONS)..PartyConstants::MAX_WEAPONS
}.compact
end
# Returns a date range based on the recency parameter.
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 value or a default if blank.
def build_count(value, default)
value.blank? ? default : value.to_i
end
# Processes an option parameter.
def build_option(value)
value.to_i unless value.blank? || value.to_i == -1
end
# Applies “includes” filtering for objects in the party.
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 for objects in the party.
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 prefix to the corresponding grid and object table names.
def grid_table_and_object_table(id)
case id[0]
when '3' then %w[grid_characters characters]
when '2' then %w[grid_summons summons]
when '1' then %w[grid_weapons weapons]
else [nil, nil]
end
end
# Returns a remixed party name based on the current party name and current_user language.
def remixed_name(name)
blanked_name = { en: name.blank? ? 'Untitled team' : name, ja: name.blank? ? '無名の編成' : name }

View file

@ -0,0 +1,221 @@
# 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

View file

@ -20,112 +20,6 @@ end
RSpec.describe DummyQueryClass do
let(:dummy) { DummyQueryClass.new }
describe '#build_filters' do
context 'when parameters are provided' do
before do
dummy.params.merge!({
element: '3',
raid: 'raid_id_123',
recency: '3600',
full_auto: '1',
auto_guard: '0',
charge_attack: '1',
characters_count: '4',
summons_count: '3',
weapons_count: '6'
})
end
it 'builds a hash with converted values and a date range for created_at' do
filters = dummy.build_filters
expect(filters[:element]).to eq(3)
expect(filters[:raid_id]).to eq('raid_id_123')
expect(filters[:created_at]).to be_a(Range)
expect(filters[:full_auto]).to eq(1)
expect(filters[:auto_guard]).to eq(0)
expect(filters[:charge_attack]).to eq(1)
# For object count ranges, we expect a Range.
expect(filters[:characters_count]).to be_a(Range)
expect(filters[:summons_count]).to be_a(Range)
expect(filters[:weapons_count]).to be_a(Range)
end
end
context 'when no parameters are provided' do
before { dummy.params = {} }
it 'returns the default quality filters' do
filters = dummy.build_filters
expect(filters).to include(
characters_count: (PartyConstants::DEFAULT_MIN_CHARACTERS..PartyConstants::MAX_CHARACTERS),
summons_count: (PartyConstants::DEFAULT_MIN_SUMMONS..PartyConstants::MAX_SUMMONS),
weapons_count: (PartyConstants::DEFAULT_MIN_WEAPONS..PartyConstants::MAX_WEAPONS)
)
end
end
end
describe '#build_date_range' do
context 'with a recency parameter' do
before { dummy.params = { recency: '7200' } }
it 'returns a valid date range' do
date_range = dummy.build_date_range
expect(date_range).to be_a(Range)
expect(date_range.begin).to be <= DateTime.current
expect(date_range.end).to be >= DateTime.current - 2.hours
end
end
context 'without a recency parameter' do
before { dummy.params = {} }
it 'returns nil' do
expect(dummy.build_date_range).to be_nil
end
end
end
describe '#build_count' do
it 'returns the default value when blank' do
expect(dummy.build_count('', 3)).to eq(3)
end
it 'converts string values to integer' do
expect(dummy.build_count('5', 3)).to eq(5)
end
end
describe '#build_option' do
it 'returns nil for blank or -1 values' do
expect(dummy.build_option('')).to be_nil
expect(dummy.build_option('-1')).to be_nil
end
it 'returns the integer value for valid input' do
expect(dummy.build_option('2')).to eq(2)
end
end
describe '#grid_table_and_object_table' do
it 'maps id starting with "3" to grid_characters and characters' do
tables = dummy.grid_table_and_object_table('300000')
expect(tables).to eq(%w[grid_characters characters])
end
it 'maps id starting with "2" to grid_summons and summons' do
tables = dummy.grid_table_and_object_table('200000')
expect(tables).to eq(%w[grid_summons summons])
end
it 'maps id starting with "1" to grid_weapons and weapons' do
tables = dummy.grid_table_and_object_table('100000')
expect(tables).to eq(%w[grid_weapons weapons])
end
it 'returns [nil, nil] for an unknown prefix' do
tables = dummy.grid_table_and_object_table('900000')
expect(tables).to eq([nil, nil])
end
end
describe '#remixed_name' do
context 'when current_user is present' do
let(:user) { build(:user, language: 'en') }

View file

@ -0,0 +1,197 @@
# frozen_string_literal: true
# This spec verifies that PartyQueryBuilder correctly builds an ActiveRecord
# query based on provided parameters. It tests both the overall build process and
# individual helper methods.
#
require 'rails_helper'
RSpec.describe PartyQueryBuilder, type: :model do
let(:base_query) { Party.all } # Use Party.all as our starting query.
let(:params) do
{
element: '3',
raid: '123e4567-e89b-12d3-a456-426614174000',
recency: '3600',
full_auto: '1',
auto_guard: '0',
charge_attack: '1',
weapons_count: '', # blank => should use default
characters_count: '4',
summons_count: '2',
includes: '300001,200002',
excludes: '100003',
name_quality: '1' # dummy flag for testing name_quality clause
}
end
let(:current_user) { create(:user) }
let(:options) { {} }
subject { described_class.new(base_query, params: params, current_user: current_user, options: options) }
describe '#build' do
context 'with all filters provided' do
it 'returns an ActiveRecord::Relation with filters applied' do
query = subject.build
sql = query.to_sql
# Expect the element filter to be applied (converted to integer)
expect(sql).to include('"parties"."element" = 3')
# Expect the raid filter to be applied
expect(sql).to match(/"parties"."raid_id"\s*=\s*'123e4567-e89b-12d3-a456-426614174000'/)
# Expect a created_at range condition from the recency param
expect(sql).to include('"parties"."created_at" BETWEEN')
# Expect object count filtering for characters_count (range clause)
expect(sql).to include('characters_count')
# Expect the name quality stub condition
expect(sql).to include("name NOT LIKE 'Untitled%'")
# Expect that includes and excludes clauses (EXISTS and NOT EXISTS) are added
expect(sql).to include('EXISTS (')
expect(sql).to include('NOT EXISTS (')
end
end
context 'when default_status option is provided' do
let(:options) { { default_status: 'active' } }
it 'applies the default status filter' do
query = subject.build
sql = query.to_sql
expect(sql).to include('"parties"."status" = \'active\'')
end
end
context 'when current_user is not admin and skip_privacy is not set' do
it 'applies the privacy filter (visibility = 1)' do
query = subject.build
sql = query.to_sql
expect(sql).to include('visibility = 1')
end
end
context 'when current_user is admin' do
let(:current_user) { create(:user, role: 9) }
it 'does not apply the privacy filter' do
query = subject.build
sql = query.to_sql
expect(sql).not_to include('visibility = 1')
end
end
context 'when skip_privacy option is set' do
let(:options) { { skip_privacy: true } }
it 'does not apply the privacy filter even for non-admins' do
query = subject.build
sql = query.to_sql
expect(sql).not_to include('visibility = 1')
end
end
it 'returns an ActiveRecord::Relation object' do
expect(subject.build).to be_a(ActiveRecord::Relation)
end
end
describe 'private helper methods' do
describe '#grid_table_and_object_table' do
it 'returns grid_characters and characters for an id starting with "3"' do
result = subject.send(:grid_table_and_object_table, '300001')
expect(result).to eq(%w[grid_characters characters])
end
it 'returns grid_summons and summons for an id starting with "2"' do
result = subject.send(:grid_table_and_object_table, '200001')
expect(result).to eq(%w[grid_summons summons])
end
it 'returns grid_weapons and weapons for an id starting with "1"' do
result = subject.send(:grid_table_and_object_table, '100001')
expect(result).to eq(%w[grid_weapons weapons])
end
it 'returns [nil, nil] for an unknown prefix' do
result = subject.send(:grid_table_and_object_table, '900001')
expect(result).to eq([nil, nil])
end
end
describe '#build_date_range' do
it 'returns a range when recency parameter is provided' do
range = subject.send(:build_date_range)
expect(range).to be_a(Range)
expect(range.begin).to be <= DateTime.current
# The range should span from beginning of the day to now
expect(range.end).to be >= DateTime.current - 3600.seconds
end
it 'returns nil when recency parameter is missing' do
new_params = params.dup
new_params.delete(:recency)
builder = described_class.new(base_query, params: new_params, current_user: current_user, options: options)
expect(builder.send(:build_date_range)).to be_nil
end
end
describe '#build_count' do
it 'returns the default value when given a blank parameter' do
expect(subject.send(:build_count, '', 3)).to eq(3)
end
it 'converts string numbers to integer' do
expect(subject.send(:build_count, '5', 3)).to eq(5)
end
end
describe '#build_option' do
it 'returns nil if the value is blank' do
expect(subject.send(:build_option, '')).to be_nil
end
it 'returns nil if the value is -1' do
expect(subject.send(:build_option, '-1')).to be_nil
end
it 'returns the integer value for valid input' do
expect(subject.send(:build_option, '2')).to eq(2)
end
end
describe '#apply_includes and #apply_excludes' do
context 'with a valid includes parameter' do
let(:includes_param) { '300001' } # should map to grid_characters/characters
it 'adds an EXISTS clause to the query' do
query = subject.send(:apply_includes, base_query, includes_param)
sql = query.to_sql
expect(sql).to include('EXISTS (')
expect(sql).to include('grid_characters')
expect(sql).to include('characters')
end
end
context 'with a valid excludes parameter' do
let(:excludes_param) { '100001' } # should map to grid_weapons/weapons
it 'adds a NOT EXISTS clause to the query' do
query = subject.send(:apply_excludes, base_query, excludes_param)
sql = query.to_sql
expect(sql).to include('NOT EXISTS (')
expect(sql).to include('grid_weapons')
expect(sql).to include('weapons')
end
end
context 'with an unknown prefix in includes/excludes' do
let(:bad_param) { '900001' }
it 'skips the condition for includes' do
query = subject.send(:apply_includes, base_query, bad_param)
sql = query.to_sql
expect(sql).not_to include('EXISTS (')
end
it 'skips the condition for excludes' do
query = subject.send(:apply_excludes, base_query, bad_param)
sql = query.to_sql
expect(sql).not_to include('NOT EXISTS (')
end
end
end
end
end