Class: Praxis::Extensions::AttributeFiltering::FilteringParams

Inherits:
Object
  • Object
show all
Includes:
Attributor::Dumpable, Attributor::Type
Defined in:
lib/praxis/extensions/attribute_filtering/filters_parser.rb,
lib/praxis/extensions/attribute_filtering/filtering_params.rb

Defined Under Namespace

Classes: Condition, ConditionGroup, DSLCompiler, Parser

Constant Summary collapse

VALUE_OPERATORS =
Set.new(['!=', '>=', '<=', '=', '<', '>']).freeze
NOVALUE_OPERATORS =
Set.new(['!', '!!']).freeze
AVAILABLE_OPERATORS =
Set.new(VALUE_OPERATORS + NOVALUE_OPERATORS).freeze

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(parsed = []) ⇒ FilteringParams

Returns a new instance of FilteringParams.



220
221
222
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 220

def initialize(parsed = [])
  @parsed_array = parsed
end

Class Attribute Details

.allowed_filtersObject (readonly)

Returns the value of attribute allowed_filters.



59
60
61
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 59

def allowed_filters
  @allowed_filters
end

.allowed_leavesObject (readonly)

Returns the value of attribute allowed_leaves.



59
60
61
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 59

def allowed_leaves
  @allowed_leaves
end

.media_typeObject (readonly)

Returns the value of attribute media_type.



59
60
61
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 59

def media_type
  @media_type
end

Instance Attribute Details

#parsed_arrayObject (readonly)

Returns the value of attribute parsed_array.



33
34
35
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 33

def parsed_array
  @parsed_array
end

Class Method Details

.add_any(name, operators:, fuzzy:) ⇒ Object



90
91
92
93
94
95
96
97
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 90

def add_any(name, operators:, fuzzy:)
  raise 'Invalid set of operators passed' unless AVAILABLE_OPERATORS.superset?(operators)

  @allowed_leaves[name] = {
    operators: operators,
    fuzzy_match: fuzzy
  }
end

.add_filter(name, operators:, fuzzy:) ⇒ Object



78
79
80
81
82
83
84
85
86
87
88
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 78

def add_filter(name, operators:, fuzzy:)
  components = name.to_s.split('.').map(&:to_sym)
  attribute, _enclosing_type = find_filter_attribute(components, media_type)
  raise 'Invalid set of operators passed' unless AVAILABLE_OPERATORS.superset?(operators)

  @allowed_filters[name] = {
    value_type: attribute.type,
    operators: operators,
    fuzzy_match: fuzzy
  }
end

.construct(definition, **options) ⇒ Object



120
121
122
123
124
125
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 120

def self.construct(definition, **options)
  return self if definition.nil?

  DSLCompiler.new(self, **options).parse(*definition)
  self
end

.constructable?Boolean

Returns:

  • (Boolean)


116
117
118
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 116

def self.constructable?
  true
end

.describe(_root = false, example: nil) ⇒ Object



208
209
210
211
212
213
214
215
216
217
218
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 208

def self.describe(_root = false, example: nil)
  hash = super
  if allowed_filters
    hash[:filters] = allowed_filters.each_with_object({}) do |(name, spec), accum|
      accum[name] = { operators: spec[:operators].to_a }
      accum[name][:fuzzy] = true if spec[:fuzzy_match]
    end
  end

  hash
end

.display_nameObject



108
109
110
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 108

def self.display_name
  'Filtering'
end

.dump(value, **_opts) ⇒ Object



204
205
206
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 204

def self.dump(value, **_opts)
  load(value).dump
end

.example(_context = Attributor::DEFAULT_ROOT_CONTEXT, **_options) ⇒ Object



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 138

def self.example(_context = Attributor::DEFAULT_ROOT_CONTEXT, **_options)
  fields = if media_type
             mt_example = media_type.example
             pickable_fields = mt_example.object.keys & allowed_filters.keys
             pickable_fields.sample(2).each_with_object([]) do |filter_name, arr|
               op = allowed_filters[filter_name][:operators].to_a.sample(1).first

               # Switch this to pick the right example attribute from the mt example
               filter_components = filter_name.to_s.split('.').map(&:to_sym)
               mapped_attribute, _enclosing_type = find_filter_attribute(filter_components, media_type)
               unless mapped_attribute
                 raise "filter with name #{filter_name} does not correspond to an existing field inside " \
                       " MediaType #{media_type.name}"
               end
               if NOVALUE_OPERATORS.include?(op)
                 arr << "#{filter_name}#{op}" # Do not add a value for the operators that don't take it
               else
                 attr_example = filter_components.inject(mt_example) do |last, name|
                   # we can safely do sends, since we've verified the components are valid
                   last.send(name)
                 end
                 arr << "#{filter_name}#{op}#{attr_example}"
               end
             end.join('&')
           else
             'name=Joe&date>2017-01-01'
           end
  load(fields)
end

.familyObject



112
113
114
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 112

def self.family
  'string'
end

.find_filter_attribute(name_components, type) ⇒ Object



127
128
129
130
131
132
133
134
135
136
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 127

def self.find_filter_attribute(name_components, type)
  type = type.member_type if type < Attributor::Collection
  first, *rest = name_components
  first_attr = type.attributes[first]
  raise "Error, you've requested to filter by field '#{first}' which does not exist in the #{type.name} mediatype!\n" unless first_attr

  return find_filter_attribute(rest, first_attr.type) if rest.present?

  [first_attr, type] # Return the attribute and associated enclosing type
end

.for(media_type, **_opts) ⇒ Object



