Skip to content
47 changes: 47 additions & 0 deletions app/controllers/concerns/filterable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true

# A concern to handle filtering for controllers.
# Usage: Include this concern in your controller and call `apply_filters` on your query.
module Filterable
extend ActiveSupport::Concern

# Applies filters to a query based on the given filter parameters.
# @param query [ActiveRecord::Relation] The query to filter.
# @param filters [Hash] A hash of filter parameters (e.g., { rank: 'genus', status: 15 }).
# @return [ActiveRecord::Relation] The filtered query.
def apply_filters(query, filters)
filters.each do |key, value|
next if value.nil?
query = query.where(key => value)
end
query
end

# Applies sorting to a query based on the given sort parameters.
# @param query [ActiveRecord::Relation] The query to sort.
# @param sort_by [String, nil] The field to sort by (defaults to params[:sort]).
# @param sort_direction [String, nil] The direction to sort ('asc' or 'desc').
# @return [ActiveRecord::Relation] The sorted query.
def apply_sort(query, sort_by: nil, sort_direction: nil)
sort_by ||= params[:sort]
sort_direction ||= params[:direction] || 'asc'

return query unless sort_by.present?

# Handle special sorting cases (e.g., 'citations' for Name model)
case sort_by.to_s.downcase
when 'citations'
query.left_joins(:publication_names).group(:id).order("COUNT(publication_names.id) #{sort_direction.upcase}")
when 'date'
# Use validated_at if available, otherwise fall back to created_at
if query.model.column_names.include?('validated_at')
query.order("validated_at #{sort_direction.upcase}")
else
query.order("created_at #{sort_direction.upcase}")
end
else
# Default sorting by the given field
query.order("#{sort_by} #{sort_direction.upcase}")
end
end
end
76 changes: 76 additions & 0 deletions app/controllers/concerns/name_filterable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# frozen_string_literal: true

# A concern to handle Name-specific filtering and sorting for controllers.
# Usage: Include this concern in your controller and call `apply_name_filters` on your query.
module NameFilterable
extend ActiveSupport::Concern

included do
# Default filters for Name model
def name_status_filters
{
'public' => Name.public_status,
'automated' => 0,
'seqcode' => 15,
'icnp' => 20,
'icnafp' => 25,
'valid' => Name.valid_status
}
end

# Maps status strings to their corresponding integer values.
# @param status [String] The status string (e.g., 'SeqCode', 'ICNP').
# @return [Integer, Array<Integer>] The status value(s).
def map_status_to_value(status)
status = status.to_s.downcase
status = 'icnafp' if status == 'icn'
name_status_filters[status] || status
end
end

# Applies Name-specific filters to a query.
# @param query [ActiveRecord::Relation] The query to filter.
# @param filters [Hash] A hash of filter parameters (e.g., { rank: 'genus', status: 'SeqCode' }).
# @return [ActiveRecord::Relation] The filtered query.
def apply_name_filters(query, filters)
filters.each do |key, value|
next if value.nil?

case key.to_sym
when :status
query = query.where(status: map_status_to_value(value))
when :rank
query = query.where(rank: value)
when :redirect
query = query.where(redirect: value)
else
query = query.where(key => value)
end
end
query
end

# Applies Name-specific sorting to a query.
# @param query [ActiveRecord::Relation] The query to sort.
# @param sort_by [String, nil] The field to sort by (defaults to params[:sort]).
# @return [ActiveRecord::Relation] The sorted query.
def apply_name_sort(query, sort_by: nil)
sort_by ||= params[:sort] || 'date'

case sort_by.to_s.downcase
when 'date'
# Use validated_at for SeqCode-validated names, otherwise created_at
if params[:status] == 'SeqCode' || params[:status] == 'seqcode'
query.order(validated_at: :desc)
else
query.order(created_at: :desc)
end
when 'citations'
query.left_joins(:publication_names).group(:id).order('COUNT(publication_names.id) DESC')
when 'alphabetically'
query.order(name: :asc)
else
query.order(sort_by => :asc)
end
end
end
25 changes: 25 additions & 0 deletions app/controllers/concerns/paginatable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

# A concern to handle pagination for controllers.
# Usage: Include this concern in your controller and call `paginate` on your query.
module Paginatable
extend ActiveSupport::Concern

included do
# Default pagination parameters
def default_per_page
30
end
end

# Paginates a query with the given parameters.
# @param query [ActiveRecord::Relation] The query to paginate.
# @param page [Integer, nil] The page number (defaults to params[:page]).
# @param per_page [Integer, nil] The number of items per page (defaults to default_per_page).
# @return [ActiveRecord::Relation] The paginated query.
def paginate(query, page: nil, per_page: nil)
page ||= params[:page]
per_page ||= default_per_page
query.paginate(page: page, per_page: per_page)
end
end
1 change: 0 additions & 1 deletion app/controllers/names_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -684,5 +684,4 @@ def add_automatic_correspondence(message)
user: current_user, name: @name
).save
end

end
71 changes: 71 additions & 0 deletions app/services/name/fuzzy_search.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# frozen_string_literal: true

