Class: Search

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

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, 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

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::EventPublishable

#publish_event

Class Attribute Details

.composite_columnsObject (readonly)

Returns the value of attribute composite_columns.



32
33
34
# File 'app/models/search.rb', line 32

def composite_columns
  @composite_columns
end

.default_selected_columnsObject (readonly)

Returns the value of attribute default_selected_columns.



32
33
34
# File 'app/models/search.rb', line 32

def default_selected_columns
  @default_selected_columns
end

.default_sort_columnsObject (readonly)

Returns the value of attribute default_sort_columns.



32
33
34
# File 'app/models/search.rb', line 32

def default_sort_columns
  @default_sort_columns
end

.global_favoritesObject (readonly)

Returns the value of attribute global_favorites.



32
33
34
# File 'app/models/search.rb', line 32

def global_favorites
  @global_favorites
end

Instance Attribute Details

#composite_columnsObject (readonly)

Returns the value of attribute composite_columns.



39
40
41
# File 'app/models/search.rb', line 39

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



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

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



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

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



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

def pagy_page
  @pagy_page
end

#sort_column1Object

BEGIN INSTANCE VARIABLES #



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

def sort_column1
  @sort_column1
end

#sort_column2Object

Returns the value of attribute sort_column2.



39
40
41
# File 'app/models/search.rb', line 39

def sort_column2
  @sort_column2
end

#sort_column3Object

Returns the value of attribute sort_column3.



39
40
41
# File 'app/models/search.rb', line 39

def sort_column3
  @sort_column3
end

#sql_select_columnsObject (readonly)

Returns the value of attribute sql_select_columns.



39
40
41
# File 'app/models/search.rb', line 39

def sql_select_columns
  @sql_select_columns
end

Class Method Details

.allowed_role_idsObject



76
77
78
# File 'app/models/search.rb', line 76

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



201
202
203
# File 'app/models/search.rb', line 201

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



212
213
214
# File 'app/models/search.rb', line 212

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

.composite_column(*options) ⇒ Object

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



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

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

.custom_criteria_keysObject



448
449
450
# File 'app/models/search.rb', line 448

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



207
208
209
# File 'app/models/search.rb', line 207

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



181
182
183
# File 'app/models/search.rb', line 181

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



175
176
177
# File 'app/models/search.rb', line 175

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:



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

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

.friendly_column_name(column_name) ⇒ Object



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

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

.global_favorite(*options) ⇒ Object



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

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)


80
81
82
83
84
# File 'app/models/search.rb', line 80

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

  (role_ids & allowed_role_ids).present?
end

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



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

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



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

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

.main_resource_tableObject



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

def self.main_resource_table
  main_resource_class.tableize
end

.mass_actions_for_selectObject



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

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



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

def self.maximum_unpersisted_queries
  5
end

.options(_visible_only = true) ⇒ Object



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

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

.options_classesObject



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

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



129
130
131
132
133
# File 'app/models/search.rb', line 129

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:



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

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

.search_nameObject



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

def self.search_name
  name.titleize
end

.search_typeObject



97
98
99
# File 'app/models/search.rb', line 97

def self.search_type
  name.tableize.singularize
end

.search_type_humanizedObject



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

def self.search_type_humanized
  search_type.humanize.titleize
end

.select_sort_columnsObject

Returns available columns for sorting



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

def self.select_sort_columns
  view_resource_klass.select_sort_columns
end

.unpersisted_queries_quote_reached?(search_type, employee_id) ⇒ Boolean

Returns:

  • (Boolean)


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

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



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

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

.view_resource_klassObject



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

def self.view_resource_klass
  view_resource_class.constantize
end

.view_resource_tableObject



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

def self.view_resource_table
  view_resource_class.tableize
end

.visible?Boolean

Returns:

  • (Boolean)


135
136
137
# File 'app/models/search.rb', line 135

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:



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

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:



274
275
276
# File 'app/models/search.rb', line 274

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

#append_custom_column(*options) ⇒ Object

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)'



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

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



320
321
322
323
# File 'app/models/search.rb', line 320

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

#append_to_sql_select_columns(*args) ⇒ Object (protected)



886
887
888
# File 'app/models/search.rb', line 886

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

#applicable_sort_termsObject

Filters down the sort columns available and cross reference them with the
columns that are in the query output.



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

def applicable_sort_terms
  sorts = []
  sort_columns.select 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
    sorts << [sort_column_fq, sort_order] if available_output_columns.include? sort_column.to_sym
  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



863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
# File 'app/models/search.rb', line 863

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



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
# File 'app/models/search.rb', line 421

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



