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?
This commit is contained in:
parent
d6300f7aeb
commit
a042847aab
12 changed files with 615 additions and 279 deletions
5
Gemfile
5
Gemfile
|
|
@ -76,6 +76,11 @@ gem 'strscan'
|
||||||
# New Relic Ruby Agent
|
# New Relic Ruby Agent
|
||||||
gem 'newrelic_rpm'
|
gem 'newrelic_rpm'
|
||||||
|
|
||||||
|
# The Sentry SDK for Rails
|
||||||
|
gem "stackprof"
|
||||||
|
gem "sentry-ruby"
|
||||||
|
gem "sentry-rails"
|
||||||
|
|
||||||
group :doc do
|
group :doc do
|
||||||
gem 'apipie-rails'
|
gem 'apipie-rails'
|
||||||
gem 'sdoc'
|
gem 'sdoc'
|
||||||
|
|
|
||||||
10
Gemfile.lock
10
Gemfile.lock
|
|
@ -377,6 +377,12 @@ GEM
|
||||||
sdoc (2.6.1)
|
sdoc (2.6.1)
|
||||||
rdoc (>= 5.0)
|
rdoc (>= 5.0)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
|
sentry-rails (5.22.4)
|
||||||
|
railties (>= 5.0)
|
||||||
|
sentry-ruby (~> 5.22.4)
|
||||||
|
sentry-ruby (5.22.4)
|
||||||
|
bigdecimal
|
||||||
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
shoulda-matchers (6.4.0)
|
shoulda-matchers (6.4.0)
|
||||||
activesupport (>= 5.2.0)
|
activesupport (>= 5.2.0)
|
||||||
sidekiq (7.3.8)
|
sidekiq (7.3.8)
|
||||||
|
|
@ -420,6 +426,7 @@ GEM
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
sprockets (>= 3.0.0)
|
sprockets (>= 3.0.0)
|
||||||
squasher (0.8.0)
|
squasher (0.8.0)
|
||||||
|
stackprof (0.2.27)
|
||||||
stringio (3.1.2)
|
stringio (3.1.2)
|
||||||
strscan (3.1.2)
|
strscan (3.1.2)
|
||||||
thor (1.3.2)
|
thor (1.3.2)
|
||||||
|
|
@ -491,6 +498,8 @@ DEPENDENCIES
|
||||||
rubocop
|
rubocop
|
||||||
rufus-scheduler
|
rufus-scheduler
|
||||||
sdoc
|
sdoc
|
||||||
|
sentry-rails
|
||||||
|
sentry-ruby
|
||||||
shoulda-matchers
|
shoulda-matchers
|
||||||
sidekiq
|
sidekiq
|
||||||
simplecov
|
simplecov
|
||||||
|
|
@ -499,6 +508,7 @@ DEPENDENCIES
|
||||||
spring-commands-rspec
|
spring-commands-rspec
|
||||||
sprockets-rails
|
sprockets-rails
|
||||||
squasher (>= 0.6.0)
|
squasher (>= 0.6.0)
|
||||||
|
stackprof
|
||||||
strscan
|
strscan
|
||||||
will_paginate (~> 3.3)
|
will_paginate (~> 3.3)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,16 +105,20 @@ module Api
|
||||||
|
|
||||||
# Lists parties based on query parameters.
|
# Lists parties based on query parameters.
|
||||||
def index
|
def index
|
||||||
query = build_parties_query
|
query = build_filtered_query(build_common_base_query)
|
||||||
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
||||||
render_paginated_parties(@parties)
|
render_paginated_parties(@parties)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Lists parties favorited by the current user.
|
# GET /api/v1/parties/favorites
|
||||||
def favorites
|
def favorites
|
||||||
raise Api::V1::UnauthorizedError unless current_user
|
raise Api::V1::UnauthorizedError unless current_user
|
||||||
ap "Total favorites in DB: #{Favorite.count}"
|
|
||||||
query = build_parties_query(favorites: true)
|
base_query = build_common_base_query
|
||||||
|
.joins(:favorites)
|
||||||
|
.where(favorites: { user_id: current_user.id })
|
||||||
|
.distinct
|
||||||
|
query = build_filtered_query(base_query)
|
||||||
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
||||||
render_paginated_parties(@parties)
|
render_paginated_parties(@parties)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -55,34 +55,39 @@ module Api
|
||||||
if @user.nil?
|
if @user.nil?
|
||||||
render_not_found_response('user')
|
render_not_found_response('user')
|
||||||
else
|
else
|
||||||
conditions = build_conditions
|
base_query = Party.includes(
|
||||||
conditions[:user_id] = @user.id
|
{ raid: :group },
|
||||||
|
:job,
|
||||||
favorites_query = "EXISTS (SELECT 1 FROM favorites WHERE favorites.party_id = parties.id AND favorites.user_id = #{current_user&.id || 'NULL'}) AS is_favorited"
|
:user,
|
||||||
parties = Party.where(conditions)
|
:skill0,
|
||||||
.where(name_quality)
|
:skill1,
|
||||||
.where(user_quality)
|
:skill2,
|
||||||
.where(original)
|
:skill3,
|
||||||
.where(privacy)
|
:guidebook1,
|
||||||
.includes(:favorites)
|
:guidebook2,
|
||||||
.select(Party.arel_table[Arel.star])
|
:guidebook3,
|
||||||
.select(
|
{ characters: :character },
|
||||||
Arel.sql(favorites_query)
|
{ weapons: :weapon },
|
||||||
)
|
{ summons: :summon }
|
||||||
.order(created_at: :desc)
|
)
|
||||||
.paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE)
|
# Restrict to parties belonging to the profile owner
|
||||||
|
base_query = base_query.where(user_id: @user.id)
|
||||||
count = Party.where(conditions).count
|
skip_privacy = (current_user&.id == @user.id)
|
||||||
|
query = PartyQueryBuilder.new(
|
||||||
|
base_query,
|
||||||
|
params: params,
|
||||||
|
current_user: current_user,
|
||||||
|
options: { skip_privacy: skip_privacy }
|
||||||
|
).build
|
||||||
|
parties = query.paginate(page: params[:page], per_page: PartyConstants::COLLECTION_PER_PAGE)
|
||||||
|
count = query.count
|
||||||
render json: UserBlueprint.render(@user,
|
render json: UserBlueprint.render(@user,
|
||||||
view: :profile,
|
view: :profile,
|
||||||
root: 'profile',
|
root: 'profile',
|
||||||
parties: parties,
|
parties: parties,
|
||||||
meta: {
|
meta: { count: count, total_pages: (count.to_f / PartyConstants::COLLECTION_PER_PAGE).ceil, per_page: PartyConstants::COLLECTION_PER_PAGE },
|
||||||
count: count,
|
current_user: current_user
|
||||||
total_pages: count.to_f / COLLECTION_PER_PAGE > 1 ? (count.to_f / COLLECTION_PER_PAGE).ceil : 1,
|
)
|
||||||
per_page: COLLECTION_PER_PAGE
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -98,6 +103,36 @@ module Api
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def build_profile_query(profile_user)
|
||||||
|
query = Party.includes(
|
||||||
|
{ raid: :group },
|
||||||
|
:job,
|
||||||
|
:user,
|
||||||
|
:skill0,
|
||||||
|
:skill1,
|
||||||
|
:skill2,
|
||||||
|
:skill3,
|
||||||
|
:guidebook1,
|
||||||
|
:guidebook2,
|
||||||
|
:guidebook3,
|
||||||
|
{ characters: :character },
|
||||||
|
{ weapons: :weapon },
|
||||||
|
{ summons: :summon }
|
||||||
|
)
|
||||||
|
# Restrict to parties belonging to the profile’s owner.
|
||||||
|
query = query.where(user_id: profile_user.id)
|
||||||
|
# Then apply the additional filters that we normally use:
|
||||||
|
query = query.where(name_quality)
|
||||||
|
.where(user_quality)
|
||||||
|
.where(original)
|
||||||
|
.where(privacy)
|
||||||
|
# And if there are any request-supplied filters, includes, or excludes:
|
||||||
|
query = apply_filters(query) if params[:filters].present?
|
||||||
|
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
|
||||||
|
|
||||||
def build_conditions
|
def build_conditions
|
||||||
params = request.params
|
params = request.params
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,30 +4,28 @@ module PartyQueryingConcern
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
include PartyConstants
|
include PartyConstants
|
||||||
|
|
||||||
# Builds the base query for parties with all required associations.
|
# Returns the common base query for Parties including all necessary associations.
|
||||||
def build_parties_query(favorites: false)
|
def build_common_base_query
|
||||||
query = Party.includes(
|
Party.includes(
|
||||||
{ raid: :group },
|
{ raid: :group },
|
||||||
:job,
|
:job,
|
||||||
:user,
|
:user,
|
||||||
:skill0, :skill1, :skill2, :skill3,
|
:skill0,
|
||||||
:guidebook1, :guidebook2, :guidebook3,
|
:skill1,
|
||||||
|
:skill2,
|
||||||
|
:skill3,
|
||||||
|
:guidebook1,
|
||||||
|
:guidebook2,
|
||||||
|
:guidebook3,
|
||||||
{ characters: :character },
|
{ characters: :character },
|
||||||
{ weapons: :weapon },
|
{ weapons: :weapon },
|
||||||
{ summons: :summon }
|
{ summons: :summon }
|
||||||
)
|
)
|
||||||
query = if favorites
|
end
|
||||||
query.joins(:favorites)
|
|
||||||
.where(favorites: { user_id: current_user.id })
|
# Uses PartyQueryBuilder to apply additional filters (includes, excludes, date ranges, etc.)
|
||||||
.distinct.order(created_at: :desc)
|
def build_filtered_query(base_query)
|
||||||
else
|
PartyQueryBuilder.new(base_query, params: params, current_user: current_user).build
|
||||||
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
|
end
|
||||||
|
|
||||||
# Renders paginated parties using PartyBlueprint.
|
# Renders paginated parties using PartyBlueprint.
|
||||||
|
|
@ -39,115 +37,12 @@ module PartyQueryingConcern
|
||||||
meta: {
|
meta: {
|
||||||
count: parties.total_entries,
|
count: parties.total_entries,
|
||||||
total_pages: parties.total_pages,
|
total_pages: parties.total_pages,
|
||||||
per_page: PartyConstants::COLLECTION_PER_PAGE
|
per_page: COLLECTION_PER_PAGE
|
||||||
},
|
},
|
||||||
current_user: current_user
|
current_user: current_user
|
||||||
)
|
)
|
||||||
end
|
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 id’s 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.
|
# Returns a remixed party name based on the current party name and current_user language.
|
||||||
def remixed_name(name)
|
def remixed_name(name)
|
||||||
blanked_name = { en: name.blank? ? 'Untitled team' : name, ja: name.blank? ? '無名の編成' : name }
|
blanked_name = { en: name.blank? ? 'Untitled team' : name, ja: name.blank? ? '無名の編成' : name }
|
||||||
|
|
|
||||||
221
app/services/party_query_builder.rb
Normal file
221
app/services/party_query_builder.rb
Normal 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 user’s 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 ID’s 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
|
||||||
7
config/initializers/sentry.rb
Normal file
7
config/initializers/sentry.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
Sentry.init do |config|
|
||||||
|
config.breadcrumbs_logger = [:active_support_logger]
|
||||||
|
config.dsn = ENV['SENTRY_DSN']
|
||||||
|
config.enable_tracing = true
|
||||||
|
end
|
||||||
66
config/newrelic.yml
Normal file
66
config/newrelic.yml
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
#
|
||||||
|
# This file configures the New Relic Agent. New Relic monitors Ruby, Java,
|
||||||
|
# .NET, PHP, Python, Node, and Go applications with deep visibility and low
|
||||||
|
# overhead. For more information, visit www.newrelic.com.
|
||||||
|
#
|
||||||
|
# Generated October 28, 2022
|
||||||
|
#
|
||||||
|
# This configuration file is custom generated for NewRelic Administration
|
||||||
|
#
|
||||||
|
# For full documentation of agent configuration options, please refer to
|
||||||
|
# https://docs.newrelic.com/docs/agents/ruby-agent/installation-configuration/ruby-agent-configuration
|
||||||
|
|
||||||
|
common: &default_settings
|
||||||
|
# Required license key associated with your New Relic account.
|
||||||
|
license_key: <%= ENV['NEW_RELIC_LICENSE_KEY'] %>
|
||||||
|
|
||||||
|
# Your application name. Renaming here affects where data displays in New
|
||||||
|
# Relic. For more details, see https://docs.newrelic.com/docs/apm/new-relic-apm/maintenance/renaming-applications
|
||||||
|
app_name: 'hensei-api'
|
||||||
|
|
||||||
|
distributed_tracing:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# To disable the agent regardless of other settings, uncomment the following:
|
||||||
|
|
||||||
|
# agent_enabled: false
|
||||||
|
|
||||||
|
# Logging level for log/newrelic_agent.log
|
||||||
|
log_level: info
|
||||||
|
|
||||||
|
application_logging:
|
||||||
|
# If `true`, all logging-related features for the agent can be enabled or disabled
|
||||||
|
# independently. If `false`, all logging-related features are disabled.
|
||||||
|
enabled: true
|
||||||
|
forwarding:
|
||||||
|
# If `true`, the agent captures log records emitted by this application.
|
||||||
|
enabled: true
|
||||||
|
# Defines the maximum number of log records to buffer in memory at a time.
|
||||||
|
max_samples_stored: 10000
|
||||||
|
metrics:
|
||||||
|
# If `true`, the agent captures metrics related to logging for this application.
|
||||||
|
enabled: true
|
||||||
|
local_decorating:
|
||||||
|
# If `true`, the agent decorates logs with metadata to link to entities, hosts, traces, and spans.
|
||||||
|
# This requires a log forwarder to send your log files to New Relic.
|
||||||
|
# This should not be used when forwarding is enabled.
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# Environment-specific settings are in this section.
|
||||||
|
# RAILS_ENV or RACK_ENV (as appropriate) is used to determine the environment.
|
||||||
|
# If your application has other named environments, configure them here.
|
||||||
|
development:
|
||||||
|
<<: *default_settings
|
||||||
|
app_name: 'hensei-api (Development)'
|
||||||
|
|
||||||
|
test:
|
||||||
|
<<: *default_settings
|
||||||
|
# It doesn't make sense to report to New Relic from automated test runs.
|
||||||
|
monitor_mode: false
|
||||||
|
|
||||||
|
staging:
|
||||||
|
<<: *default_settings
|
||||||
|
app_name: 'hensei-api (Staging)'
|
||||||
|
|
||||||
|
production:
|
||||||
|
<<: *default_settings
|
||||||
|
|
@ -40,54 +40,56 @@ module Granblue
|
||||||
def transform
|
def transform
|
||||||
Rails.logger.info "[TRANSFORM] Starting SummonTransformer#transform"
|
Rails.logger.info "[TRANSFORM] Starting SummonTransformer#transform"
|
||||||
|
|
||||||
# Validate that input data is a Hash
|
|
||||||
unless data.is_a?(Hash)
|
unless data.is_a?(Hash)
|
||||||
Rails.logger.error "[TRANSFORM] Invalid summon data structure"
|
Rails.logger.error "[TRANSFORM] Invalid summon data structure"
|
||||||
Rails.logger.error "[TRANSFORM] Data class: #{data.class}"
|
Rails.logger.error "[TRANSFORM] Data class: #{data.class}"
|
||||||
return []
|
return []
|
||||||
end
|
end
|
||||||
|
|
||||||
summons = []
|
# Determine the maximum index from the keys (assumed to be numeric strings).
|
||||||
# Process each summon in the data
|
max_index = data.keys.map(&:to_i).max || 0
|
||||||
data.each_value do |summon_data|
|
# Pre-allocate an array so that key "1" ends up at index 0, etc.
|
||||||
|
summons = Array.new(max_index)
|
||||||
|
|
||||||
|
# Process keys sorted numerically.
|
||||||
|
data.keys.sort_by(&:to_i).each do |key|
|
||||||
|
summon_data = data[key]
|
||||||
Rails.logger.debug "[TRANSFORM] Processing summon: #{summon_data['master']['name'] if summon_data['master']}"
|
Rails.logger.debug "[TRANSFORM] Processing summon: #{summon_data['master']['name'] if summon_data['master']}"
|
||||||
|
|
||||||
# Extract master and parameter data
|
|
||||||
master, param = get_master_param(summon_data)
|
master, param = get_master_param(summon_data)
|
||||||
unless master && param
|
unless master && param
|
||||||
Rails.logger.debug "[TRANSFORM] Skipping summon - missing master or param data"
|
Rails.logger.debug "[TRANSFORM] Skipping summon - missing master or param data"
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
# Build base summon hash with required attributes
|
# Build the base summon hash.
|
||||||
summon = {
|
summon = {
|
||||||
name: master['name'], # Summon's display name
|
name: master['name'],
|
||||||
id: master['id'], # Unique identifier
|
id: master['id'],
|
||||||
uncap: param['evolution'].to_i # Current uncap level
|
uncap: param['evolution'].to_i
|
||||||
}
|
}
|
||||||
|
|
||||||
Rails.logger.debug "[TRANSFORM] Base summon data: #{summon}"
|
# Calculate and add transcendence level.
|
||||||
|
level = param['level'].to_i
|
||||||
|
summon[:transcend] = calculate_transcendence_level(level)
|
||||||
|
|
||||||
# Add transcendence level for highly uncapped summons
|
# Mark quick summon status if this summon matches quick_summon_id.
|
||||||
if summon[:uncap] > 5
|
|
||||||
level = param['level'].to_i
|
|
||||||
trans = calculate_transcendence_level(level)
|
|
||||||
summon[:transcend] = trans
|
|
||||||
Rails.logger.debug "[TRANSFORM] Added transcendence level: #{trans}"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Mark quick summon status if this summon matches quick_summon_id
|
|
||||||
if @quick_summon_id && param['id'].to_s == @quick_summon_id.to_s
|
if @quick_summon_id && param['id'].to_s == @quick_summon_id.to_s
|
||||||
summon[:qs] = true
|
summon[:qs] = true
|
||||||
Rails.logger.debug "[TRANSFORM] Marked as quick summon"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
summons << summon
|
# Include subaura (sub_skill) information if present.
|
||||||
|
if summon_data['sub_skill'].is_a?(Hash) && summon_data['sub_skill']['name']
|
||||||
|
summon[:sub_aura] = summon_data['sub_skill']['name']
|
||||||
|
end
|
||||||
|
|
||||||
|
# Place the summon in the proper 0-indexed slot.
|
||||||
|
summons[key.to_i - 1] = summon
|
||||||
Rails.logger.info "[TRANSFORM] Successfully processed summon #{summon[:name]}"
|
Rails.logger.info "[TRANSFORM] Successfully processed summon #{summon[:name]}"
|
||||||
end
|
end
|
||||||
|
|
||||||
Rails.logger.info "[TRANSFORM] Completed processing #{summons.length} summons"
|
Rails.logger.info "[TRANSFORM] Completed processing #{summons.compact.length} summons"
|
||||||
summons
|
summons.compact
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
||||||
|
|
@ -20,112 +20,6 @@ end
|
||||||
RSpec.describe DummyQueryClass do
|
RSpec.describe DummyQueryClass do
|
||||||
let(:dummy) { DummyQueryClass.new }
|
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
|
describe '#remixed_name' do
|
||||||
context 'when current_user is present' do
|
context 'when current_user is present' do
|
||||||
let(:user) { build(:user, language: 'en') }
|
let(:user) { build(:user, language: 'en') }
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ RSpec.describe 'Parties API', type: :request do
|
||||||
|
|
||||||
describe 'Preview Management Endpoints' do
|
describe 'Preview Management Endpoints' do
|
||||||
let(:user) { create(:user) }
|
let(:user) { create(:user) }
|
||||||
let!(:party) { create(:party, user: user, shortcode: 'PREV01', element: 'default') }
|
let!(:party) { create(:party, user: user, shortcode: 'PREV01', element: 0) }
|
||||||
let(:headers) do
|
let(:headers) do
|
||||||
{ 'Authorization' => "Bearer #{Doorkeeper::AccessToken.create!(resource_owner_id: user.id, expires_in: 30.days, scopes: 'public').token}",
|
{ 'Authorization' => "Bearer #{Doorkeeper::AccessToken.create!(resource_owner_id: user.id, expires_in: 30.days, scopes: 'public').token}",
|
||||||
'Content-Type' => 'application/json' }
|
'Content-Type' => 'application/json' }
|
||||||
|
|
|
||||||
197
spec/services/party_query_builder_spec.rb
Normal file
197
spec/services/party_query_builder_spec.rb
Normal 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
|
||||||
Loading…
Reference in a new issue