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
|
||||
gem 'newrelic_rpm'
|
||||
|
||||
# The Sentry SDK for Rails
|
||||
gem "stackprof"
|
||||
gem "sentry-ruby"
|
||||
gem "sentry-rails"
|
||||
|
||||
group :doc do
|
||||
gem 'apipie-rails'
|
||||
gem 'sdoc'
|
||||
|
|
|
|||
10
Gemfile.lock
10
Gemfile.lock
|
|
@ -377,6 +377,12 @@ GEM
|
|||
sdoc (2.6.1)
|
||||
rdoc (>= 5.0)
|
||||
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)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (7.3.8)
|
||||
|
|
@ -420,6 +426,7 @@ GEM
|
|||
activesupport (>= 6.1)
|
||||
sprockets (>= 3.0.0)
|
||||
squasher (0.8.0)
|
||||
stackprof (0.2.27)
|
||||
stringio (3.1.2)
|
||||
strscan (3.1.2)
|
||||
thor (1.3.2)
|
||||
|
|
@ -491,6 +498,8 @@ DEPENDENCIES
|
|||
rubocop
|
||||
rufus-scheduler
|
||||
sdoc
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
shoulda-matchers
|
||||
sidekiq
|
||||
simplecov
|
||||
|
|
@ -499,6 +508,7 @@ DEPENDENCIES
|
|||
spring-commands-rspec
|
||||
sprockets-rails
|
||||
squasher (>= 0.6.0)
|
||||
stackprof
|
||||
strscan
|
||||
will_paginate (~> 3.3)
|
||||
|
||||
|
|
|
|||
|
|
@ -105,16 +105,20 @@ module Api
|
|||
|
||||
# Lists parties based on query parameters.
|
||||
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)
|
||||
render_paginated_parties(@parties)
|
||||
end
|
||||
|
||||
# Lists parties favorited by the current user.
|
||||
# GET /api/v1/parties/favorites
|
||||
def favorites
|
||||
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)
|
||||
render_paginated_parties(@parties)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -55,34 +55,39 @@ module Api
|
|||
if @user.nil?
|
||||
render_not_found_response('user')
|
||||
else
|
||||
conditions = build_conditions
|
||||
conditions[:user_id] = @user.id
|
||||
|
||||
favorites_query = "EXISTS (SELECT 1 FROM favorites WHERE favorites.party_id = parties.id AND favorites.user_id = #{current_user&.id || 'NULL'}) AS is_favorited"
|
||||
parties = Party.where(conditions)
|
||||
.where(name_quality)
|
||||
.where(user_quality)
|
||||
.where(original)
|
||||
.where(privacy)
|
||||
.includes(:favorites)
|
||||
.select(Party.arel_table[Arel.star])
|
||||
.select(
|
||||
Arel.sql(favorites_query)
|
||||
base_query = Party.includes(
|
||||
{ raid: :group },
|
||||
:job,
|
||||
:user,
|
||||
:skill0,
|
||||
:skill1,
|
||||
:skill2,
|
||||
:skill3,
|
||||
:guidebook1,
|
||||
:guidebook2,
|
||||
:guidebook3,
|
||||
{ characters: :character },
|
||||
{ weapons: :weapon },
|
||||
{ summons: :summon }
|
||||
)
|
||||
.order(created_at: :desc)
|
||||
.paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE)
|
||||
|
||||
count = Party.where(conditions).count
|
||||
|
||||
# Restrict to parties belonging to the profile owner
|
||||
base_query = base_query.where(user_id: @user.id)
|
||||
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,
|
||||
view: :profile,
|
||||
root: 'profile',
|
||||
parties: parties,
|
||||
meta: {
|
||||
count: count,
|
||||
total_pages: count.to_f / COLLECTION_PER_PAGE > 1 ? (count.to_f / COLLECTION_PER_PAGE).ceil : 1,
|
||||
per_page: COLLECTION_PER_PAGE
|
||||
})
|
||||
meta: { count: count, total_pages: (count.to_f / PartyConstants::COLLECTION_PER_PAGE).ceil, per_page: PartyConstants::COLLECTION_PER_PAGE },
|
||||
current_user: current_user
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -98,6 +103,36 @@ module Api
|
|||
|
||||
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
|
||||
params = request.params
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
# 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 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.
|
||||
def remixed_name(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
|
||||
Rails.logger.info "[TRANSFORM] Starting SummonTransformer#transform"
|
||||
|
||||
# Validate that input data is a Hash
|
||||
unless data.is_a?(Hash)
|
||||
Rails.logger.error "[TRANSFORM] Invalid summon data structure"
|
||||
Rails.logger.error "[TRANSFORM] Data class: #{data.class}"
|
||||
return []
|
||||
end
|
||||
|
||||
summons = []
|
||||
# Process each summon in the data
|
||||
data.each_value do |summon_data|
|
||||
# Determine the maximum index from the keys (assumed to be numeric strings).
|
||||
max_index = data.keys.map(&:to_i).max || 0
|
||||
# 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']}"
|
||||
|
||||
# Extract master and parameter data
|
||||
master, param = get_master_param(summon_data)
|
||||
unless master && param
|
||||
Rails.logger.debug "[TRANSFORM] Skipping summon - missing master or param data"
|
||||
next
|
||||
end
|
||||
|
||||
# Build base summon hash with required attributes
|
||||
# Build the base summon hash.
|
||||
summon = {
|
||||
name: master['name'], # Summon's display name
|
||||
id: master['id'], # Unique identifier
|
||||
uncap: param['evolution'].to_i # Current uncap level
|
||||
name: master['name'],
|
||||
id: master['id'],
|
||||
uncap: param['evolution'].to_i
|
||||
}
|
||||
|
||||
Rails.logger.debug "[TRANSFORM] Base summon data: #{summon}"
|
||||
|
||||
# Add transcendence level for highly uncapped summons
|
||||
if summon[:uncap] > 5
|
||||
# Calculate and add transcendence level.
|
||||
level = param['level'].to_i
|
||||
trans = calculate_transcendence_level(level)
|
||||
summon[:transcend] = trans
|
||||
Rails.logger.debug "[TRANSFORM] Added transcendence level: #{trans}"
|
||||
end
|
||||
summon[:transcend] = calculate_transcendence_level(level)
|
||||
|
||||
# Mark quick summon status if this summon matches quick_summon_id
|
||||
# Mark quick summon status if this summon matches quick_summon_id.
|
||||
if @quick_summon_id && param['id'].to_s == @quick_summon_id.to_s
|
||||
summon[:qs] = true
|
||||
Rails.logger.debug "[TRANSFORM] Marked as quick summon"
|
||||
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]}"
|
||||
end
|
||||
|
||||
Rails.logger.info "[TRANSFORM] Completed processing #{summons.length} summons"
|
||||
summons
|
||||
Rails.logger.info "[TRANSFORM] Completed processing #{summons.compact.length} summons"
|
||||
summons.compact
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -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') }
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ RSpec.describe 'Parties API', type: :request do
|
|||
|
||||
describe 'Preview Management Endpoints' do
|
||||
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
|
||||
{ 'Authorization' => "Bearer #{Doorkeeper::AccessToken.create!(resource_owner_id: user.id, expires_in: 30.days, scopes: 'public').token}",
|
||||
'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