486
487
488
489
490
491
492
493
494
495
# File 'app/models/search.rb', line 486

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 = {}) ⇒ Object



497
498
499
500
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
527
528
# File 'app/models/search.rb', line 497

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



458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
# File 'app/models/search.rb', line 458

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



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

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



827
828
829
830
831
832
833
# File 'app/models/search.rb', line 827

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



258
259
260
# File 'app/models/search.rb', line 258

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

#cleanup_search_resultsObject (protected)



894
895
896
# File 'app/models/search.rb', line 894

def cleanup_search_results
  search_results.delete_all if saved_change_to_query_params?
end

#composite_column(*options) ⇒ Object

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



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

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

#discard_excessObject (protected)



904
905
906
907
# File 'app/models/search.rb', line 904

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



340
341
342
# File 'app/models/search.rb', line 340

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



336
337
338
# File 'app/models/search.rb', line 336

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

#employeeEmployee

Returns:

See Also:



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

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.



707
708
709
710
711
# File 'app/models/search.rb', line 707

def enqueue_navbar_pinned_refresh
  return if employee_id.blank?

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

#fast_countObject



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

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

#get_pinned_resultsObject



355
356
357
# File 'app/models/search.rb', line 355

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



534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
# File 'app/models/search.rb', line 534

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



344
345
346
347
348
# File 'app/models/search.rb', line 344

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)



882
883
884
# File 'app/models/search.rb', line 882

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.



351
352
353
# File 'app/models/search.rb', line 351

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

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

Mass Export



718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
# File 'app/models/search.rb', line 718

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).



839
840
841
# File 'app/models/search.rb', line 839

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



281
282
283
284
285
# File 'app/models/search.rb', line 281

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.



45
46
47
48
49
# File 'app/models/search.rb', line 45

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



371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
# File 'app/models/search.rb', line 371

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



416
417
418
419
# File 'app/models/search.rb', line 416

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



361
362
363
# File 'app/models/search.rb', line 361

def pinned_query
  search_results
end

#prepend_custom_column(*options) ⇒ Object

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



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

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



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

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).



454
455
456
# File 'app/models/search.rb', line 454

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

#record_list(resource_ids) ⇒ Object



624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
# File 'app/models/search.rb', line 624

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.where(search_id: id).delete_all
    # 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.connection.execute(sql)
  else
    return if resource_ids.blank?

    search_results = []
    resource_ids.each do |raw_resource_id|
      resource_type, resource_id = raw_resource_id.split('|')
      search_results << SearchResult.new(
        resource_type: resource_type,
        resource_id: resource_id,
        search_id: id
      )
    end
    require 'activerecord-import/base'
    require 'activerecord-import/active_record/adapters/postgresql_adapter'
    SearchResult.import search_results, validate: false, on_duplicate_key_ignore: true
  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.



690
691
692
693
694
695
696
697
698
699
700
701
702
# File 'app/models/search.rb', line 690

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).pluck(:id)
  stale_ids    = pinned_ids - matching_ids
  return 0 if stale_ids.empty?

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

#remove_other_pinsObject (protected)



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

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

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

#search_nameObject



713
714
715
# File 'app/models/search.rb', line 713

def search_name
  name || self.class.search_name
end

#search_resultsActiveRecord::Relation<SearchResult>

Returns:

See Also:



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

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

#select_sort_columnsObject

Instance level list of possible sort columns



288
289
290
291
292
# File 'app/models/search.rb', line 288

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)



890
891
892
# File 'app/models/search.rb', line 890

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

#set_defaultsObject



809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
# File 'app/models/search.rb', line 809

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



555
556
557
558
559
560
561
562
563
564
565
# File 'app/models/search.rb', line 555

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_columns_for_selectObject

Retrieves a list of sort columns suitable for a select



581
582
583
584
585
586
587
588
589
590
# File 'app/models/search.rb', line 581

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 }



571
572
573
574
575
576
577
578
# File 'app/models/search.rb', line 571

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

#target_geocodingObject (protected)



845
846
847
848
849
850
851
852
853
854
855
856
857
# File 'app/models/search.rb', line 845

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



662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
# File 'app/models/search.rb', line 662

def unrecord_list(resource_ids)
  return if resource_ids.blank?

  if resource_ids == '*'
    SearchResult.where(search_id: id).delete_all
  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.where(search_id: id, resource_type: resource_type, resource_id: resource_ids).delete_all
      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.



298
299
300
# File 'app/models/search.rb', line 298

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:



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

delegate :view_resource_klass, to: :class