61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 61

def for(media_type, **_opts)
  unless media_type < Praxis::MediaType
    raise ArgumentError, "Invalid type: #{media_type.name} for Filters. " \
      'Using the .for method for defining a filter, requires passing a subclass of a MediaType'
  end

  ::Class.new(self) do
    @media_type = media_type
    @allowed_filters = {}
    @allowed_leaves = {}
  end
end

.json_schema_typeObject



74
75
76
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 74

def json_schema_type
  :string
end

.load(filters, _context = Attributor::DEFAULT_ROOT_CONTEXT, **_options) ⇒ Object



173
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
199
200
201
202
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 173

def self.load(filters, _context = Attributor::DEFAULT_ROOT_CONTEXT, **_options)
  return filters if filters.is_a?(native_type)
  return new if filters.nil? || filters.blank?

  parsed = Parser.new.parse(filters)

  tree = ConditionGroup.load(parsed)

  rr = tree.flattened_conditions
  accum = []
  rr.each do |spec|
    attr_name = spec[:name]
    # TODO: Do we need to CGI.unescape things? here or even before??...
    coerced = \
      if media_type
        filter_components = attr_name.to_s.split('.').map(&:to_sym)
        attr, _enclosing_type = find_filter_attribute(filter_components, media_type)
        if spec[:values].is_a?(Array)
          attr_coll = Attributor::Collection.of(attr.type)
          attr_coll.load(spec[:values])
        else
          attr.load(spec[:values])
        end
      else
        spec[:values]
      end
    accum.push(name: attr_name, op: spec[:op], value: coerced, fuzzy: spec[:fuzzies], node_object: spec[:node_object])
  end
  new(accum)
end

.nameObject



104
105
106
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 104

def self.name
  'Praxis::Types::FilteringParams'
end

.native_typeObject



100
101
102
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 100

def self.native_type
  self
end

.validate(value, context = Attributor::DEFAULT_ROOT_CONTEXT, _attribute = nil) ⇒ Object



168
169
170
171
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 168

def self.validate(value, context = Attributor::DEFAULT_ROOT_CONTEXT, _attribute = nil)
  instance = load(value, context)
  instance.validate(context)
end

Instance Method Details

#allowed_filtersObject



297
298
299
300
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 297

def allowed_filters
  # Class method defined by the subclassing Class (using .for)
  self.class.allowed_filters
end

#allowed_leavesObject



302
303
304
305
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 302

def allowed_leaves
  # Class method defined by the subclassing Class (using .for)
  self.class.allowed_leaves
end

#dumpObject

Dump back string parseable form



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 264

def dump
  parsed_array.each_with_object([]) do |item, arr|
    field = item[:name]
    value = \
      if item[:value].is_a?(Array)
        item[:value].map.with_index do |i, idx|
          case item[:fuzzy][idx]
          when nil
            i
          when :start
            "*#{i}"
          when :end
            "#{i}*"
          end
        end.join(',')
      else
        case item[:fuzzy]
        when nil
          item[:value]
        when :start
          "*#{item[:value]}"
        when :end
          "#{item[:value]}*"
        end
      end
    arr << "#{field}#{item[:op]}#{value}"
  end.join('&')
end

#each(&block) ⇒ Object



293
294
295
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 293

def each(&block)
  parsed_array&.each(&block)
end

#matching_leaf_filter(filter_string) ⇒ Object



224
225
226
227
228
229
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 224

def matching_leaf_filter(filter_string)
  return nil unless allowed_leaves.keys.present?

  last_component = filter_string.to_s.split('.').last.to_sym
  allowed_leaves[last_component]
end

#validate(_context = Attributor::DEFAULT_ROOT_CONTEXT) ⇒ Object



231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/praxis/extensions/attribute_filtering/filtering_params.rb', line 231

def validate(_context = Attributor::DEFAULT_ROOT_CONTEXT)
  # Treat a blank block definition for the filters, as a way to allow any valid filter, on any operator and fuz
  # Obviously, the filter names need to be valid, but that's checked below.
  # Also, in some circumstances, you'd need to make sure there is a filters_map entry for the ones that aren't directly translatable to query associations/columns
  return [] if allowed_filters.blank? && allowed_leaves.blank?

  parsed_array.each_with_object([]) do |item, errors|
    attr_name = item[:name]
    attr_filters = allowed_filters[attr_name]
    unless attr_filters
      # does not match a complete filter, let's check if it matches an 'any' filter on the last component
      attr_filters = matching_leaf_filter(attr_name)
      unless attr_filters
        msg = "Filtering by #{attr_name} is not allowed. You can filter by #{allowed_filters.keys.map(&:to_s).join(', ')}"
        msg += " or leaf attributes matching #{allowed_leaves.keys.map(&:to_s).join(', ')}" if allowed_leaves.keys.presence
        errors << msg
        next
      end
    end
    allowed_operators = attr_filters[:operators]
    errors << "Operator #{item[:op]} not allowed for filter #{attr_name}" unless allowed_operators.include?(item[:op])
    value_type = attr_filters[:value_type]
    next unless value_type == Attributor::String

    next unless item[:value].presence

    fuzzy_match = attr_filters[:fuzzy_match]
    # If fuzzy matches aren't allowed, but there is one passed in (or in the case of a multimatch, any of the ones in it), we disallow it
    errors << "Fuzzy matching for #{attr_name} is not allowed (yet '*' was found in the value)" if item[:fuzzy] && !fuzzy_match && !(item[:fuzzy].is_a?(Array) && item[:fuzzy].compact.empty?)
  end
end