Class: RESTFramework::Filters::QueryFilter

Inherits:
BaseFilter
  • Object
show all
Defined in:
lib/rest_framework/filters/query_filter.rb

Overview

A simple filtering backend that supports filtering a recordset based on query parameters.

Defined Under Namespace

Classes: Not

Constant Summary collapse

PREDICATES =
{
  true: true,
  false: false,
  null: nil,
  lt: ->(f, v) { { f => ...v } },
  # `gt` must negate `lte` because Rails doesn't support `>` with endless ranges.
  # Ref: https://github.com/rails/rails/pull/47345
  gt: ->(f, v) { Not.new({ f => ..v }) },
  lte: ->(f, v) { { f => ..v } },
  gte: ->(f, v) { { f => v.. } },
  not: ->(f, v) { Not.new({ f => v }) },
  cont: ->(f, v) {
    [
      "#{ActiveRecord::Base.connection.quote_column_name(f)} LIKE ?", "%#{
        ActiveRecord::Base.sanitize_sql_like(v)
      }%"
    ]
  },
  in: ->(f, v) {
    if v.is_a?(Array)
      { f => v.map { |el| el == "null" ? nil : el } }
    elsif v.is_a?(String)
      { f => v.split(",").map { |el| el == "null" ? nil : el } }
    end
  },
}.freeze
PREDICATES_REGEX =
/^(.*)_(#{PREDICATES.keys.join("|")})$/
ARRAY_PREDICATES =

Predicates whose value may be an array (e.g. ?id_in[]=1&id_in[]=2). Every other predicate operates on a single scalar and skips array input, which would otherwise raise: cont in sanitize_sql_like, the range predicates while casting the endpoint.

%i[in not].freeze

Instance Method Summary collapse

Methods inherited from BaseFilter

#_polymorphic_columns, _safe_query_value?, #initialize

Constructor Details

This class inherits a constructor from RESTFramework::Filters::BaseFilter

Instance Method Details

#_get_fieldsObject



39
40
41
42
# File 'lib/rest_framework/filters/query_filter.rb', line 39

def _get_fields
  # Always return a list of strings; `@controller.readable_columns_or_associations` already does.
  @controller.class.filter_fields&.map(&:to_s) || @controller.readable_columns_or_associations
end

#_get_query_configObject

Filter params for keys allowed by the current action's filter_fields/fields config and return a query config in the form of: [base_query, pred_queries, includes].



58
59
60
61
62
63
64
65
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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/rest_framework/filters/query_filter.rb', line 58

def _get_query_config
  fields = self._get_fields
  poly_columns = self._polymorphic_columns(@controller.class.filter_fields)
  includes = []

  # Predicate queries must be added to a separate list because multiple predicates can be used.
  # E.g., `age_lt=10&age_gte=5` would transform to `[{age: ...10}, {age: 5..}]` to avoid conflict
  # on the `age` key.
  pred_queries = []

  base_query = @controller.request.query_parameters.map { |field, v|
    # Skip params whose values aren't bind-safe (e.g. a user submitted
    # `?field[evil]=x`, which Rack parses into a Hash). AR can't quote
    # those, and the predicate lambdas below would also blow up on them.
    next nil unless self.class._safe_query_value?(v)

    # First, if field is a simple filterable field, return early.
    if field.in?(fields)
      next [ field, v ]
    end

    # A polymorphic association's `<name>.id`/`<name>.type` maps to a backing column on the base
    # table, so it filters directly with no JOIN (unlike other sub-fields).
    if column = poly_columns[field]
      next [ column, v ]
    end

    # First, try to parse a simple predicate and check if it is filterable.
    pred_field, predicate = self.parse_predicate(field)
    if predicate && pred_field.in?(fields)
      field = pred_field
    elsif predicate && (column = poly_columns[pred_field])
      field = column
    else
      # Last, try to parse a sub-field or sub-field w/predicate.
      root_field, sub_field = field.split(".", 2)
      _, pred_sub_field = pred_field.split(".", 2) if predicate

      # Check if sub-field or sub-field w/predicate is filterable.
      if sub_field
        next nil unless root_field.in?(fields)

        association_fields = @controller.class.field_configuration[root_field][:fields] || []
        if sub_field.in?(association_fields)
          includes << root_field.to_sym
          next [ field, v ]
        elsif pred_sub_field && pred_sub_field.in?(association_fields)
          includes << root_field.to_sym
          field = pred_field
        else
          next nil
        end
      else
        next nil
      end
    end

    # If we get here, we must have a predicate, either from a field or a sub-field. Transform the
    # value into a query that can be used in the ActiveRecord `where` API.
    cfg = PREDICATES[predicate.to_sym]
    if cfg.is_a?(Proc)
      # Skip a scalar predicate given an array value (Rack parses `?field_cont[]=a&field_cont[]=b`
      # into an array); only `in`/`not` accept arrays, the rest would raise.
      next nil if v.is_a?(Array) && !predicate.to_sym.in?(ARRAY_PREDICATES)

      pred_queries << cfg.call(field, v)
    else
      pred_queries << { field => cfg }
    end

    next nil
  }.compact.to_h.symbolize_keys

  return base_query, pred_queries, includes
end

#filter_data(data) ⇒ Object

Filter data according to the request query parameters.



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/rest_framework/filters/query_filter.rb', line 135

def filter_data(data)
  base_query, pred_queries, includes = self._get_query_config

  if base_query.any? || pred_queries.any?
    if includes.any?
      data = data.includes(*includes)
    end

    data = data.where(**base_query) if base_query.any?

    pred_queries.each do |q|
      if q.is_a?(Not)
        data = data.where.not(q.q)
      else
        data = data.where(q)
      end
    end
  end

  data
end

#parse_predicate(field) ⇒ Object

Helper to find a variation of a field using a predicate. For example, there could be a field called age, and if age_lt it passed, we should return ["age", "lt"]. Otherwise, if something like age is passed, then we should return ["age", nil].



47
48
49
50
51
52
53
54
# File 'lib/rest_framework/filters/query_filter.rb', line 47

def parse_predicate(field)
  if match = PREDICATES_REGEX.match(field)
    field = match[1]
    predicate = match[2]
  end

  return field, predicate
end