Class: Search

Inherits:
ApplicationRecord show all
Defined in:
app/models/search.rb

Overview

Single-table-inheritance base class for every saved-query type in
the CRM (e.g. CustomerSearch, OrderSearch, etc.). Holds the user-
editable bundle of query params, selected columns, sort, pagination,
and pinning, and exposes a Ransack-backed query result via
QueryTemplate.

Subclasses configure themselves with default_selected_columns,
default_sort_columns, and composite_columns so each searchable
entity stays in sync with its underlying ActiveRecord model.

Defined Under Namespace

Classes: ActivityPresenter, ActivityTextPresenter, BudgetPresenter, CertificationPresenter, ContactPresenter, ContactTextPresenter, CouponPresenter, CouponTextPresenter, CreditApplicationPresenter, CreditApplicationTextPresenter, CreditMemoPresenter, CustomerPresenter, CustomerTextPresenter, DeliveryPresenter, DeliveryTextPresenter, EmployeeReviewPresenter, EmployeeReviewTextPresenter, ExchangeRatePresenter, InvoicePresenter, InvoiceSummaryPresenter, InvoiceTextPresenter, ItAssetPresenter, ItemLedgerEntryPresenter, ItemLedgerEntryTextPresenter, ItemPresenter, ItemTextPresenter, LedgerEntryPresenter, LedgerEntryTextPresenter, LocatorRecordPresenter, LocatorRecordTextPresenter, OpportunityPresenter, OpportunitySummaryPresenter, OpportunityTextPresenter, OrderPresenter, OrderTextPresenter, OutgoingPaymentPresenter, PresenterFactory, PresetJobPresenter, PresetJobTextPresenter, ProductCatalogPresenter, ProductCatalogTextPresenter, PurchaseOrderPresenter, PurchaseOrderTextPresenter, QuotePresenter, QuoteTextPresenter, ReceiptPresenter, RmaPresenter, RmaTextPresenter, RoomConfigurationPresenter, RoomConfigurationTextPresenter, SalesCommissionPresenter, ServiceJobPresenter, SupplierItemPresenter, SupplierItemTextPresenter, SupportCasePresenter, SupportCaseTextPresenter, VoucherPresenter

Constant Summary collapse

DISTANCE_SEARCH_KEYS =

Keys consumed by apply_distance_search, apply_customer_cross_reference, and geocoding
in the base class — excluded from the Ransack strict probe across all subclasses.

%w[target_coordinates within_miles within_miles_of
cross_reference_customer_search_id].freeze

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Class Attribute Summary collapse

Instance Attribute Summary collapse

Belongs to collapse

Has many collapse

Delegated Instance Attributes collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Models::Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Class Attribute Details

.composite_columnsObject (readonly)

Returns the value of attribute composite_columns.



41
42
43
# File 'app/models/search.rb', line 41

def composite_columns
  @composite_columns
end

.default_selected_columnsObject (readonly)

Returns the value of attribute default_selected_columns.



41
42
43
# File 'app/models/search.rb', line 41

def default_selected_columns
  @default_selected_columns
end

.default_sort_columnsObject (readonly)

Returns the value of attribute default_sort_columns.



41
42
43
# File 'app/models/search.rb', line 41

def default_sort_columns
  @default_sort_columns
end

.global_favoritesObject (readonly)

Returns the value of attribute global_favorites.



41
42
43
# File 'app/models/search.rb', line 41

def global_favorites
  @global_favorites
end

Instance Attribute Details

#composite_columnsObject (readonly)

Returns the value of attribute composite_columns.



48
49
50
# File 'app/models/search.rb', line 48

def composite_columns
  @composite_columns
end

#pagy_countObject (readonly)

Expose raw pagination metadata for the controller to build a proper Pagy object with request context



50
51
52
# File 'app/models/search.rb', line 50

def pagy_count
  @pagy_count
end

#pagy_limitObject (readonly)

Expose raw pagination metadata for the controller to build a proper Pagy object with request context



50
51
52
# File 'app/models/search.rb', line 50

def pagy_limit
  @pagy_limit
end

#pagy_pageObject (readonly)

Expose raw pagination metadata for the controller to build a proper Pagy object with request context



50
51
52
# File 'app/models/search.rb', line 50

def pagy_page
  @pagy_page
end

#sql_select_columnsObject (readonly)

Returns the value of attribute sql_select_columns.



48
49
50
# File 'app/models/search.rb', line 48

def sql_select_columns
  @sql_select_columns
end

Class Method Details

.allowed_role_idsObject



85
86
87
# File 'app/models/search.rb', line 85

def self.allowed_role_ids
  nil
end

.available_output_columnsObject

Return a list of available output columns combining composite columns
and actual view defined columns. This is a class level method
that does not take into consideration dynamically added fields
which only exist in the context of a query instance



230
231
232
# File 'app/models/search.rb', line 230

def self.available_output_columns
  (composite_columns || {}).keys | database_columns
end

.base_search_class_nameObject

Returns a base model view of what we're trying to earch on. e.g Order for OrderSearch



241
242
243
# File 'app/models/search.rb', line 241

def self.base_search_class_name
  name.scan(/(\w+)Search/).join
end

.composite_column(*options) ⇒ Hash

Sets 'virtual' attribute or composite column which will be
rendered in the presenter but require specific columns present
in the select, define these at top of the search class such as
customer: { select: [:customer_full_name, :customer_id]}
in the example above we can specify customer as an output column
which will translate in customer_id and customer_full_name being added
to the select for the query