module Name
# Service object to handle fuzzy search for names.
# This encapsulates the logic for finding similar names using
# PostgreSQL's similarity functions.
class FuzzySearch
# Performs a fuzzy search for names similar to the given query.
# @param query [String] The search query.
# @param method [Symbol] The search method (:similarity or :levenshtein).
# @param threshold [Float, Integer] The threshold for matching.
# @param limit [Integer] The maximum number of results to return.
# @param selection [Symbol, ActiveRecord::Relation] The selection of names to search.
# Can be :all_valid, :all_public, :valid_genera, :public_genera, or a custom query.
# @return [ActiveRecord::Relation] The matching names.
def self.call(
query,
method: :similarity,
threshold: nil,
limit: 10,
selection: :all_valid
)
return unless ActiveRecord::Base.connection.adapter_name == 'PostgreSQL'

selection = resolve_selection(selection)
clean_query = ActiveRecord::Base.connection.quote(query)

case method.to_sym
when :similarity
perform_similarity_search(selection, clean_query, threshold || 0.7, limit)
when :levenshtein
perform_levenshtein_search(selection, clean_query, threshold || 2, limit)
else
raise ArgumentError, "Unsupported fuzzy match method: #{method}"
end
end

private_class_method def self.resolve_selection(selection)
case selection
when :all_valid
Name.all_valid
when :all_public
Name.all_public
when :valid_genera
Name.all_valid.where(rank: :genus)
when :public_genera
Name.all_public.where(rank: :genus)
when ActiveRecord::Relation
selection
else
raise ArgumentError, "Unsupported selection: #{selection}"
end
end

private_class_method def self.perform_similarity_search(selection, query, threshold, limit)
selection
.select("id, name, similarity(name, #{query}) AS score")
.where('similarity(name, ?) > ?', query, threshold)
.order('score DESC')
.limit(limit)
end

private_class_method def self.perform_levenshtein_search(selection, query, threshold, limit)
selection
.select("id, name, levenshtein(name, #{query}) AS score")
.where('levenshtein(name, ?) <= ?', query, threshold)
.order('score ASC')
.limit(limit)
end
end
end
27 changes: 27 additions & 0 deletions app/services/name/type_resolver.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

module Name
# Service object to resolve the nomenclatural type for a name.
# This encapsulates the logic for determining the type_of_type value
# and handling edge cases.
class TypeResolver
# Resolves the nomenclatural type class for a given object.
# @param object [Object, nil] The object to resolve (e.g., a Name, Genome, or Strain).
# @return [String] The resolved type_of_type value, or 'unknown' if unresolved.
def self.resolve(object)
return 'unknown' if object.nil?
return object.type_of_type if object.respond_to?(:type_of_type)
'unknown'
end

# Resolves the nomenclatural type class for a given object, with additional context.
# @param object [Object, nil] The object to resolve.
# @param context [Hash] Additional context (e.g., { fallback: 'Name' }).
# @return [String] The resolved type_of_type value.
def self.resolve_with_context(object, context = {})
resolved = resolve(object)
return resolved unless resolved == 'unknown' && context[:fallback].present?
context[:fallback]
end
end
end
17 changes: 17 additions & 0 deletions db/migrate/20260511100000_add_indexes_to_names.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class AddIndexesToNames < ActiveRecord::Migration[6.1]
def change
# Add indexes for frequently queried columns
add_index :names, :rank
add_index :names, :status
add_index :names, :redirect_id
add_index :names, :created_by_id
add_index :names, :validated_by_id
add_index :names, :priority_date
add_index :names, :nomenclatural_type_type
add_index :names, :nomenclatural_type_id

# Composite indexes for common query patterns
add_index :names, [:rank, :status]
add_index :names, [:status, :priority_date]
end
end
12 changes: 11 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 2026_05_04_121446) do
ActiveRecord::Schema.define(version: 2026_05_11_100000) do

# These are extensions that must be enabled in order to support this database
enable_extension "fuzzystrmatch"
Expand Down Expand Up @@ -255,14 +255,24 @@
t.string "wikidata_item"
t.integer "basonym_id"
t.string "wikispecies_entry"
t.index ["created_by_id"], name: "index_names_on_created_by_id"
t.index ["genome_id"], name: "index_names_on_genome_id"
t.index ["name"], name: "index_names_on_name", unique: true
t.index ["name"], name: "index_names_on_name_trgm", opclass: :gin_trgm_ops, using: :gin
t.index ["name_order"], name: "index_names_on_name_order"
t.index ["nomenclatural_type_id"], name: "index_names_on_nomenclatural_type_id"
t.index ["nomenclatural_type_type", "nomenclatural_type_id"], name: "index_names_on_nomenclatural_type"
t.index ["nomenclatural_type_type"], name: "index_names_on_nomenclatural_type_type"
t.index ["parent_id"], name: "index_names_on_parent_id"
t.index ["priority_date"], name: "index_names_on_priority_date"
t.index ["rank", "status"], name: "index_names_on_rank_and_status"
t.index ["rank"], name: "index_names_on_rank"
t.index ["redirect_id"], name: "index_names_on_redirect_id"
t.index ["register_id"], name: "index_names_on_register_id"
t.index ["status", "priority_date"], name: "index_names_on_status_and_priority_date"
t.index ["status"], name: "index_names_on_status"
t.index ["tutorial_id"], name: "index_names_on_tutorial_id"
t.index ["validated_by_id"], name: "index_names_on_validated_by_id"
end

create_table "notifications", force: :cascade do |t|
Expand Down
Loading
Loading