Module: CrudComponents::LikeSpec

Defined in:
lib/crud_components/like_spec.rb

Overview

The declarative mini-language shared by ‘filter like:` and `search_in`: case-insensitive contains across columns, joining associations as needed.

:title                       own column
%i[title subtitle]           several own columns, OR-combined
{ authors: %i[name email] }  join, explicit columns
:publisher                   join, delegate to Publisher's search_in
{ user: :address }           nested join, delegate to Address

Specs never contain SQL strings; conditions are built through Arel with LIKE wildcards escaped, so they are parameterized end to end.

Defined Under Namespace

Classes: Entry

Constant Summary collapse

MAX_DELEGATIONS =

Only delegation hops (an association name resolved through the target’s search_in) can form a cycle; explicit nesting is bounded by the literal spec. So the guard counts delegations only.

5

Class Method Summary collapse

Class Method Details

.apply(scope, spec, value) ⇒ Object



21
22
23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/crud_components/like_spec.rb', line 21

def apply(scope, spec, value)
  model = scope.respond_to?(:model) ? scope.model : scope
  entries = expand(model, spec)
  return scope if entries.empty?

  pattern = "%#{ActiveRecord::Base.sanitize_sql_like(value)}%"
  condition = entries.map { |entry| entry.arel_condition(pattern) }.reduce(:or)
  joins = entries.filter_map(&:join_fragment).reduce({}) { |acc, j| deep_merge(acc, j) }

  return scope.where(condition) if joins.empty?

  # distinct only matters once a join can multiply rows
  scope.left_joins(joins).where(condition).distinct
end

.deep_merge(left, right) ⇒ Object



104
105
106
107
108
109
110
111
# File 'lib/crud_components/like_spec.rb', line 104

def deep_merge(left, right)
  normalize = ->(j) { j.is_a?(Hash) ? j : { j => {} } }
  l = normalize.call(left)
  normalize.call(right).each do |key, value|
    l[key] = l.key?(key) ? deep_merge(l[key], value) : value
  end
  l
end

.delegate(model, reflection, path, delegations) ⇒ Object

Association name without columns: use the target model’s search_in spec.



92
93
94
95
96
97
98
99
100
101
102
# File 'lib/crud_components/like_spec.rb', line 92

def delegate(model, reflection, path, delegations)
  target = reflection.klass
  target_spec = Structure.for(target).search_in_spec
  if target_spec.nil?
    raise DefinitionError, "cannot delegate like-spec to #{model}##{reflection.name}: " \
                           "#{target}'s search_in is a custom block — spell the columns out, " \
                           "e.g. { #{reflection.name}: %i[...] }"
  end

  expand(target, target_spec, path + [reflection.name], delegations + 1)
end

.expand(model, spec, path = [], delegations = 0) ⇒ Object

Resolves a spec into flat [path, klass, column] entries, expanding association names without columns through the target’s search_in spec. ‘delegations` counts only delegation hops (the cycle risk).



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/crud_components/like_spec.rb', line 53

def expand(model, spec, path = [], delegations = 0)
  if delegations > MAX_DELEGATIONS
    raise DefinitionError, "search_in/like delegation more than #{MAX_DELEGATIONS} levels deep " \
                           "starting at #{model} — most likely a delegation cycle"
  end

  Array.wrap(spec).flat_map do |item|
    case item
    when Symbol, String then expand_name(model, item.to_sym, path, delegations)
    when Hash then item.flat_map { |assoc, sub| expand_assoc(model, assoc.to_sym, sub, path, delegations) }
    else
      raise DefinitionError, "invalid like-spec element #{item.inspect} for #{model}" \
                             'use column symbols, association symbols, or { assoc => columns } hashes'
    end
  end
end

.expand_assoc(model, assoc, sub, path, delegations) ⇒ Object

Explicit nesting ({ assoc => columns }) — bounded by the spec, not a cycle risk, so it does not count against the delegation limit.

Raises:



83
84
85
86
87
88
89
# File 'lib/crud_components/like_spec.rb', line 83

def expand_assoc(model, assoc, sub, path, delegations)
  reflection = model.reflect_on_association(assoc)
  raise DefinitionError, "like-spec references association '#{assoc}', " \
                         "which #{model} does not have" unless reflection

  expand(reflection.klass, sub, path + [assoc], delegations)
end

.expand_name(model, name, path, delegations) ⇒ Object



70
71
72
73
74
75
76
77
78
79
# File 'lib/crud_components/like_spec.rb', line 70

def expand_name(model, name, path, delegations)
  if model.columns_hash.key?(name.to_s)
    [Entry.new(path, model, name)]
  elsif (reflection = model.reflect_on_association(name))
    delegate(model, reflection, path, delegations)
  else
    raise DefinitionError, "like-spec references '#{name}', which is neither a column nor " \
                           "an association of #{model}"
  end
end