Parameters:

  • options (Array)

    composite column name followed by an options
    hash

Options Hash (*options):

  • select (Array<Symbol>)

    underlying columns the
    composite column requires in the select

Returns:

  • (Hash)

    the stored composite column options



221
222
223
224
# File 'app/models/search.rb', line 221

def self.composite_column(*options)
  @composite_columns ||= {}
  @composite_columns[options.shift] = options.extract_options!.dup
end

.custom_criteria_keysObject



491
492
493
# File 'app/models/search.rb', line 491

def self.custom_criteria_keys
  []
end

.database_columnsObject

Returns an array of database level columns names as symbols, these are defined by the view resource class
and how its sql view is defined



236
237
238
# File 'app/models/search.rb', line 236

def self.database_columns
  view_resource_klass.column_names.map(&:to_sym)
end

.default_columns(*columns) ⇒ Object

Sets the default columns to be used when creating this search
use default_columns :id, :name, ... etc at head of search class



204
205
206
# File 'app/models/search.rb', line 204

def self.default_columns(*columns)
  @default_selected_columns = columns
end

.default_sort(*columns) ⇒ Object

Sets the default sort to be used when creating this search
use default_columns :id, :name, ... etc at head of search class



198
199
200
# File 'app/models/search.rb', line 198

def self.default_sort(*columns)
  @default_sort_columns = columns
end

.favoritesActiveRecord::Relation<Search>

A relation of Searches that are favorites. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Search>)

See Also:



80
# File 'app/models/search.rb', line 80

scope :favorites, -> { where(persist: true) }

.friendly_column_name(column_name) ⇒ Object



173
174
175
# File 'app/models/search.rb', line 173

def self.friendly_column_name(column_name)
  view_resource_klass.human_attribute_name(column_name)
end

.global_favorite(*options) ⇒ Hash

Registers a global favorite (saved query template) for this search
class.

Parameters:

  • options (Array)

    favorite key followed by an options hash

Options Hash (*options):

  • title (String)

    display title

  • query_params (Hash)

    query params of the favorite

  • selected_columns (Array)

    columns of the favorite

  • sort_column (Symbol, Array)

    sort column

  • sort_columns (Array)

    sort columns

  • sort_direction (String)

    sort direction

  • set_limit (Integer)

    row limit

  • pinned (Boolean)

    pinned flag

  • auto_count (Boolean)

    auto count flag

Returns:

  • (Hash)

    the stored favorite options



191
192
193
194
# File 'app/models/search.rb', line 191

def self.global_favorite(*options)
  @global_favorites ||= {}
  @global_favorites[options.shift] = options.extract_options!.dup
end

.has_role_for_search?(role_ids) ⇒ Boolean

Returns:

  • (Boolean)


89
90
91
92
93
# File 'app/models/search.rb', line 89

def self.has_role_for_search?(role_ids)
  return true if allowed_role_ids.nil?

  role_ids.intersect?(allowed_role_ids)
end

.instantiate_query_template(template_key = nil, extra_options = nil) ⇒ Object



132
133
134
135
136
# File 'app/models/search.rb', line 132

def self.instantiate_query_template(template_key = nil, extra_options = nil)
  params = global_favorites.try(:[], template_key) || {}
  params = params.deep_merge(extra_options) if extra_options
  QueryTemplate.new(self, params)
end

.main_resource_classObject



148
149
150
# File 'app/models/search.rb', line 148

def self.main_resource_class
  name.match(/(.+)Search/)[1]
end

.main_resource_tableObject



152
153
154
# File 'app/models/search.rb', line 152

def self.main_resource_table
  main_resource_class.tableize
end

.mass_actions_for_selectObject



118
119
120
121
# File 'app/models/search.rb', line 118

def self.mass_actions_for_select
  opts = instance_methods + superclass.instance_methods
  opts.grep(/^mass_/).sort.map { |m| [m.to_s.humanize, m] }.uniq
end

.maximum_unpersisted_queriesObject

Actions that must run synchronously in the controller because they render



124
125
126
# File 'app/models/search.rb', line 124

def self.maximum_unpersisted_queries
  5
end

.options(_visible_only = true) ⇒ Object



95
96
97
# File 'app/models/search.rb', line 95

def self.options(_visible_only = true)
  options_classes.map { |search_klass| [search_klass.search_name, search_klass.name] }
end

.options_classesObject



99
100
101
102
103
104
# File 'app/models/search.rb', line 99

def self.options_classes
  list = %i[activity_search contact_search customer_search delivery_search employee_review_search sales_commission_search invoice_search it_asset_search item_search product_catalog_search item_ledger_entry_search
            ledger_entry_search supplier_item_search locator_record_search opportunity_search order_search purchase_order_search credit_memo_search
            quote_search rma_search room_configuration_search support_case_search credit_application_search coupon_search voucher_search outgoing_payment_search receipt_search certification_search]
  list.sort.map { |scn| scn.to_s.classify.constantize }
end

.query_favorites_templatesObject



138
139
140
141
142
# File 'app/models/search.rb', line 138

def self.query_favorites_templates
  (global_favorites || {}).map do |_k, options|
    QueryTemplate.new(self, options)
  end
end

.recent_searches_for_employee_idActiveRecord::Relation<Search>

A relation of Searches that are recent searches for employee id. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Search>)

See Also:



82
# File 'app/models/search.rb', line 82

scope :recent_searches_for_employee_id, ->(employee_id) { where(employee_id: employee_id).order(created_at: :desc) }

