Class: CafeCar::QueryBuilder

Inherits:
Object
  • Object
show all
Defined in:
lib/cafe_car/query_builder.rb

Defined Under Namespace

Classes: Op

Constant Summary collapse

OPS =

Word-form comparison operators for URL filters — price.min=10 parses to { "price" => { "min" => "10" } }, and each word maps to an Arel predicate. min/max read friendlier than gte/lte and are the documented spelling.

{
  "eq"  => "==", "gt"  => ">",  "lt"  => "<",
  "gte" => ">=", "lte" => "<=", "min" => ">=", "max" => "<="
}.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(scope) ⇒ QueryBuilder

Returns a new instance of QueryBuilder.



37
38
39
# File 'lib/cafe_car/query_builder.rb', line 37

def initialize(scope)
  @scope = scope
end

Instance Attribute Details

#scopeObject (readonly)

Returns the value of attribute scope.



35
36
37
# File 'lib/cafe_car/query_builder.rb', line 35

def scope
  @scope
end

Instance Method Details

#arel(key) ⇒ Object



42
# File 'lib/cafe_car/query_builder.rb', line 42

def arel(key)  = @scope.arel_table[chomp(key)]

#arel!(node) ⇒ Object



117
# File 'lib/cafe_car/query_builder.rb', line 117

def arel!(node) = @scope.where!(node)

#association!(name, value) ⇒ Object



155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/cafe_car/query_builder.rb', line 155

def association!(name, value, ...)
  update! do
    case value
    when true  then @scope.where_assoc_exists(name)
    when false then @scope.where_assoc_not_exists(name)
    when Integer, Range, /^\d+$/
      @scope.where_assoc_count(parse(name, value), :==, name)
    when Op
      value = parse(name, value)
      @scope.where_assoc_count(value.rhs, value.flop, name)
    else @scope.where_assoc_exists(name) { all.query!(value, ...) }
    end
  end
end

#association?(name) ⇒ Boolean

Returns:

  • (Boolean)


113
# File 'lib/cafe_car/query_builder.rb', line 113

def association?(name) = reflection(name).present?

#attribute!(key, value) ⇒ Object



138
139
140
141
142
143
144
145
146
147
148
# File 'lib/cafe_car/query_builder.rb', line 138

def attribute!(key, value)
  case [ key, value ]
  in _, Regexp
    @scope.where!(arel(key).matches_regexp(value.source, !value.casefold?))
  in _, Op
    @scope.where!(parse(key, value).arel(arel(key)))
  in _, Hash if operators?(value)
    value.each { |word, rhs| attribute!(key, Op.new(OPS[word.to_s], rhs)) }
  else @scope.where!(key => parse(key, value))
  end
end

#attribute?(name) ⇒ Boolean

Returns:

  • (Boolean)


114
# File 'lib/cafe_car/query_builder.rb', line 114

def attribute?(name)   = column(name).present?

#chomp(key) ⇒ Object



43
# File 'lib/cafe_car/query_builder.rb', line 43

def chomp(key) = key.to_s.sub(/\W+$/, "")

#column(name) ⇒ Object



109
# File 'lib/cafe_car/query_builder.rb', line 109

def column(name)       = @scope.columns_hash[name.to_s]

#enum?(name) ⇒ Boolean

Returns:

  • (Boolean)


112
# File 'lib/cafe_car/query_builder.rb', line 112

def enum?(name)        = info(name).enum?

#info(name) ⇒ Object



111
# File 'lib/cafe_car/query_builder.rb', line 111

def info(name)         = CafeCar[:ModelInfo].find(@scope.model).field(chomp(name))

#not!Object



104
105
106
107
# File 'lib/cafe_car/query_builder.rb', line 104

def not!(&)
  inverted = unscoped.tap { _1.instance_exec(&) }.scope.invert_where
  update! { _1.and(inverted) }
end

#operators?(value) ⇒ Boolean

A filter value like { "min" => 10, "max" => 50 } — every key a known comparison operator — desugars to >= 10 AND <= 50 rather than an equality against the literal hash.

Returns:

  • (Boolean)


153
# File 'lib/cafe_car/query_builder.rb', line 153

def operators?(value) = value.any? && value.keys.all? { OPS.key?(_1.to_s) }

#param!(key, value) ⇒ Object



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/cafe_car/query_builder.rb', line 119

