Module: ElaineCrud::SearchAndFiltering

Extended by:
ActiveSupport::Concern
Included in:
BaseController
Defined in:
lib/elaine_crud/search_and_filtering.rb

Overview

Methods for handling search and filtering of records Provides global text search and per-field filtering capabilities

Instance Method Summary collapse

Instance Method Details

#apply_date_range_filters(records) ⇒ ActiveRecord::Relation

Apply date range filters

Parameters:

  • records (ActiveRecord::Relation)

Returns:

  • (ActiveRecord::Relation)


123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/elaine_crud/search_and_filtering.rb', line 123

def apply_date_range_filters(records)
  date_fields = determine_date_columns
  table = crud_model.arel_table

  date_fields.each do |field|
    from_key = "#{field}_from"
    to_key = "#{field}_to"

    # Use Arel to safely construct date range queries
    # This prevents SQL injection in field names
    if filters[from_key].present?
      records = records.where(table[field].gteq(filters[from_key]))
    end

    if filters[to_key].present?
      records = records.where(table[field].lteq(filters[to_key]))
    end
  end

  records
end

#apply_field_filter(records, field, value) ⇒ ActiveRecord::Relation

Apply filter for a specific field

Parameters:

  • records (ActiveRecord::Relation)
  • field (String, Symbol)

    Field name

  • value (String, Array)

    Filter value

Returns:

  • (ActiveRecord::Relation)


95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/elaine_crud/search_and_filtering.rb', line 95

def apply_field_filter(records, field, value)
  column_type = get_column_type(field)
  table = crud_model.arel_table

  case column_type
  when :string, :text
    # Partial match for text fields using Arel
    # This prevents SQL injection in field names
    records.where(table[field].lower.matches("%#{value.downcase}%"))
  when :boolean
    # Exact match for booleans using Arel
    records.where(table[field].eq(ActiveModel::Type::Boolean.new.cast(value)))
  when :integer
    # Handle foreign keys and integers using Arel
    if value.is_a?(Array)
      records.where(table[field].in(value))
    else
      records.where(table[field].eq(value))
    end
  else
    # Default: exact match using Arel
    records.where(table[field].eq(value))
  end
end

#apply_filters(records) ⇒ ActiveRecord::Relation

Apply individual field filters

Parameters:

  • records (ActiveRecord::Relation)

Returns:

  • (ActiveRecord::Relation)


71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/elaine_crud/search_and_filtering.rb', line 71

def apply_filters(records)
  filters.each do |field, value|
    next if value.blank?

    # Skip special date range fields (handled separately)
    next if field.to_s.end_with?('_from', '_to')

    # Validate field is in the model
    next unless valid_filter_field?(field)

    records = apply_field_filter(records, field, value)
  end

  # Apply date range filters
  records = apply_date_range_filters(records)

  records
end

#apply_global_search(records) ⇒ ActiveRecord::Relation

Apply global text search across searchable columns

Parameters:

  • records (ActiveRecord::Relation)

Returns:

  • (ActiveRecord::Relation)


49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/elaine_crud/search_and_filtering.rb', line 49

def apply_global_search(records)
  # Get searchable columns (string/text types)
  searchable_columns = determine_searchable_columns

  return records if searchable_columns.empty?

  # Build OR conditions for each searchable column using Arel
  # This prevents SQL injection in column names
  table = crud_model.arel_table
  search_pattern = "%#{search_query.downcase}%"

  conditions = searchable_columns.map do |column|
    table[column].lower.matches(search_pattern)
  end

  # Combine all conditions with OR
  records.where(conditions.reduce(:or))
end

#apply_search_and_filters(records) ⇒ ActiveRecord::Relation

Apply search and filters to records

Parameters:

  • records (ActiveRecord::Relation)

    The base query

Returns:

  • (ActiveRecord::Relation)

    Filtered query



17
18
19
20
21
# File 'lib/elaine_crud/search_and_filtering.rb', line 17

def apply_search_and_filters(records)
  records = apply_global_search(records) if search_query.present?
  records = apply_filters(records) if filters.present?
  records
end

#determine_date_columnsArray<String>

Determine date columns for range filtering

Returns:

  • (Array<String>)

    Column names



210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/elaine_crud/search_and_filtering.rb', line 210

def determine_date_columns
  return [] unless crud_model

  date_cols = []

  crud_model.columns.each do |col|
    if [:date, :datetime, :timestamp].include?(col.type) &&
       determine_columns.include?(col.name)

      # Check if explicitly configured as non-filterable
      config = field_config_for(col.name.to_sym)
      if config&.respond_to?(:filterable)
        next unless config.filterable  # Skip if explicitly set to false
      end

      # Default: date fields are filterable
      date_cols << col.name
    end
  end

  date_cols