.search_nameObject



114
115
116
# File 'app/models/search.rb', line 114

def self.search_name
  name.titleize
end

.search_typeObject



106
107
108
# File 'app/models/search.rb', line 106

def self.search_type
  name.tableize.singularize
end

.search_type_humanizedObject



110
111
112
# File 'app/models/search.rb', line 110

def self.search_type_humanized
  search_type.humanize.titleize
end

.select_sort_columnsObject

Returns available columns for sorting



169
170
171
# File 'app/models/search.rb', line 169

def self.select_sort_columns
  view_resource_klass.select_sort_columns
end

.unpersisted_queries_quote_reached?(search_type, employee_id) ⇒ Boolean

Returns:

  • (Boolean)


128
129
130
# File 'app/models/search.rb', line 128

def self.unpersisted_queries_quote_reached?(search_type, employee_id)
  Search.where(employee_id: employee_id).where(type: search_type).where('persist IS NULL or NOT persist').count >= maximum_unpersisted_queries
end

.view_resource_classObject



156
157
158
# File 'app/models/search.rb', line 156

def self.view_resource_class
  "View#{name.match(/(.+)Search/)[1]}"
end

.view_resource_klassObject



160
161
162
# File 'app/models/search.rb', line 160

def self.view_resource_klass
  view_resource_class.constantize
end

.view_resource_tableObject



164
165
166
# File 'app/models/search.rb', line 164

def self.view_resource_table
  view_resource_class.tableize
end

.visible?Boolean

Returns:

  • (Boolean)


144
145
146
# File 'app/models/search.rb', line 144

def self.visible?
  true
end

.with_search_resultsActiveRecord::Relation<Search>

A relation of Searches that are with search results. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Search>)

See Also:



81
# File 'app/models/search.rb', line 81

scope :with_search_results, -> { where('exists(select 1 from search_results sr where sr.search_id = searches.id)') }

Instance Method Details

#all_composite_columnsObject

Returns a combination of instance level and class level composite columns
instance levels are defined in the query build mechanism and unit to this
search instance. class level are defined in the search class header via composite_column:



308
309
310
# File 'app/models/search.rb', line 308

def all_composite_columns
  (self.class.composite_columns || {}).merge(composite_columns || {})
end

#append_custom_column(*options) ⇒ void

This method returns an undefined value.

Sometimes the criteria will append a custom field to the query that is
not part of the initial view, we need to make this field part of the output
and also the custom select
pass options to this function as you would composite_column, e.g
:distance, select: '(select max(x) as distance)'

Parameters:

  • options (Array)

    column name followed by an options hash

Options Hash (*options):

  • select (Array<Symbol>, String)

    underlying select
    columns or SQL for the custom column



346
347
348
349
350
# File 'app/models/search.rb', line 346

def append_custom_column(*options)
  column_name = options.first.to_s
  composite_column(*options)
  append_ouput_column(column_name)
end

#append_ouput_column(column_name) ⇒ Object



364
365
366
367
# File 'app/models/search.rb', line 364

def append_ouput_column(column_name)
  selected_columns.delete(column_name)
  selected_columns << column_name
end

#append_to_sql_select_columns(*args) ⇒ Object (protected)



954
955
956
# File 'app/models/search.rb', line 954

def append_to_sql_select_columns(*args)
  @sql_select_columns += args
end

#applicable_sort_termsObject

