praxis 0.21 → 2.0.pre.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +8 -15
  3. data/CHANGELOG.md +328 -299
  4. data/CONTRIBUTING.md +4 -4
  5. data/README.md +11 -9
  6. data/lib/api_browser/app/js/directives/attribute_table.js +2 -1
  7. data/lib/api_browser/app/js/directives/conditional_requirements.js +13 -0
  8. data/lib/api_browser/app/js/directives/type_placeholder.js +10 -1
  9. data/lib/api_browser/app/js/factories/normalize_attributes.js +4 -2
  10. data/lib/api_browser/app/js/factories/template_for.js +5 -2
  11. data/lib/api_browser/app/js/filters/has_requirement.js +14 -0
  12. data/lib/api_browser/app/js/filters/tag_requirement.js +13 -0
  13. data/lib/api_browser/app/sass/praxis.scss +11 -0
  14. data/lib/api_browser/app/views/action.html +2 -2
  15. data/lib/api_browser/app/views/directives/attribute_description/member_options.html +2 -2
  16. data/lib/api_browser/app/views/directives/attribute_table.html +1 -1
  17. data/lib/api_browser/app/views/type.html +1 -1
  18. data/lib/api_browser/app/views/type/details.html +2 -2
  19. data/lib/api_browser/app/views/types/embedded/array.html +2 -0
  20. data/lib/api_browser/app/views/types/embedded/default.html +3 -1
  21. data/lib/api_browser/app/views/types/embedded/requirements.html +6 -0
  22. data/lib/api_browser/app/views/types/embedded/single_req.html +9 -0
  23. data/lib/api_browser/app/views/types/embedded/struct.html +14 -2
  24. data/lib/api_browser/app/views/types/standalone/array.html +1 -1
  25. data/lib/api_browser/app/views/types/standalone/struct.html +2 -1
  26. data/lib/api_browser/package.json +1 -1
  27. data/lib/praxis.rb +9 -3
  28. data/lib/praxis/action_definition.rb +1 -1
  29. data/lib/praxis/action_definition/headers_dsl_compiler.rb +1 -1
  30. data/lib/praxis/application.rb +1 -9
  31. data/lib/praxis/bootloader.rb +1 -4
  32. data/lib/praxis/config.rb +1 -1
  33. data/lib/praxis/dispatcher.rb +10 -6
  34. data/lib/praxis/docs/generator.rb +2 -1
  35. data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +180 -0
  36. data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +273 -0
  37. data/lib/praxis/extensions/attribute_filtering/sequel_filter_query_builder.rb +125 -0
  38. data/lib/praxis/extensions/field_selection.rb +1 -9
  39. data/lib/praxis/extensions/field_selection/active_record_query_selector.rb +51 -0
  40. data/lib/praxis/extensions/field_selection/sequel_query_selector.rb +61 -0
  41. data/lib/praxis/extensions/rails_compat.rb +2 -0
  42. data/lib/praxis/extensions/rails_compat/request_methods.rb +19 -0
  43. data/lib/praxis/handlers/xml.rb +1 -1
  44. data/lib/praxis/mapper/active_model_compat.rb +98 -0
  45. data/lib/praxis/mapper/resource.rb +242 -0
  46. data/lib/praxis/mapper/selector_generator.rb +149 -0
  47. data/lib/praxis/mapper/sequel_compat.rb +76 -0
  48. data/lib/praxis/media_type_identifier.rb +2 -1
  49. data/lib/praxis/middleware_app.rb +20 -2
  50. data/lib/praxis/multipart/parser.rb +14 -2
  51. data/lib/praxis/notifications.rb +1 -1
  52. data/lib/praxis/plugins/mapper_plugin.rb +64 -0
  53. data/lib/praxis/plugins/rails_plugin.rb +104 -0
  54. data/lib/praxis/request.rb +7 -1
  55. data/lib/praxis/request_superclassing.rb +11 -0
  56. data/lib/praxis/resource_definition.rb +5 -5
  57. data/lib/praxis/response.rb +1 -1
  58. data/lib/praxis/route.rb +1 -1
  59. data/lib/praxis/routing_config.rb +1 -1
  60. data/lib/praxis/trait.rb +1 -1
  61. data/lib/praxis/types/media_type_common.rb +2 -2
  62. data/lib/praxis/types/multipart.rb +1 -1
  63. data/lib/praxis/types/multipart_array.rb +2 -2
  64. data/lib/praxis/types/multipart_array/part_definition.rb +1 -1
  65. data/lib/praxis/version.rb +1 -1
  66. data/praxis.gemspec +14 -13
  67. data/spec/functional_spec.rb +4 -7
  68. data/spec/praxis/action_definition_spec.rb +1 -1
  69. data/spec/praxis/application_spec.rb +1 -1
  70. data/spec/praxis/collection_spec.rb +3 -2
  71. data/spec/praxis/config_spec.rb +2 -2
  72. data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +106 -0
  73. data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +147 -0
  74. data/spec/praxis/extensions/field_selection/support/spec_resources_active_model.rb +130 -0
  75. data/spec/praxis/extensions/field_selection/support/spec_resources_sequel.rb +106 -0
  76. data/spec/praxis/handlers/xml_spec.rb +2 -2
  77. data/spec/praxis/mapper/resource_spec.rb +169 -0
  78. data/spec/praxis/mapper/selector_generator_spec.rb +293 -0
  79. data/spec/praxis/media_type_spec.rb +0 -10
  80. data/spec/praxis/middleware_app_spec.rb +29 -9
  81. data/spec/praxis/request_stages/action_spec.rb +8 -1
  82. data/spec/praxis/response_definition_spec.rb +7 -4
  83. data/spec/praxis/response_spec.rb +1 -1
  84. data/spec/praxis/responses/internal_server_error_spec.rb +2 -2
  85. data/spec/praxis/responses/validation_error_spec.rb +2 -2
  86. data/spec/praxis/router_spec.rb +1 -1
  87. data/spec/spec_app/app/controllers/instances.rb +1 -1
  88. data/spec/spec_app/config/environment.rb +3 -21
  89. data/spec/spec_helper.rb +11 -15
  90. data/spec/support/be_deep_equal_matcher.rb +39 -0
  91. data/spec/support/spec_resources.rb +124 -0
  92. data/tasks/thor/templates/generator/empty_app/Gemfile +3 -3
  93. metadata +102 -77
  94. data/.ruby-version +0 -1
  95. data/lib/praxis/extensions/mapper_selectors.rb +0 -16
  96. data/lib/praxis/media_type_collection.rb +0 -127
  97. data/lib/praxis/plugins/praxis_mapper_plugin.rb +0 -246
  98. data/lib/praxis/stats.rb +0 -113
  99. data/spec/praxis/media_type_collection_spec.rb +0 -157
  100. data/spec/praxis/plugins/praxis_mapper_plugin_spec.rb +0 -142
  101. data/spec/praxis/stats_spec.rb +0 -9
  102. data/spec/spec_app/app/models/person.rb +0 -3