end

#determine_filterable_columnsArray<Hash>

Determine which columns are filterable

Returns:

  • (Array<Hash>)

    Array of hashes with field info



174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/elaine_crud/search_and_filtering.rb', line 174

def determine_filterable_columns
  return [] unless crud_model

  filterable = []

  determine_columns.each do |col_name|
    next if %w[id created_at updated_at].include?(col_name.to_s)

    config = field_config_for(col_name.to_sym)

    # Check if explicitly configured as non-filterable
    if config&.respond_to?(:filterable)
      next unless config.filterable  # Skip if explicitly set to false
    end

    # Default: all fields are filterable (matching search behavior)
    filterable << {
      name: col_name.to_sym,
      type: infer_filter_type(col_name),
      config: config
    }
  end

  filterable
end

#determine_searchable_columnsArray<String>

Determine which columns are searchable (string/text types)

Returns:

  • (Array<String>)

    Column names



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/elaine_crud/search_and_filtering.rb', line 147

def determine_searchable_columns
  return [] unless crud_model

  searchable = []

  crud_model.columns.each do |col|
    # Include string/text columns that are displayed
    if [:string, :text].include?(col.type) &&
       !%w[id created_at updated_at].include?(col.name) &&
       determine_columns.include?(col.name)

      # Check if field is configured as searchable
      config = field_config_for(col.name.to_sym)
      if config&.respond_to?(:searchable)
        searchable << col.name if config.searchable
      else
        # Default: string/text fields are searchable
        searchable << col.name
      end
    end
  end

  searchable
end

#filtersHash

Get filter parameters

Returns:

  • (Hash)

    Filter parameters



31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/elaine_crud/search_and_filtering.rb', line 31

def filters
  filter_params = params[:filter] || {}
  # Convert ActionController::Parameters to Hash for compatibility
  if filter_params.respond_to?(:to_unsafe_h)
    filter_params.to_unsafe_h
  elsif filter_params.respond_to?(:to_h)
    filter_params.to_h
  elsif filter_params.is_a?(Hash)
    filter_params
  else
    # Handle malformed filter parameter (e.g., plain string)
    {}
  end
end

#get_column_type(field) ⇒ Symbol

Get column type for a field

Parameters:

  • field (String, Symbol)

    Field name

Returns:

  • (Symbol)

    Column type



203
204
205
206
# File 'lib/elaine_crud/search_and_filtering.rb', line 203

def get_column_type(field)
  column = crud_model.columns.find { |col| col.name == field.to_s }
  column&.type || :string
end

#infer_filter_type(field_name) ⇒ Symbol

Infer filter type from column type and configuration

Parameters:

  • field_name (String, Symbol)

    Field name

Returns:

  • (Symbol)

    Filter type



236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/elaine_crud/search_and_filtering.rb', line 236

def infer_filter_type(field_name)
  column = crud_model.columns.find { |col| col.name == field_name.to_s }
  return :text unless column

  config = field_config_for(field_name.to_sym)

  # Use configured filter type if available
  return config.filter_type if config&.respond_to?(:filter_type) && config.filter_type

  # Infer from column type
  case column.type
  when :boolean then :boolean
  when :date, :datetime, :timestamp then :date_range
  when :integer
    # Check if it's a foreign key
    if field_name.to_s.end_with?('_id')
      :select
    else
      :text
    end
  else
    :text
  end
end

#search_active?Boolean

Check if any search or filters are active

Returns:

  • (Boolean)


263
264
265
# File 'lib/elaine_crud/search_and_filtering.rb', line 263

def search_active?
  search_query.present? || filters.present?
end

#search_queryString?

Get search query from params

Returns:

  • (String, nil)

    The search term



25
26
27
# File 'lib/elaine_crud/search_and_filtering.rb', line 25

def search_query
  params[:search]
end

#total_unfiltered_countInteger

Get total count without filters (for "X of Y results" display)

Returns:

  • (Integer)


269
270
271
272
273
274
275
276
# File 'lib/elaine_crud/search_and_filtering.rb', line 269

def total_unfiltered_count
  @total_unfiltered_count ||= begin
    # Build base query without search/filters
    records = crud_model.all
    records = apply_has_many_filtering(records)
    records.count
  end
end

#valid_filter_field?(field) ⇒ Boolean

Validate that a field is safe to filter on

Parameters:

  • field (String, Symbol)

    Field name

Returns:

  • (Boolean)


281
282
283
# File 'lib/elaine_crud/search_and_filtering.rb', line 281

def valid_filter_field?(field)
  crud_model.column_names.include?(field.to_s)
end