Filters the saved sort columns down to those that are in the query output
and can be expressed as a SQL ORDER BY term (see #sortable_column?). The
direction is allow-listed to ASC/DESC before it reaches the raw Arel.sql
ORDER BY in #apply_sort; anything else (or a missing direction) defaults to
ASC, so a crafted ?direction= param can't be interpolated into the query.



667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
# File 'app/models/search.rb', line 667

def applicable_sort_terms
  sorts = []
  sort_columns.each do |sort_column_combined|
    sort_column_fq, sort_order = sort_column_combined.split
    # Sort columns can be fully qualified (table_name.sort_column) here we
    # detect this and assign accordingly
    sort_column, = sort_column_fq.split('.').reverse
    # available_output_columns never contains the fully qualified version
    # So we check against the sort_column name
    next unless available_output_columns.include?(sort_column.to_sym) && sortable_column?(sort_column)

    direction = sort_order.to_s.upcase
    direction = 'ASC' unless %w[ASC DESC].include?(direction)
    sorts << [sort_column_fq, direction]
  end
  sorts
end

#apply_array_match(results, param, column = nil, nil_value = nil, any_value = nil) ⇒ Object (protected)

param: an array of values to test against
nil_value: of the array of values, which one represent a database null, e.g. "Open" might translate to records having result null
any_value: of the array of values, which means 'any value', note that this trumps nil_value



931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
# File 'app/models/search.rb', line 931

def apply_array_match(results, param, column = nil, nil_value = nil, any_value = nil)
  if query_params[param].present?
    column ||= param
    val_ids = query_params[param].dup.compact.uniq
    results.unscoped # Start with a blank
    # Any value just checks for non nil end of story
    if any_value && val_ids.include?(any_value)
      results = results.where.not(column => nil)
    else
      # nil_value might be a string expressing null so we substitute, e.g. "not blank"
      val_ids << nil if nil_value && val_ids.delete(nil_value)
      # Block given we might add more values to check on
      val_ids = yield(val_ids) if block_given?
      results = results.where(column => val_ids) if val_ids.present?
    end
  end
  results
end

#apply_criteriaObject



464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
# File 'app/models/search.rb', line 464

def apply_criteria
  criteria_klass = self.class.view_resource_klass

  # Strict probe: build the search object (no SQL) with unknown conditions
  # disallowed.  If any stored param key is stale/renamed, Ransack raises
  # InvalidSearchError.  We report it to AppSignal and continue — the real
  # query below uses the default lenient mode so the user is never disrupted.
  # Custom keys handled by apply_custom_criteria and apply_distance_search are
  # excluded from the probe to avoid false positives.
  begin
    probe_params = ransack_probe_params
    criteria_klass.ransack(probe_params, ignore_unknown_conditions: false)
  rescue Ransack::InvalidSearchError => e
    Appsignal.report_error(e) do |transaction|
      transaction.set_tags(
        search_type: self.class.name,
        search_id: id.to_s,
        query_params: query_params.to_json
      )
    end
  end

  results = criteria_klass.ransack(query_params).result
  results = apply_custom_criteria(results) if respond_to?(:apply_custom_criteria)
  results
end

#apply_customer_cross_reference(results, join_key = nil) ⇒ Object

Apply customer cross reference



529
530
531
532
533
534
535
536
537
538
# File 'app/models/search.rb', line 529

def apply_customer_cross_reference(results, join_key = nil)
  join_key ||= 'customer_id'
  fq_join_key = "#{self.class.view_resource_table}.#{join_key}"
  if query_params[:cross_reference_customer_search_id].present?
    results = results.where(
      "EXISTS(select 1 from search_results src WHERE src.resource_type = 'Customer' and src.resource_id = #{fq_join_key} and src.search_id IN (?))", [query_params[:cross_reference_customer_search_id]].flatten.join(',')
    )
  end
  results
end

#apply_distance_search(results, options = {}) ⇒ ActiveRecord::Relation

Restricts the results to rows within the queried distance of the
target coordinates, adding a distance column.

Parameters:

  • results (ActiveRecord::Relation)
  • options (Hash) (defaults to: {})

Options Hash (options):

  • custom_table_join (String)

    SQL join fragment for
    the table carrying the coordinates

  • custom_table_alias (String)

    alias qualifying the
    lat/lng columns

  • lat_column (String) — default: 'lat'

    latitude column name

  • lng_column (String) — default: 'lng'

    longitude column name

Returns:

  • (ActiveRecord::Relation)


552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
# File 'app/models/search.rb', line 552

def apply_distance_search(results, options = {})
  custom_table_join = options[:custom_table_join]
  custom_table_alias = options[:custom_table_alias]
  target_coordinates = query_params[:target_coordinates]
  within_miles = query_params[:within_miles].present? ? query_params[:within_miles].to_i : 25

  # custom_table_join ||= "INNER JOIN addresses on addresses.id = address_id"
  # custom_table_alias ||= "addresses"
  lat_column = options[:lat_column] || 'lat'
  lng_column = options[:lng_column] || 'lng'

  lat_column = "#{custom_table_alias}.#{lat_column}" if custom_table_alias
  lng_column = "#{custom_table_alias}.#{lng_column}" if custom_table_alias

  lat, lng = target_coordinates
  if target_coordinates.present? && lat && lng
    origin_lat = Math.deg2rad(lat)
    origin_lng = Math.deg2rad(lng)
    distance_formula = <<-EOS
    (ACOS(COS(#{origin_lat})*COS(#{origin_lng})*COS(RADIANS(#{lat_column}))*COS(RADIANS(#{lng_column}))+
    COS(#{origin_lat})*SIN(#{origin_lng})*COS(RADIANS(#{lat_column}))*SIN(RADIANS(#{lng_column}))+
    SIN(#{origin_lat})*SIN(RADIANS(#{lat_column})))*3963)
    EOS
    results = results.joins(custom_table_join)
    prepend_custom_column(:distance, raw: "#{distance_formula} as distance", data_type: :float)
    results = results.where("(#{distance_formula}) < ?", within_miles)
    set_sort_term(:distance) if sort_columns.blank?
  else # reset because it won't be there
    sort_columns.delete('distance')
  end
  results
end

#apply_select(results) ⇒ Object



501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
# File 'app/models/search.rb', line 501

def apply_select(results)
  # Builds an array of sql select statements based on an array of column name, translating any
  # composite key to their required actual column
  sql_select_columns = []
  sql_select_columns << if new_record?
                          'false as selected_attr'
                        else
                          "(EXISTS(select id from search_results sr where sr.resource_id = #{self.class.view_resource_table}.id and sr.search_id = #{id})) as selected_attr"
                        end

  columns = [:id]
  validated_selected_columns.each do |column|
    if (composite_attrs = all_composite_columns[column.to_sym])
      sql_select_columns |= [composite_attrs[:raw]].flatten.compact
      columns |= [composite_attrs[:select]].flatten.compact
      # create_custom_column(results, column.to_s, composite_attrs[:data_type])
    elsif self.class.view_resource_klass.column_names.include?(column.to_s)
      columns |= [column]
    else # column does not exist, remove
      selected_columns.delete(column.to_s)
    end
  end
  sql_select_columns += columns.compact.uniq.map { |column| "#{self.class.view_resource_table}.#{column}" }

  results.select(sql_select_columns.join(','))
end

#apply_sort(results) ⇒ Object

Apply sort columns to a result set, and transforms composite column to their
consituents



687
688
689
690
691
692
693
694
695
696
697
698
699
# File 'app/models/search.rb', line 687

def apply_sort(results)
  return results if sort_columns.blank?

  sorts = applicable_sort_terms
  if sorts.present?
    sort_sql = Arel.sql(sorts.map { |(column_name, sort_direction)| get_sort_term(column_name, sort_direction) }.join(','))
    results = results.reorder(sort_sql)
  else
    # Auto fix bad sort columns
    update_column(:sort_columns, [])
  end
  results
end

#available_eventsObject

Assuming our resources implement state machines, retrieves a list of available events that can be triggered



896
897
898
899
900
901
902
# File 'app/models/search.rb', line 896

def available_events
  events = []
  search_results.includes(:resource).filter_map(&:resource).uniq.each do |resource|
    events += resource.state_events.select { |evt| resource.send(:"can_#{evt}?") }
  end
  events.compact.uniq.sort.map { |evt| [evt.to_s.titleize, evt] }
end

#available_output_columnsObject

Instance level available output column check, this takes into consideration
Dynamic attributes added as the select query is built



286
287
288
# File 'app/models/search.rb', line 286

def available_output_columns
  (composite_columns || {}).keys | self.class.available_output_columns
end

#cleanup_search_resultsObject (protected)



962
963
964
# File 'app/models/search.rb', line 962

def cleanup_search_results
  search_results.delete_all if saved_change_to_query_params?
end

#composite_column(*options) ⇒ Hash

Similar to class version but affects the instance variable list of composite
columns. When the query is built both instance and class level composite
columns are combined. THis allows for instance dynamic attributes to be added
such as a distance column when proximity critiera are passed in

Parameters:

  • options (Array)

    composite column name followed by an options
    hash

Options Hash (*options):

  • select (Array<Symbol>)

    underlying columns the
    composite column requires in the select

Returns:

  • (Hash)

    the stored composite column options



300
301
302
303
# File 'app/models/search.rb', line 300

def composite_column(*options)
  @composite_columns ||= {}
  @composite_columns[options.shift] = options.extract_options!.dup
end

#discard_excessObject (protected)



972
973
974
975
# File 'app/models/search.rb', line 972

def discard_excess
  excess_searches = Search.where(employee_id: employee_id).where(type: self.class.name).where('persist IS NULL or NOT persist').order(id: :desc).offset(self.class.maximum_unpersisted_queries)
  excess_searches.destroy_all
end

#effective_nameObject



383
384
385
# File 'app/models/search.rb', line 383

def effective_name
  name.presence || human_query_params.presence || 'Unfiltered Search'
end

#effective_short_nameObject

def create_custom_column(results, column_name, type)
pgc = ActiveRecord::ConnectionAdapters::PostgreSQLColumn.new(column_name, nil, type)
results.columns << pgc unless results.columns.include?(pgc)
results.column_names << column_name unless results.column_names.include?(column_name)
end



379
380
381
# File 'app/models/search.rb', line 379

def effective_short_name
  name || "#{self.class.name.humanize.titleize} #{created_at.to_fs(:crm_default)}"
end

#employeeEmployee

Returns:

See Also:



62
# File 'app/models/search.rb', line 62

belongs_to :employee, inverse_of: :searches, optional: true

#enqueue_navbar_pinned_refreshObject

Schedules a single coalesced Turbo Stream replace of this employee's
pinned-items navbar badge. Safe to call from any code path that may have
changed the pinned set — bulk SQL helpers, AR callbacks, controllers.



777
778
779
780
781
# File 'app/models/search.rb', line 777

def enqueue_navbar_pinned_refresh
  return if employee_id.blank?

  CrmNavbarRefreshWorker.schedule(user_id: employee_id, badge: :pinned)
end

#fast_countObject



408
409
410
411
412
# File 'app/models/search.rb', line 408

def fast_count
  results = apply_criteria
  results.select(selected_columns.first)
  results.count
end

#get_pinned_resultsObject



398
399
400
# File 'app/models/search.rb', line 398

def get_pinned_results
  pinned_query.includes(:resource).order(:position) # ensures we only show resource that still exists
end

#get_sort_term(column_name, sort_direction = 'ASC') ⇒ Object

Generate the SQL sort term for a given column,
it checks if the column name is a composite column with a custom select, in which
case replace the sort term



588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
# File 'app/models/search.rb', line 588

def get_sort_term(column_name, sort_direction = 'ASC')
  sort_terms = []
  if (composite_attrs = all_composite_columns[column_name.to_sym])
    if composite_attrs[:order]
      sort_terms << composite_attrs[:order]
    elsif composite_attrs[:select]
      sort_terms = composite_attrs[:select]
    else
      sort_terms << column_name
    end
  else
    sort_terms << if self.class.view_resource_table == column_name.split('.')[0]
                    column_name.to_s
                  else
                    "#{self.class.view_resource_table}.#{column_name}"
                  end
  end
  sort_terms.map { |term| "#{term} #{sort_direction}" }
end

#human_query_paramsObject



387
388
389
390
391
# File 'app/models/search.rb', line 387

def human_query_params
  query_params.filter_map { |k, v| v.present? ? "#{k.humanize}: #{v}" : nil }.join(' and ')
rescue StandardError
  nil
end

#instantiate_resource_updater(params = {}) ⇒ Object (protected)



950
951
952
# File 'app/models/search.rb', line 950

def instantiate_resource_updater(params = {})
  ResourceUpdater.new(self, params)
end

#limit_optionsObject

if you want to override the available result set size you can do so in your specialized class.



394
395
396
# File 'app/models/search.rb', line 394

def limit_options
  [10, 25, 50, 100, 200, 500, 1000, 5000]
end

#mass_export(params = {}, cur_user = nil, path = nil) ⇒ Object

Mass Export



788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
# File 'app/models/search.rb', line 788

def mass_export(params = {}, cur_user = nil, path = nil)
  params ||= {}
  file_format = params[:file_format].presence || 'CSV'
  file_extension = file_format.downcase
  file_name = "export_#{id}.#{file_extension}"
  directory_path = path || Rails.root.join(Rails.application.config.x.temp_storage_path.to_s, 'users', cur_user ? cur_user.id.to_s : '')
  FileUtils.mkdir_p(directory_path)
  file_path = Rails.root.join(directory_path, file_name)

  # Prepare export data
  results = perform(nil, nil, nil, false, 100_000, false)
            .where("exists(select id from search_results sr where sr.search_id = ? and sr.resource_id = #{self.class.view_resource_table}.id)", id)

  search_composite_map = Search.find(id).type.constantize.composite_columns.to_h
  validated_composite_columns = validated_selected_columns.map do |column_name|
    [column_name, search_composite_map[column_name]&.dig(:select, 0) || column_name]
  end

  include_headers = params[:include_headers].to_b
  headers = validated_selected_columns.map { |column_name| self.class.friendly_column_name(column_name) }

  if file_format == 'XLSX'
    export_xlsx(file_path, results, validated_composite_columns, headers, include_headers, params)
  else
    export_csv(file_path, results, validated_composite_columns, headers, include_headers, params)
  end

  { status: :ok, result_file: file_name, result_file_path: file_path }
end

#normalize_for_mass_update(_resource, _params) ⇒ Object

Per-record hook called by SearchResourceUpdateWorker before saving each resource.
Override in subclasses to apply custom normalization or guard logic.
Return false to skip the save and count the record as an error.
Must be public — the worker invokes it from another class (not a valid protected call).



908
909
910
# File 'app/models/search.rb', line 908

def normalize_for_mass_update(_resource, _params)
  true
end

#output_columns_for_selectObject

Renders a select list of possible output columns including all
composite columns, note that we only include class level, permanent composite
columns and not instance level which can disapear



315
316
317
318
319
# File 'app/models/search.rb', line 315

def output_columns_for_select
  # Start with the ones already selected
  columns = validated_selected_columns | self.class.available_output_columns.sort_by { |cn| self.class.friendly_column_name(cn) }
  columns.map { |cn| [self.class.friendly_column_name(cn), cn] }
end

#pagy_from_searchObject

Backward-compatible reader: returns a simple struct with count/page/limit so existing
controller code (pagy(:offset, results, count:, page:, limit:)) keeps working unchanged.



54
55
56
57
58
# File 'app/models/search.rb', line 54

def pagy_from_search
  return nil unless @pagy_count

  Data.define(:count, :page, :limit).new(count: @pagy_count, page: @pagy_page, limit: @pagy_limit)
end

#perform(page = 1, sort = nil, direction = nil, record_results = false, per_page = nil, autosave = true, disable_pagination = false) ⇒ Object



414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
# File 'app/models/search.rb', line 414

def perform(page = 1, sort = nil, direction = nil, record_results = false, per_page = nil, autosave = true, disable_pagination = false)
  results = apply_criteria
  # Count BEFORE applying complex SELECT to avoid SQL syntax errors
  total_count = results.count if !disable_pagination && results.respond_to?(:limit)

  results = apply_select(results)
  set_sort_term(sort, direction)
  results = apply_sort(results)

  if !disable_pagination && results.respond_to?(:limit)
    @pagy_page  = (page || 1).to_i
    @pagy_limit = (per_page || set_limit).to_i
    @pagy_count = total_count
    results = results.limit(@pagy_limit).offset((@pagy_page - 1) * @pagy_limit)
    self.result_set_size = @pagy_count
  else
    @pagy_count = @pagy_page = @pagy_limit = nil
    self.result_set_size = results.size
  end

  if autosave
    save
    if record_results
      search_results.delete_all
      results.find_each do |r|
        search_results.create(resource_type: (begin
          r.class.main_resource_class
        rescue StandardError
          nil
        end) || r.class.to_s, resource_id: r.id)
      end
    end
    results.to_a.compact.each do |r|
      r.class.send(:attr_accessor, :search_selected)
      r.search_selected = if r.respond_to? :selected_attr
                            r.selected_attr
                          else
                            search_results.exists?(resource_type: r.class.name,
                                                   resource_id: r.id)
                          end
    end
  end
  results
end

#perform_selected(selected_columns: nil) ⇒ Object



459
460
461
462
# File 'app/models/search.rb', line 459

def perform_selected(selected_columns: nil)
  self.selected_columns = selected_columns if selected_columns.present?
  perform(nil, nil, nil, false, nil, false, true).where("exists(select id from search_results sr where sr.search_id = ? and sr.resource_id = #{self.class.view_resource_table}.id)", id)
end

#pinned_queryObject

Retrieves the pinned search results, in most case this is suffucient but for speed purpose you might want to override and specialize
e.g. if you pin presenter displays information in associated model you might want to include the associated table



404
405
406
# File 'app/models/search.rb', line 404

def pinned_query
  search_results
end

#prepend_custom_column(*options) ⇒ void

This method returns an undefined value.

Same as append_custom_column except it adds it to the front of the column list.

Parameters:

  • options (Array)

    column name followed by an options hash

Options Hash (*options):

  • select (Array<Symbol>, String)

    underlying select
    columns or SQL for the custom column



358
359
360
361
362
# File 'app/models/search.rb', line 358

def prepend_custom_column(*options)
  column_name = options.first.to_s
  composite_column(*options)
  prepend_output_column(column_name)
end

#prepend_output_column(column_name) ⇒ Object



369
370
371
372
# File 'app/models/search.rb', line 369

def prepend_output_column(column_name)
  selected_columns.delete(column_name) # delete it if present already
  selected_columns.insert(0, column_name)
end

#ransack_probe_paramsObject

Returns the query_params hash with non-Ransack keys stripped for the strict probe.
Subclasses can override to remove additional dynamic keys (e.g. spec_field_* in ItemSearch).



497
498
499
# File 'app/models/search.rb', line 497

def ransack_probe_params
  query_params.except(*DISTANCE_SEARCH_KEYS, *self.class.custom_criteria_keys)
end

#record_list(resource_ids) ⇒ Object



701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
# File 'app/models/search.rb', line 701

def record_list(resource_ids)
  if resource_ids == '*'
    results = apply_criteria
    results = results.except(:select).select "'#{self.class.main_resource_class}', #{self.class.view_resource_table}.id, now(), now(), #{id}"
    # By far the fastest method is straight sql first to wipe all existing
    SearchResult.delete_by(search_id: id)
    # Bulk magic
    sql = %{
      INSERT INTO search_results
        ( resource_type,
          resource_id,
          created_at,
          updated_at,
          search_id )
      #{results.to_sql}
      ON CONFLICT DO NOTHING
    }
    ActiveRecord::Base.lease_connection.execute(sql)
  else
    return if resource_ids.blank?

    search_results = resource_ids.map do |raw_resource_id|
      resource_type, resource_id = raw_resource_id.split('|')
      { resource_type: resource_type, resource_id: resource_id, search_id: id }
    end
    SearchResult.insert_all(search_results) if search_results.any?
  end
  enqueue_navbar_pinned_refresh if pinned
  true
end

#refresh_pinned_resultsObject

Re-runs the saved search criteria as a prune over the user's curated
pin selection: keeps the intersection of (currently-pinned IDs) and
(records that still match the criteria), drops the rest. Never adds new
rows — a user who pinned 5 specific records gets at most 5 back, with
stale ones (e.g. an opportunity that was closed) removed.

Returns the number of stale rows removed.



760
761
762
763
764
765
766
767
768
769
770
771
772
# File 'app/models/search.rb', line 760

def refresh_pinned_results
  main_type  = self.class.main_resource_class
  pinned_ids = search_results.where(resource_type: main_type).pluck(:resource_id)
  return 0 if pinned_ids.empty?

  matching_ids = apply_criteria.except(:select).where(id: pinned_ids).ids
  stale_ids    = pinned_ids - matching_ids
  return 0 if stale_ids.empty?

  search_results.delete_by(resource_type: main_type, resource_id: stale_ids)
  enqueue_navbar_pinned_refresh if pinned
  stale_ids.size
end

#remove_other_pinsObject (protected)



966
967
968
969
970
# File 'app/models/search.rb', line 966

def remove_other_pins
  return unless employee && pinned_changed? && pinned

  employee.searches.where(pinned: true).update_all(pinned: false)
end

#search_nameObject



783
784
785
# File 'app/models/search.rb', line 783

def search_name
  name || self.class.search_name
end

#search_resultsActiveRecord::Relation<SearchResult>

Returns:

See Also:



63
# File 'app/models/search.rb', line 63

has_many   :search_results, inverse_of: :search, dependent: :delete_all

#select_sort_columnsObject

Instance level list of possible sort columns



322
323
324
325
326
# File 'app/models/search.rb', line 322

def select_sort_columns
  # Current sort columns
  current_sort_columns = sort_columns.map { |sc| sc.split&.first&.to_sym }
  available_output_columns | current_sort_columns
end

#select_statementObject (protected)



958
959
960
# File 'app/models/search.rb', line 958

def select_statement
  @sql_select_columns.join(',')
end

#set_defaultsObject



878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
# File 'app/models/search.rb', line 878

def set_defaults
  self.set_limit = 100 if set_limit.blank?
  self.persist = false if persist.blank?
  self.name = nil if name.blank?
  self.selected_columns = (self.class.default_selected_columns.presence || available_output_columns) if selected_columns.nil? || selected_columns.blank?
  # Apply default sort columns
  if sort_columns.blank?
    if self.class.default_sort_columns.present?
      self.sort_columns = (self.class.default_sort_columns & available_output_columns)
    elsif available_output_columns.include?(:created_at)
      # self.sort_columns = ["#{self.class.view_resource_table}.created_at desc"]
      self.sort_columns = ['created_at DESC']
    end
  end
  self.sort_columns ||= []
end

#set_sort_term(set_sort_column, set_sort_direction = nil) ⇒ Object

Given a single sort column applies it and overrides any previous search ordering



609
610
611
612
613
614
615
616
617
618
619
# File 'app/models/search.rb', line 609

def set_sort_term(set_sort_column, set_sort_direction = nil)
  return if set_sort_column.blank?

  set_sort_direction ||= 'ASC'

  self.sort_columns = if available_output_columns.include? set_sort_column.to_sym
                        ["#{set_sort_column.downcase} #{set_sort_direction.downcase}"]
                      else
                        []
                      end
end

#sort_column1Object

BEGIN INSTANCE VARIABLES #



248
249
250
# File 'app/models/search.rb', line 248

def sort_column1
  sort_columns.try(:[], 0)
end

#sort_column1=(val) ⇒ Object



252
253
254
255
256
257
258
# File 'app/models/search.rb', line 252

def sort_column1=(val)
  if val.present?
    self.sort_columns = sort_columns.insert(0, val).compact.uniq
  else
    sort_columns.delete_at(0)
  end
end

#sort_column2Object



260
261
262
# File 'app/models/search.rb', line 260

def sort_column2
  sort_columns.try(:[], 1)
end

#sort_column2=(val) ⇒ Object



264
265
266
267
268
269
270
# File 'app/models/search.rb', line 264

def sort_column2=(val)
  if val.present?
    self.sort_columns = sort_columns.insert(1, val).compact.uniq
  else
    sort_columns.delete_at(1)
  end
end

#sort_column3Object



272
273
274
# File 'app/models/search.rb', line 272

def sort_column3
  sort_columns.try(:[], 2)
end

#sort_column3=(val) ⇒ Object



276
277
278
279
280
281
282
# File 'app/models/search.rb', line 276

def sort_column3=(val)
  if val.present?
    self.sort_columns = sort_columns.insert(2, val).compact.uniq
  else
    sort_columns.delete_at(2)
  end
end

#sort_columns_for_selectObject

Retrieves a list of sort columns suitable for a select



635
636
637
638
639
640
641
642
643
644
# File 'app/models/search.rb', line 635

def sort_columns_for_select
  selection_list = []
  select_sort_columns.each do |sc|
    selection_list << ["#{sc.to_s.humanize} ascending", "#{sc} ASC"]
    selection_list << ["#{sc.to_s.humanize} descending", "#{sc} DESC"]
  end
  # Add composite keys

  selection_list
end

#sort_columns_to_hashObject

Takes all the sort columns specified for a search and returns them as a
hash with a symbolized key being the column name and the value being the sort
direction
e.g. { :primary_sales_rep_name => :DESC, :amount => :ASC }



625
626
627
628
629
630
631
632
# File 'app/models/search.rb', line 625

def sort_columns_to_hash
  return {} if sort_columns.blank?

  sort_columns.each_with_object({}) do |sort_combined, hsh|
    sort_column, sort_direction = sort_combined.split
    hsh[sort_column.to_sym] = (sort_direction || :ASC).to_sym
  end
end

#sortable_column?(column_name) ⇒ Boolean

Whether +column_name+ can appear in a SQL ORDER BY clause. Real database
columns always can. Composite columns can only when they map to concrete
SQL — an explicit :order expression, a :select of real columns, or a :raw
expression (which becomes a SELECT alias the ORDER BY can reference, e.g.
the :distance column added by #apply_distance_search).
"Virtual" composite columns that are assembled by the presenter from
associations (e.g. SupportCaseSearch's :participants / :rooms) have none of
these; ordering by them emits a bare, non-existent identifier and raises
PG::UndefinedColumn (AppSignal #6124), so they are not sortable.

Returns:

  • (Boolean)


655
656
657
658
659
660
# File 'app/models/search.rb', line 655

def sortable_column?(column_name)
  composite_attrs = all_composite_columns[column_name.to_sym]
  return true if composite_attrs.nil?

  composite_attrs[:order].present? || composite_attrs[:select].present? || composite_attrs[:raw].present?
end

#target_geocodingObject (protected)



914
915
916
917
918
919
920
921
922
923
924
925
926
# File 'app/models/search.rb', line 914

def target_geocoding
  return unless query_params

  if query_params[:within_miles_of].present?
    if query_params[:target_coordinates].blank? || (query_params_was[:within_miles_of] != query_params[:within_miles_of])
      logger.info "Attempt geocoding #{query_params[:within_miles_of]}"
      query_params[:target_coordinates] = Geocoder.coordinates(query_params[:within_miles_of])
      errors.add(:query_params, "error, cannot geocode #{query_params[:within_miles_of]}") unless query_params[:target_coordinates]
    end
  else
    query_params.delete(:target_coordinates)
  end
end

#unrecord_list(resource_ids) ⇒ Object



732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
# File 'app/models/search.rb', line 732

def unrecord_list(resource_ids)
  return if resource_ids.blank?

  if resource_ids == '*'
    SearchResult.delete_by(search_id: id)
  else
    SearchResult.transaction do # One transaction wrap makes this faster
      hsh_delete = {}
      resource_ids.each do |raw_resource_id|
        resource_type, resource_id = raw_resource_id.split('|')
        hsh_delete[resource_type] ||= []
        hsh_delete[resource_type] << resource_id
      end
      hsh_delete.each do |(resource_type, resource_ids)|
        SearchResult.delete_by(search_id: id, resource_type: resource_type, resource_id: resource_ids)
      end
    end
  end
  enqueue_navbar_pinned_refresh if pinned
end

#validated_selected_columnsObject

Presentation layer on top of store array attribute selected columns,
makes sure to symbolize keys, prevents duplicate, cleans up blanks.



332
333
334
# File 'app/models/search.rb', line 332

def validated_selected_columns
  (selected_columns || []).map { |c| c.presence&.to_sym }.uniq.compact
end

#view_resource_klassObject

Alias for Class#view_resource_klass

Returns:

  • (Object)

    Class#view_resource_klass

See Also:



328
# File 'app/models/search.rb', line 328

delegate :view_resource_klass, to: :class