@@ -76,8 +76,17 @@ module Praxis
76
76
  @action = action
77
77
  @request = request
78
78
 
79
- payload = {request: request, response: nil}
79
+ payload = {request: request, response: nil, controller: @controller}
80
80
 
81
+ instrumented_dispatch( payload )
82
+
83
+ ensure
84
+ @controller = nil
85
+ @action = nil
86
+ @request = nil
87
+ end
88
+
89
+ def instrumented_dispatch( payload )
81
90
  Notifications.instrument 'praxis.request.all'.freeze, payload do
82
91
  begin
83
92
  # the response stage must be the final stage in the list
@@ -100,13 +109,8 @@ module Praxis
100
109
  @application.error_handler.handle!(request, e)
101
110
  end
102
111
  end
103
- ensure
104
- @controller = nil
105
- @action = nil
106
- @request = nil
107
112
  end
108
113
 
109
-
110
114
  # TODO: fix for multithreaded environments
111
115
  def reset_cache!
112
116
  return unless Praxis::Blueprint.caching_enabled?
@@ -20,7 +20,8 @@ module Praxis
20
20
  Attributor::Integer,
21
21
  Attributor::Object,
22
22
  Attributor::String,
23
- Attributor::Symbol
23
+ Attributor::Symbol,
24
+ Attributor::URI,
24
25
  ]).freeze