def param!(key, value)
  case key
  when /^(.*?)\s*!$/
    not! { param!($1, value) }
  when /^(.*?)\s*~$/
    param!($1, Regexp.new(value, Regexp::IGNORECASE))
  when /^(.*?)\s*([<>]=?)$/
    param!($1, Op.new($2, value))
  when method(:association?)
    association!(key, value)
  when method(:attribute?)
    attribute!(key, value)
  when method(:scope?)
    scope!(key, value)
  else
    raise MissingAttributeError, "can't find #{key.inspect} on #{@scope.model_name}"
  end
end

#parse(key, value) ⇒ Object



57
58
59
60
61
62
63
64
# File 'lib/cafe_car/query_builder.rb', line 57

def parse(key, value)
  new_value = parse_value(key, value)
  if new_value != value
    parse(key, new_value)
  else
    new_value
  end
end

#parse_range(key, value) ⇒ Object



51
52
53
54
55
# File 'lib/cafe_car/query_builder.rb', line 51

def parse_range(key, value)
  Range.new(parse_value(key, value.begin).then { _1.try(:begin) or _1 },
            parse_value(key, value.end).then { value.exclude_end? ? _1.try(:begin) : _1.try(:end) or _1 },
            value.exclude_end?)
end

#parse_time(value) ⇒ Object



45
46
47
48
49
# File 'lib/cafe_car/query_builder.rb', line 45

def parse_time(value)
  Chronic.parse(value, guess: false, context: :past)
rescue NoMethodError
  nil
end

#parse_value(key, value) ⇒ Object



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/cafe_car/query_builder.rb', line 66

def parse_value(key, value)
  case value
  in Op(rhs: /^=(.*)$/)
    Op.new("#{value.op}=", $1)
  in Op(op: (:< | :>=), rhs: Range)
    value.map(&:begin)
  in Op(op: (:> | :<=), rhs: Range)
    value.map(&:end)
  in Range
    parse_range(key, value)
  in Array | Op
    value.map { parse_value(key, _1) }
  # An enum key ("archived") passes through untouched — ActiveRecord casts it
  # to the stored value itself; coercing by column type would `to_i` an
  # integer-backed enum's key to 0, the wrong bucket.
  in String if enum?(key)
    value
  in "true" then true
  in "false" then false
  in String
    case column(key)&.type || reflection(key)&.macro
    when :datetime then parse_time(value) || value
    when :integer  then value.to_i
    when :float    then value.to_f
    when :belongs_to, :has_many, :has_one
      value.to_i
    else value
    end
  else value
  end
end

#queryObject



195
# File 'lib/cafe_car/query_builder.rb', line 195

def query(...) = clone.update!(&:all).query!(...)

#query!(params = nil) ⇒ Object



183
184
185
186
187
188
189
190
191
192
193
# File 'lib/cafe_car/query_builder.rb', line 183

def query!(params = nil)
  case params
  when Hash  then params.each { param!(_1, _2) }
  when Array then params.each { query! _1 }
  when String then search!(params)
  # when Arel::Nodes::Node then arel!(params)
  when nil
  else raise ArgumentError, "cannot query on #{params}"
  end
  self
end

#reflection(name) ⇒ Object



110
# File 'lib/cafe_car/query_builder.rb', line 110

def reflection(name)   = @scope.reflect_on_association(name)

#scope!(name, value) ⇒ Object



170
171
172
173
174
175
176
# File 'lib/cafe_car/query_builder.rb', line 170

def scope!(name, value)
  value = parse_value(name, value)
  arity = (@scope.scopes[name] || @scope.method(name)).arity
  value = nil if arity == 0 and value == true

  update! { _1.public_send(name, *value) }
end

#scope?(name) ⇒ Boolean

Returns:

  • (Boolean)


115
# File 'lib/cafe_car/query_builder.rb', line 115

def scope?(name)       = name.intern.in? @scope.local_methods

#search!(term) ⇒ Object



178
179
180
181
# File 'lib/cafe_car/query_builder.rb', line 178

def search!(term)
  @scope.query!("body~": term) if @scope < ::ActionText::RichText
  update! { _1.respond_to?(:search) ? _1.search(term) : _1.default_search(term) }
end

#unscopedObject



41
# File 'lib/cafe_car/query_builder.rb', line 41

def unscoped   = QueryBuilder.new(@scope.unscope(:where))

#update!Object



98
99
100
101
102
# File 'lib/cafe_car/query_builder.rb', line 98

def update!(&)
  scope  = yield @scope
  @scope = scope if scope
  self
end