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

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



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

def composite_columns
  @composite_columns
end

.default_selected_columnsObject (readonly)

Returns the value of attribute default_selected_columns.



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

def default_selected_columns
  @default_selected_columns
end

.default_sort_columnsObject (readonly)

Returns the value of attribute default_sort_columns.



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

def default_sort_columns
  @default_sort_columns
end

.global_favoritesObject (readonly)

Returns the value of attribute global_favorites.



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

def global_favorites
  @global_favorites
end

Instance Attribute Details

#composite_columnsObject (readonly)

Returns the value of attribute composite_columns.



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

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



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

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



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

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



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

def pagy_page
  @pagy_page
end

#sql_select_columnsObject (readonly)

Returns the value of attribute sql_select_columns.



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

def sql_select_columns
  @sql_select_columns
end

Class Method Details

.allowed_role_idsObject



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

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



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

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



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

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



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

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

.custom_criteria_keysObject



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

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



217
218
219
# File 'app/models/search.rb', line 217

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



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

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



185
186
187
# File 'app/models/search.rb', line 185

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:



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

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

.friendly_column_name(column_name) ⇒ Object



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

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

.global_favorite(*options) ⇒ Object



178
179
180
181
# File 'app/models/search.rb', line 178

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)


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

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



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

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



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

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

.main_resource_tableObject



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

def self.main_resource_table
  main_resource_class.tableize
end

.mass_actions_for_selectObject



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

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



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

def self.maximum_unpersisted_queries
  5
end

.options(_visible_only = true) ⇒ Object



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

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

.options_classesObject



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

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



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

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:



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

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

.search_nameObject



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

def self.search_name
  name.titleize
end

.search_typeObject



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

def self.search_type
  name.tableize.singularize
end

.search_type_humanizedObject



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

def self.search_type_humanized
  search_type.humanize.titleize
end

.select_sort_columnsObject

Returns available columns for sorting



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

def self.select_sort_columns
  view_resource_klass.select_sort_columns
end

.unpersisted_queries_quote_reached?(search_type, employee_id) ⇒ Boolean

Returns:

  • (Boolean)


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

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



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

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

.view_resource_klassObject



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

def self.view_resource_klass
  view_resource_class.constantize
end

.view_resource_tableObject



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

def self.view_resource_table
  view_resource_class.tableize
end

.visible?Boolean

Returns:

  • (Boolean)


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

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:



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

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:



284
285
286
# File 'app/models/search.rb', line 284

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



317
318
319
320
321
# File 'app/models/search.rb', line 317

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



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

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

#append_to_sql_select_columns(*args) ⇒ Object (protected)



888
889
890
# File 'app/models/search.rb', line 888

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.



604
605
606
607
608
609
610
611
612
613
614
615
616
# File 'app/models/search.rb', line 604

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



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

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



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

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



496
497
498
499
500
501
502
503
504
505
# File 'app/models/search.rb', line 496

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



507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
# File 'app/models/search.rb', line 507

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



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

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



620
621
622
623
624
625
626
627
628
629
630
631
632
# File 'app/models/search.rb', line 620

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



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

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



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

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

#cleanup_search_resultsObject (protected)



896
897
898
# File 'app/models/search.rb', line 896

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



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

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

#discard_excessObject (protected)



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

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



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

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



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

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

#employeeEmployee

Returns:

See Also:



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

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.



710
711
712
713
714
# File 'app/models/search.rb', line 710

def enqueue_navbar_pinned_refresh
  return if employee_id.blank?

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

#fast_countObject



375
376
377
378
379
# File 'app/models/search.rb', line 375

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

#get_pinned_resultsObject



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

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



544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
# File 'app/models/search.rb', line 544

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



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

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)



884
885
886
# File 'app/models/search.rb', line 884

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.



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

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

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

Mass Export



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
747
748
749
# File 'app/models/search.rb', line 721

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



841
842
843
# File 'app/models/search.rb', line 841

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



291
292
293
294
295
# File 'app/models/search.rb', line 291

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.



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

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



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
415
416
417
418
419
420
421
422
423
424
# File 'app/models/search.rb', line 381

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



426
427
428
429
# File 'app/models/search.rb', line 426

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



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

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.



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

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



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

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



464
465
466
# File 'app/models/search.rb', line 464

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

#record_list(resource_ids) ⇒ Object



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
661
662
663
# File 'app/models/search.rb', line 634

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.



693
694
695
696
697
698
699
700
701
702
703
704
705
# File 'app/models/search.rb', line 693

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)



900
901
902
903
904
# File 'app/models/search.rb', line 900

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

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

#search_nameObject



716
717
718
# File 'app/models/search.rb', line 716

def search_name
  name || self.class.search_name
end

#search_resultsActiveRecord::Relation<SearchResult>

Returns:

See Also:



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

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

#select_sort_columnsObject

Instance level list of possible sort columns



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

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)



892
893
894
# File 'app/models/search.rb', line 892

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

#set_defaultsObject



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

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



565
566
567
568
569
570
571
572
573
574
575
# File 'app/models/search.rb', line 565

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 #



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

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

#sort_column1=(val) ⇒ Object



234
235
236
237
238
239
240
# File 'app/models/search.rb', line 234

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



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

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

#sort_column2=(val) ⇒ Object



246
247
248
249
250
251
252
# File 'app/models/search.rb', line 246

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



254
255
256
# File 'app/models/search.rb', line 254

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

#sort_column3=(val) ⇒ Object



258
259
260
261
262
263
264
# File 'app/models/search.rb', line 258

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



591
592
593
594
595
596
597
598
599
600
# File 'app/models/search.rb', line 591

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 }



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

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)



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

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



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

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.



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

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:



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

delegate :view_resource_klass, to: :class