25
26
 
26
27
 
@@ -0,0 +1,180 @@
1
+ module Praxis
2
+ module Extensions
3
+ class ActiveRecordFilterQueryBuilder
4
+ attr_reader :query, :table, :model
5
+
6
+ # Abstract class, which needs to be used by subclassing it through the .for method, to set the mapping of attributes
7
+ class << self
8
+ def for(definition)
9
+ Class.new(self) do
10
+ @attr_to_column = case definition
11
+ when Hash
12
+ definition
13
+ when Array
14
+ definition.each_with_object({}) { |item, hash| hash[item.to_sym] = item }
15
+ else
16
+ raise "Cannot use FilterQueryBuilder.of without passing an array or a hash (Got: #{definition.class.name})"
17
+ end
18
+ class << self
19
+ attr_reader :attr_to_column
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ # Base query to build upon
26
+ def initialize(query: , model:)
27
+ @query = query
28
+ @table = model.table_name
29
+ @last_join_alias = model.table_name
30
+ @alias_counter = 0;
31
+ end
32
+
33
+ def pick_alias( name )
34
+ @alias_counter += 1
35
+ "#{name}#{@alias_counter}"
36
+ end
37
+
38
+ def build_clause(filters)
39
+ filters.each do |item|
40
+ attr = item[:name]
41
+ spec = item[:specs]
42
+ column_name = attr_to_column[attr]
43
+ raise "Filtering by #{attr} not allowed (no mapping found)" unless column_name
44
+ if column_name.is_a?(Proc)
45
+ bindings = column_name.call(spec)
46
+ # A hash of bindings, consisting of a key with column name and a value to the query value
47
+ bindings.each do|col,val|
48
+ assoc_or_field, *rest = col.to_s.split('.')
49
+ expand_binding(column_name: assoc_or_field, rest: rest, op: spec[:op], value: val, use_this_name_for_clause: @last_join_alias)
50
+ end
51
+ else
52
+ assoc_or_field, *rest = column_name.to_s.split('.')
53
+ expand_binding(column_name: assoc_or_field, rest: rest, **spec, use_this_name_for_clause: @last_join_alias)
54
+ end
55
+ end
56
+ query
57
+ end
58
+
59
+ # TODO: Support more relationship types (including things like polymorphic..etc)
60
+ def do_join(query, assoc , source_alias, table_alias)
61
+ reflection = query.reflections[assoc.to_s]
62
+ do_join_reflection( query, reflection, source_alias, table_alias )
63
+ end
64
+
65
+ def do_join_reflection( query, reflection, source_alias, table_alias )
66
+ c = query.connection
67
+ case reflection
68
+ when ActiveRecord::Reflection::BelongsToReflection
69
+ join_clause = "INNER JOIN %s as %s ON %s.%s = %s.%s " % \
70
+ [c.quote_table_name(reflection.klass.table_name),
71
+ c.quote_table_name(table_alias),
72
+ c.quote_table_name(table_alias),
73
+ c.quote_column_name(reflection.association_primary_key),
74
+ c.quote_table_name(source_alias),
75
+ c.quote_column_name(reflection.association_foreign_key)
76
+ ]
77
+ query.joins(join_clause)
78
+ when ActiveRecord::Reflection::HasManyReflection
79
+ # join_clause = "INNER JOIN #{reflection.klass.table_name} as #{table_alias} ON" + \
80
+ # " \"#{source_alias}\".\"id\" = \"#{table_alias}\".\"#{reflection.foreign_key}\" "
81
+ join_clause = "INNER JOIN %s as %s ON %s.%s = %s.%s " % \
82
+ [c.quote_table_name(reflection.klass.table_name),
83
+ c.quote_table_name(table_alias),
84
+ c.quote_table_name(source_alias),
85
+ c.quote_column_name(reflection.active_record.primary_key),
86
+ c.quote_table_name(table_alias),
87
+ c.quote_column_name(reflection.foreign_key)
88
+ ]
89
+
90
+ if reflection.type # && reflection.options[:as]....
91
+ # addition = " AND \"#{table_alias}\".\"#{reflection.type}\" = \'#{reflection.active_record.class_name}\'"
92
+ addition = " AND %s.%s = %s" % \
93
+ [ c.quote_table_name(table_alias),
94
+ c.quote_table_name(reflection.type),
95
+ c.quote(reflection.active_record.class_name)]
96
+
97
+ join_clause += addition
98
+ end
99
+ query.joins(join_clause)
100
+ when ActiveRecord::Reflection::ThroughReflection
101
+ #puts "TODO: choose different alias (based on matching table type...)"
102
+ talias = pick_alias(reflection.through_reflection.table_name)
103
+ salias = source_alias
104
+
105
+ query = do_join_reflection(query, reflection.through_reflection, salias, talias)
106
+ #puts "TODO: choose different alias ?????????"
107
+ salias = talias
108
+
109
+ through_model = reflection.through_reflection.klass
110
+ through_assoc = reflection.name
111
+ final_reflection = reflection.source_reflection
112
+
113
+ do_join_reflection(query, final_reflection, salias, table_alias)
114
+ else
115
+ raise "Joins for this association type are currently UNSUPPORTED: #{reflection.inspect}"
116
+ end
117
+ end
118
+
119
+ def expand_binding(column_name:,rest: , op:,value:, use_this_name_for_clause: column_name)
120
+ unless rest.empty?
121
+ joined_alias = pick_alias(column_name)
122
+ @query = do_join(query, column_name, @last_join_alias, joined_alias)
123
+ saved_join_alias = @last_join_alias
124
+ @last_join_alias = joined_alias
125
+ new_column_name, *new_rest = rest
126
+ expand_binding(column_name: new_column_name, rest: new_rest, op: op, value: value, use_this_name_for_clause: joined_alias)
127
+ @last_join_alias = saved_join_alias
128
+ else
129
+ column_name = "#{use_this_name_for_clause}.#{column_name}"
130
+ add_clause(column_name: column_name, op: op, value: value)
131
+ end
132
+ end
133
+
134
+ def attr_to_column
135
+ # Class method defined by the subclassing Class (using .for)
136
+ self.class.attr_to_column
137
+ end
138
+
139
+ # Private to try to funnel all column names through `build_clause` that restricts
140
+ # the attribute names better (to allow more difficult SQL injections )
141
+ private def add_clause(column_name:, op:, value:)
142
+ likeval = get_like_value(value)
143
+ @query = case op
144
+ when '='
145
+ if likeval
146
+ query.where("#{column_name} LIKE ?", likeval)
147
+ else
148
+ query.where(column_name => value)
149
+ end
150
+ when '!='
151
+ if likeval
152
+ query.where("#{column_name} NOT LIKE ?", likeval)
153
+ else
154
+ query.where.not(column_name => value)
155
+ end
156
+ when '>'
157
+ query.where("#{column_name} > ?", value)
158
+ when '<'
159
+ query.where("#{column_name} < ?", value)
160
+ when '>='
161
+ query.where("#{column_name} >= ?", value)
162
+ when '<='
163
+ query.where("#{column_name} <= ?", value)
164
+ else
165
+ raise "Unsupported Operator!!! #{op}"
166
+ end
167
+ end
168
+
169
+ # Returns nil if the value was not a fuzzzy pattern
170
+ def get_like_value(value)
171
+ if value.is_a?(String) && (value[-1] == '*' || value[0] == '*')
172
+ likeval = value.dup
173
+ likeval[-1] = '%' if value[-1] == '*'
174
+ likeval[0] = '%' if value[0] == '*'
175
+ likeval
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+ # rubocop:disable all
3
+ #
4
+ # Attributor type to define and handlea simple language to express filtering attributes in listings.
5
+ # Commonly used in a query string parameter value for listing calls.
6
+ #
7
+ # The type allows you to restrict the allowable fields (and their types) based on an existing Mediatype.
8
+ # It also alows you to define exacly what fields (from that MediaType) are allowed, an what operations are
9
+ # supported for each of them. Includes most in/equalities and fuzzy matching options(i.e., leading/trailing `*` )
10
+ #
11
+ # Example syntax: `status=open&time>2001-1-1&name=*Bar`
12
+ #
13
+ # Example use and definition of the type:
14
+ # attribute :filters,
15
+ # Types::FilteringParams.for(MediaTypes::MyType) do
16
+ # filter 'user.id', using: ['=', '!=']
17
+ # filter 'name', using: ['=', '!=']
18
+ # filter 'children.created_at', using: ['>', '>=', '<', '<=']
19
+ # filter 'display_name', using: ['=', '!='], fuzzy: true
20
+ # end
21
+
22
+ module Praxis
23
+ module Extensions
24
+ module AttributeFiltering
25
+ class FilteringParams
26
+ include Attributor::Type
27
+ include Attributor::Dumpable
28
+
29
+ # This DSL allows to define which attributes are allowed in the filters, and with which operators
30
+ class DSLCompiler < Attributor::DSLCompiler
31
+ # "account.id": { operators: ["=", "!="] },
32
+ # name: { operators: ["=", "!="], fuzzy_match: true },
33
+ # start_date: { operators: ["!=", ">=", "<=", "=", "<", ">"] }
34
+ #
35
+ def filter(name, using: nil, fuzzy: false)
36
+ target.add_filter(name.to_sym, operators: Set.new(using), fuzzy: fuzzy)
37
+ end
38
+ end
39
+
40
+ VALUE_REGEX = /[^,&]*/
41
+ AVAILABLE_OPERATORS = Set.new(['!=', '>=', '<=', '=', '<', '>']).freeze
42
+ FILTER_REGEX = /(?<attribute>([^=!><])+)(?<operator>!=|>=|<=|=|<|>)(?<value>#{VALUE_REGEX}(,#{VALUE_REGEX})*)/
43
+
44
+ # Abstract class, which needs to be used by subclassing it through the .for method, to set the allowed filters
45
+ # definition should be a hash, keyed by field name, which contains a hash that can have two pieces of metadata
46
+ # :operators => an array of operators allowed (if empty, means all)
47
+ # :value_type => a type class which the value should match
48
+ # :fuzzy_match => weather or not we allow a "like" type query (for prefix or suffix matching)
49
+ class << self
50
+ attr_reader :media_type
51
+ attr_reader :allowed_filters
52
+
53
+ def for(media_type, **_opts)
54
+ unless media_type < Praxis::MediaType
55
+ raise ArgumentError, "Invalid type: #{media_type.name} for Filters. " \
56
+ 'Must be a subclass of MediaType'
57
+ end
58
+
59
+ ::Class.new(self) do
60
+ @media_type = media_type
61
+ @allowed_filters = {}
62
+ end
63
+ end
64
+
65
+ def add_filter(name, operators:, fuzzy:)
66
+ components = name.to_s.split('.').map(&:to_sym)
67
+ attribute, enclosing_type = find_filter_attribute(components, media_type)
68
+ raise 'Invalid set of operators passed' unless AVAILABLE_OPERATORS.superset?(operators)
69
+
70
+ @allowed_filters[name] = {
71
+ value_type: attribute.type,
72
+ operators: operators,
73
+ fuzzy_match: fuzzy
74
+ }
75
+ end
76
+ end
77
+
78
+ attr_reader :parsed_array
79
+
80
+ def self.native_type
81
+ self
82
+ end
83
+
84
+ def self.name
85
+ 'Praxis::Types::FilteringParams'
86
+ end
87
+
88
+ def self.display_name
89
+ 'Filtering'
90
+ end
91
+
92
+ def self.family
93
+ 'string'
94
+ end
95
+
96
+ def self.constructable?
97
+ true
98
+ end
99
+
100
+ def self.construct(definition, **options)
101
+ return self if definition.nil?
102
+
103
+ DSLCompiler.new(self, options).parse(*definition)
104
+ self
105
+ end
106
+
107
+ def self.find_filter_attribute(name_components, type)
108
+ type = type.member_type if type < Attributor::Collection
109
+ first, *rest = name_components
110
+ first_attr = type.attributes[first]
111
+ unless first_attr
112
+ raise "Error, you've requested to filter by field #{first} which does not exist in the #{type.name} mediatype!\n"
113
+ end
114
+
115
+ return find_filter_attribute(rest, first_attr.type) if rest.present?
116
+
117
+ [first_attr, type] # Return the attribute and associated enclosing type
118
+ end
119
+
120
+ def self.example(_context = Attributor::DEFAULT_ROOT_CONTEXT, **_options)
121
+ fields = if media_type
122
+ mt_example = media_type.example
123
+ pickable_fields = mt_example.object.keys & allowed_filters.keys
124
+ pickable_fields.sample(2).each_with_object([]) do |filter_name, arr|
125
+ op = allowed_filters[filter_name][:operators].to_a.sample(1).first
126
+
127
+ # Switch this to pick the right example attribute from the mt example
128
+ filter_components = filter_name.to_s.split('.').map(&:to_sym)
129
+ mapped_attribute, _enclosing_type = find_filter_attribute(filter_components, media_type)
130
+ unless mapped_attribute
131
+ raise "filter with name #{filter_name} does not correspond to an existing field inside " \
132
+ " MediaType #{media_type.name}"
133
+ end
134
+ attr_example = filter_components.inject(mt_example) do |last, name|
135
+ # we can safely do sends, since we've verified the components are valid
136
+ last.send(name)
137
+ end
138
+ arr << "#{filter_name}#{op}#{attr_example}"
139
+ end.join('&')
140
+ else
141
+ 'name=Joe&date>2017-01-01'
142
+ end
143
+ load(fields)
144
+ end
145
+
146
+ def self.validate(value, context = Attributor::DEFAULT_ROOT_CONTEXT, _attribute = nil)
147
+ instance = load(value, context)
148
+ instance.validate(context)
149
+ end
150
+
151
+ def self.load(filters, _context = Attributor::DEFAULT_ROOT_CONTEXT, **_options)
152
+ return filters if filters.is_a?(native_type)
153
+ return new if filters.nil?
154
+ parsed = filters.split('&').each_with_object([]) do |filter_string, arr|
155
+ match = FILTER_REGEX.match(filter_string)
156
+ values = CGI.unescape(match[:value]).split(',')
157
+ value = if values.size > 1
158
+ multimatch = true
159
+ values
160
+ else
161
+ multimatch = false
162
+ values.first
163
+ end
164
+
165
+ attr_name = match[:attribute].to_sym
166
+ # TODO: we should coerce values if there's a mediatype defined?
167
+ coerced = if media_type
168
+ filter_components = attr_name.to_s.split('.').map(&:to_sym)
169
+ attr, _enclosing_type = find_filter_attribute(filter_components, media_type)
170
+ if multimatch
171
+ attr_coll = Attributor::Collection.of(attr.type)
172
+ attr_coll.load(value)
173
+ else
174
+ attr.load(value)
175
+ end
176
+ else
177
+ value
178
+ end
179
+ arr.push(name: attr_name, specs: { op: match[:operator], value: coerced } )
180
+ end
181
+ new(parsed)
182
+ end
183
+
184
+ def self.dump(value, **_opts)
185
+ load(value).dump
186
+ end
187
+
188
+ def self.describe(_root = false, example: nil)
189
+ hash = super
190
+ if allowed_filters
191
+ hash[:filters] = allowed_filters.each_with_object({}) do |(name, spec), accum|
192
+ accum[name] = { operators: spec[:operators].to_a }
193
+ accum[name][:fuzzy] = true if spec[:fuzzy_match]
194
+ end
195
+ end
196
+
197
+ hash
198
+ end
199
+
200
+ def initialize(parsed = [])
201
+ @parsed_array = parsed
202
+ end
203
+
204
+ def validate(_context = Attributor::DEFAULT_ROOT_CONTEXT)
205
+ parsed_array.each_with_object([]) do |item, errors|
206
+ attr_name = item[:name]
207
+ specs = item[:specs]
208
+ attr_filters = allowed_filters[attr_name]
209
+ unless attr_filters
210
+ errors << "Filtering by #{attr_name} is not allowed. You can filter by #{allowed_filters.keys.map(&:to_s).join(', ')}"
211
+ next
212
+ end
213
+ allowed_operators = attr_filters[:operators]
214
+ unless allowed_operators.include?(specs[:op])
215
+ errors << "Operator #{specs[:op]} not allowed for filter #{attr_name}"
216
+ end
217
+ value_type = attr_filters[:value_type]
218
+ value = specs[:value]
219
+ if value_type && !value_type.valid_type?(value)
220
+ # Allow a collection of values of the right type for multimatch (if operators are = or !=)
221
+ if ['=','!='].include?(specs[:op])
222
+ coll_type = Attributor::Collection.of(value_type)
223
+ if !coll_type.valid_type?(value)
224
+ errors << "Invalid type in filter/s value for #{attr_name} " +\
225
+ "(one or more of the multiple matches in #{value} are not a #{value_type.name.split('::').last})"
226
+ end
227
+ else
228
+ errors << "Invalid type in filter value for #{attr_name} (#{value} using '#{specs[:op]}' is not a #{value_type.name.split('::').last})"
229
+ end
230
+ end
231
+
232
+ next unless value_type == Attributor::String
233
+ unless value.empty?
234
+ fuzzy_match = attr_filters[:fuzzy_match]
235
+ if (value[-1] == '*' || value[0] == '*') && !fuzzy_match
236
+ errors << "Fuzzy matching for #{attr_name} is not allowed (yet '*' was found in the value)"
237
+ end
238
+ end
239
+ end
240
+ end
241
+
242
+ # Dump back string parseable form
243
+ def dump
244
+ parsed_array.each_with_object([]) do |item, arr|
245
+ field = item[:name]
246
+ spec = item[:specs]
247
+ arr << "#{field}#{spec[:op]}#{spec[:value]}"
248
+ end.join('&')
249
+ end
250
+
251
+ def each
252
+ parsed_array&.each do |filter|
253
+ yield filter
254
+ end
255
+ end
256
+
257
+ def allowed_filters
258
+ # Class method defined by the subclassing Class (using .for)
259
+ self.class.allowed_filters
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
265
+
266
+ # Alias it to a much shorter and sweeter name in the Types namespace.
267
+ module Praxis
268
+ module Types
269
+ FilteringParams = Praxis::Extensions::AttributeFiltering::FilteringParams
270
+ end
271
+ end
272
+
273
+ # rubocop:enable all