brainstem 0.2.6.1 → 1.0.0.pre.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. checksums.yaml +5 -13
  2. data/CHANGELOG.md +16 -2
  3. data/Gemfile.lock +51 -36
  4. data/README.md +531 -110
  5. data/brainstem.gemspec +6 -2
  6. data/lib/brainstem.rb +25 -9
  7. data/lib/brainstem/concerns/controller_param_management.rb +22 -0
  8. data/lib/brainstem/concerns/error_presentation.rb +58 -0
  9. data/lib/brainstem/concerns/inheritable_configuration.rb +29 -0
  10. data/lib/brainstem/concerns/lookup.rb +30 -0
  11. data/lib/brainstem/concerns/presenter_dsl.rb +111 -0
  12. data/lib/brainstem/controller_methods.rb +17 -8
  13. data/lib/brainstem/dsl/association.rb +55 -0
  14. data/lib/brainstem/dsl/associations_block.rb +12 -0
  15. data/lib/brainstem/dsl/base_block.rb +31 -0
  16. data/lib/brainstem/dsl/conditional.rb +25 -0
  17. data/lib/brainstem/dsl/conditionals_block.rb +15 -0
  18. data/lib/brainstem/dsl/configuration.rb +112 -0
  19. data/lib/brainstem/dsl/field.rb +68 -0
  20. data/lib/brainstem/dsl/fields_block.rb +25 -0
  21. data/lib/brainstem/preloader.rb +98 -0
  22. data/lib/brainstem/presenter.rb +325 -134
  23. data/lib/brainstem/presenter_collection.rb +82 -286
  24. data/lib/brainstem/presenter_validator.rb +96 -0
  25. data/lib/brainstem/query_strategies/README.md +107 -0
  26. data/lib/brainstem/query_strategies/base_strategy.rb +62 -0
  27. data/lib/brainstem/query_strategies/filter_and_search.rb +50 -0
  28. data/lib/brainstem/query_strategies/filter_or_search.rb +103 -0
  29. data/lib/brainstem/test_helpers.rb +5 -1
  30. data/lib/brainstem/version.rb +1 -1
  31. data/spec/brainstem/concerns/controller_param_management_spec.rb +42 -0
  32. data/spec/brainstem/concerns/error_presentation_spec.rb +113 -0
  33. data/spec/brainstem/concerns/inheritable_configuration_spec.rb +210 -0
  34. data/spec/brainstem/concerns/presenter_dsl_spec.rb +412 -0
  35. data/spec/brainstem/controller_methods_spec.rb +15 -27
  36. data/spec/brainstem/dsl/association_spec.rb +123 -0
  37. data/spec/brainstem/dsl/conditional_spec.rb +93 -0
  38. data/spec/brainstem/dsl/configuration_spec.rb +1 -0
  39. data/spec/brainstem/dsl/field_spec.rb +212 -0
  40. data/spec/brainstem/preloader_spec.rb +137 -0
  41. data/spec/brainstem/presenter_collection_spec.rb +565 -244
  42. data/spec/brainstem/presenter_spec.rb +726 -167
  43. data/spec/brainstem/presenter_validator_spec.rb +209 -0
  44. data/spec/brainstem/query_strategies/filter_and_search_spec.rb +46 -0
  45. data/spec/brainstem/query_strategies/filter_or_search_spec.rb +45 -0
  46. data/spec/spec_helper.rb +11 -3
  47. data/spec/spec_helpers/db.rb +32 -65
  48. data/spec/spec_helpers/presenters.rb +124 -29
  49. data/spec/spec_helpers/rr.rb +11 -0
  50. data/spec/spec_helpers/schema.rb +115 -0
  51. metadata +126 -30
  52. data/lib/brainstem/association_field.rb +0 -53
  53. data/lib/brainstem/engine.rb +0 -4
  54. data/pkg/brainstem-0.2.5.gem +0 -0
  55. data/pkg/brainstem-0.2.6.gem +0 -0
  56. data/spec/spec_helpers/cleanup.rb +0 -23
@@ -1,5 +1,5 @@
1
- require 'brainstem/association_field'
2
1
  require 'brainstem/search_unavailable_error'
2
+ require 'brainstem/presenter_validator'
3
3
 
4
4
  module Brainstem
5
5
  class PresenterCollection
@@ -12,102 +12,92 @@ module Brainstem
12
12
  # @return [Integer] The default number of objects that will be returned in the presented hash.
13
13
  attr_accessor :default_per_page
14
14
 
15
+ attr_accessor :default_max_filter_and_search_page
16
+
15
17
  # @!visibility private
16
18
  def initialize
17
19
  @default_per_page = 20
18
20
  @default_max_per_page = 200
21
+ @default_max_filter_and_search_page = 10_000 # TODO: figure out a better default and make it configurable
19
22
  end
20
23
 
21
24
  # The main presentation method, converting a model name and an optional scope into a hash structure, ready to be converted into JSON.
22
25
  # If searching, Brainstem filtering, only, pagination, and ordering are skipped and should be implemented with your search solution.
23
- # All request options are passed to the +search_block+ for your convenience.
24
- # @param [Class, String] name The class of the objects to be presented.
26
+ # All request options are passed to the +search block+ for your convenience.
27
+ # @param [Class, String] name Either the ActiveRecord Class itself, or its pluralized table name as a string.
25
28
  # @param [Hash] options The options that will be applied as the objects are converted.
26
29
  # @option options [Hash] :params The +params+ hash included in a request for the presented object.
27
30
  # @option options [ActiveRecord::Base] :model The model that is being presented (if different from +name+).
28
- # @option options [String] :as The top-level key the presented objects will be assigned to (if different from +name.tableize+)
29
31
  # @option options [Integer] :max_per_page The maximum number of items that can be requested by <code>params[:per_page]</code>.
30
32
  # @option options [Integer] :per_page The number of items that will be returned if <code>params[:per_page]</code> is not set.
31
33
  # @option options [Boolean] :apply_default_filters Determine if Presenter's filter defaults should be applied. On by default.
34
+ # @option options [Brainstem::Presenter] :primary_presenter The Presenter to use for filters and sorts. If unspecified, the +:model+ or +name+ will be used to find an appropriate Presenter.
32
35
  # @yield Must return a scope on the model +name+, which will then be presented.
33
36
  # @return [Hash] A hash of arrays of hashes. Top-level hash keys are pluralized model names, with values of arrays containing one hash per object that was found by the given given options.
34
37
  def presenting(name, options = {}, &block)
35
38
  options[:params] = HashWithIndifferentAccess.new(options[:params] || {})
39
+ check_for_old_options!(options)
40
+ set_default_filters_option!(options)
36
41
  presented_class = (options[:model] || name)
37
42
  presented_class = presented_class.classify.constantize if presented_class.is_a?(String)
38
43
  scope = presented_class.instance_eval(&block)
39
44
  count = 0
40
45
 
41
46
  # grab the presenter that knows about filters and sorting etc.
42
- options[:presenter] = for!(presented_class)
47
+ options[:primary_presenter] ||= for!(presented_class)
43
48
 
44
49
  # table name will be used to query the database for the filtered data
45
50
  options[:table_name] = presented_class.table_name
46
51
 
47
- # key these models will use in the struct that is output
48
- options[:as] = (options[:as] || name.to_s.tableize).to_sym
49
-
50
- allowed_includes = calculate_allowed_includes options[:presenter], presented_class, options[:params][:only].present?
51
- includes_hash = filter_includes options[:params][:include], allowed_includes
52
-
53
- if searching? options
54
- # Search
55
- sort_name, direction = calculate_sort_name_and_direction options
56
- scope, count, ordered_search_ids = run_search(scope, includes_hash.keys.map(&:to_s), sort_name, direction, options)
57
- else
58
- # Filter
59
- scope = run_filters scope, options
60
-
61
- if options[:params][:only].present?
62
- # Handle Only
63
- scope, count = handle_only(scope, options[:params][:only])
64
- else
65
- # Paginate
66
- scope, count = paginate scope, options
67
- end
68
-
69
- count = count.keys.length if count.is_a?(Hash)
70
-
71
- # Ordering
72
- scope = handle_ordering scope, options
73
- end
52
+ options[:default_per_page] = default_per_page
53
+ options[:default_max_per_page] = default_max_per_page
54
+ options[:default_max_filter_and_search_page] = default_max_filter_and_search_page
74
55
 
75
- # Load Includes
76
- records = scope.to_a
56
+ primary_models, count = strategy(options, scope).execute(scope)
77
57
 
78
58
  # Determine if an exception should be raised on an empty result set.
79
- if options[:raise_on_empty] && records.empty?
59
+ if options[:raise_on_empty] && primary_models.empty?
80
60
  raise options[:empty_error_class] || ActiveRecord::RecordNotFound
81
61
  end
82
62
 
83
- records = order_for_search(records, ordered_search_ids) if searching? options
84
- model = records.first
63
+ structure_response(presented_class, primary_models, count, options)
64
+ end
85
65
 
86
- models = perform_preloading records, includes_hash
87
- primary_models, associated_models = gather_associations(models, includes_hash)
88
- struct = { :count => count, options[:as] => [], :results => [] }
66
+ def structure_response(presented_class, primary_models, count, options)
67
+ # key these models will use in the struct that is output
68
+ brainstem_key = brainstem_key_for!(presented_class)
89
69
 
90
- associated_models.each do |json_name, models|
91
- models.flatten!
92
- models.uniq!
70
+ # filter the incoming :includes list by those available from this Presenter in the current context
71
+ selected_associations = filter_includes(options)
93
72
 
94
- if models.length > 0
95
- presenter = for!(models.first.class)
96
- assoc = includes_hash.to_a.find { |k, v| v.json_name == json_name }
97
- struct[json_name] = presenter.group_present(models, [])
98
- else
99
- struct[json_name] = []
100
- end
73
+ optional_fields = filter_optional_fields(options)
74
+
75
+ struct = { 'count' => count, brainstem_key => {}, 'results' => [] }
76
+
77
+ # Build top-level keys for all requested associations.
78
+ selected_associations.each do |association|
79
+ struct[brainstem_key_for!(association.target_class)] ||= {} unless association.polymorphic?
101
80
  end
102
81
 
103
82
  if primary_models.length > 0
104
- presented_primary_models = options[:presenter].group_present(models, includes_hash.keys)
105
- struct[options[:as]] += presented_primary_models
106
- struct[:results] = presented_primary_models.map { |model| { :key => options[:as].to_s, :id => model[:id] } }
83
+ associated_models = {}
84
+ presented_primary_models = options[:primary_presenter].group_present(primary_models,
85
+ selected_associations.map(&:name),
86
+ optional_fields: optional_fields,
87
+ load_associations_into: associated_models)
88
+
89
+ struct[brainstem_key] = presented_primary_models.each.with_object({}) { |model, obj| obj[model['id']] = model }
90
+ struct['results'] = presented_primary_models.map { |model| { 'key' => brainstem_key, 'id' => model['id'] } }
91
+
92
+ associated_models.each do |association_brainstem_key, associated_models_hash|
93
+ presenter = for!(associated_models_hash.values.first.class)
94
+ struct[association_brainstem_key] ||= {}
95
+ presenter.group_present(associated_models_hash.values).each do |model|
96
+ struct[association_brainstem_key][model['id']] ||= model
97
+ end
98
+ end
107
99
  end
108
100
 
109
- rewrite_keys_as_objects!(struct)
110
-
111
101
  struct
112
102
  end
113
103
 
@@ -120,271 +110,77 @@ module Brainstem
120
110
  # @param [*Class] klasses One or more classes that can be presented by +presenter_class+.
121
111
  def add_presenter_class(presenter_class, *klasses)
122
112
  klasses.each do |klass|
123
- presenters[klass.to_s] = presenter_class.new
113
+ presenters[klass.to_s] = presenter_class
124
114
  end
125
115
  end
126
116
 
127
- # @return [Brainstem::Presenter, nil] The presenter that knows how to present the class +klass+, or +nil+ if there isn't one.
117
+ # @return [Brainstem::Presenter, nil] A new instance of the Presenter that knows how to present the class +klass+, or +nil+ if there isn't one.
128
118
  def for(klass)
129
- presenters[klass.to_s]
119
+ presenters[klass.to_s].try(:new)
130
120
  end
131
121
 
132
- # @return [Brainstem::Presenter] The presenter that knows how to present the class +klass+.
133
- # @raise [ArgumentError] if there is no known presenter for +klass+.
122
+ # @return [Brainstem::Presenter] A new instance of the Presenter that knows how to present the class +klass+.
123
+ # @raise [ArgumentError] if there is no known Presenter for +klass+.
134
124
  def for!(klass)
135
125
  self.for(klass) || raise(ArgumentError, "Unable to find a presenter for class #{klass}")
136
126
  end
137
127
 
138
- private
139
-
140
- def paginate(scope, options)
141
- if options[:params][:limit].present? && options[:params][:offset].present?
142
- limit = calculate_limit(options)
143
- offset = calculate_offset(options)
144
- else
145
- limit = calculate_per_page(options)
146
- offset = limit * (calculate_page(options) - 1)
147
- end
148
-
149
- [scope.limit(limit).offset(offset).uniq, scope.select("distinct #{scope.connection.quote_table_name options[:table_name]}.id").count] # as of Rails 3.2.5, uniq.count generates the wrong SQL.
150
- end
151
-
152
- def calculate_per_page(options)
153
- per_page = [(options[:params][:per_page] || options[:per_page] || default_per_page).to_i, (options[:max_per_page] || default_max_per_page).to_i].min
154
- per_page = default_per_page if per_page < 1
155
- per_page
156
- end
157
-
158
- def calculate_page(options)
159
- [(options[:params][:page] || 1).to_i, 1].max
160
- end
161
-
162
- def calculate_limit(options)
163
- [[options[:params][:limit].to_i, 1].max, default_max_per_page].min
164
- end
165
-
166
- def calculate_offset(options)
167
- [options[:params][:offset].to_i, 0].max
168
- end
169
-
170
- # Gather allowed includes by inspecting the presented hash. For now, this requires that a new instance of the
171
- # presented class always be presentable.
172
- def calculate_allowed_includes(presenter, presented_class, is_only_query)
173
- allowed_includes = {}
174
- model = presented_class.new
175
- reflections = Brainstem::PresenterCollection.reflections(model.class)
176
- presenter.present(model).each do |k, v|
177
- next unless v.is_a?(AssociationField)
178
- next if v.restrict_to_only && !is_only_query
179
-
180
- if v.json_name
181
- v.json_name = v.json_name.tableize.to_sym
182
- else
183
- association = reflections[v.method_name.to_s]
184
- if association && !association.options[:polymorphic]
185
- v.json_name = association && association.table_name.to_sym
186
- if v.json_name.nil?
187
- raise ":json_name is a required option for method-based associations (#{presented_class}##{v.method_name})"
188
- end
189
- end
190
- end
191
- allowed_includes[k.to_s] = v
192
- end
193
- allowed_includes
194
- end
195
-
196
- def filter_includes(user_includes, allowed_includes)
197
- filtered_includes = {}
198
-
199
- (user_includes || '').split(',').each do |k|
200
- allowed = allowed_includes[k]
201
- if allowed
202
- filtered_includes[k] = allowed
203
- end
204
- end
205
- filtered_includes
206
- end
207
-
208
- def handle_only(scope, only)
209
- ids = (only || "").split(",").select {|id| id =~ /\A\d+\Z/}.uniq
210
- [scope.where(:id => ids), scope.where(:id => ids).count]
128
+ def brainstem_key_for!(klass)
129
+ presenter = presenters[klass.to_s]
130
+ raise(ArgumentError, "Unable to find a presenter for class #{klass}") unless presenter
131
+ presenter.configuration[:brainstem_key] || klass.table_name
211
132
  end
212
133
 
213
- def run_filters(scope, options)
214
- extract_filters(options).each do |filter_name, arg|
215
- next if arg.nil?
216
- filter_lambda = options[:presenter].filters[filter_name][1]
217
-
218
- if filter_lambda
219
- scope = filter_lambda.call(scope, arg)
220
- else
221
- scope = scope.send(filter_name, arg)
134
+ # @raise [StandardError] if any presenter in this collection is invalid.
135
+ def validate!
136
+ errors = []
137
+ presenters.each do |name, klass|
138
+ validator = Brainstem::PresenterValidator.new(klass)
139
+ unless validator.valid?
140
+ errors += validator.errors.full_messages.map { |error| "#{name}: #{error}" }
222
141
  end
223
142
  end
224
-
225
- scope
143
+ raise "PresenterCollection invalid:\n - #{errors.join("\n - ")}" if errors.length > 0
226
144
  end
227
145
 
228
- def extract_filters(options)
229
- filters_hash = {}
230
- run_defaults = options.has_key?(:apply_default_filters) ? options[:apply_default_filters] : true
231
-
232
- (options[:presenter].filters || {}).each do |filter_name, filter|
233
- requested = options[:params][filter_name]
234
- requested = requested.is_a?(Array) ? requested : (requested.present? ? requested.to_s : nil)
235
- requested = requested == "true" ? true : (requested == "false" ? false : requested)
146
+ private
236
147
 
237
- filter_options = filter[0]
238
- args = run_defaults && requested.nil? ? filter_options[:default] : requested
239
- filters_hash[filter_name] = args unless args.nil?
240
- end
148
+ def strategy(options, scope)
149
+ strat = options[:primary_presenter].get_query_strategy
241
150
 
242
- filters_hash
243
- end
244
-
245
- # Runs the current search_block and returns an array of [scope of the resulting ids, result count, result ids]
246
- # If the search_block returns a falsy value a SearchUnavailableError is raised.
247
- # Your search block should return a list of ids and the count of ids found, or false if search is unavailable.
248
- def run_search(scope, includes, sort_name, direction, options)
249
- return scope unless searching? options
250
-
251
- search_options = HashWithIndifferentAccess.new(
252
- :include => includes,
253
- :order => { :sort_order => sort_name, :direction => direction },
254
- )
255
-
256
- if options[:params][:limit].present? && options[:params][:offset].present?
257
- search_options[:limit] = calculate_limit(options)
258
- search_options[:offset] = calculate_offset(options)
259
- else
260
- search_options[:per_page] = calculate_per_page(options)
261
- search_options[:page] = calculate_page(options)
262
- end
263
-
264
- search_options.reverse_merge!(extract_filters(options))
265
-
266
- result_ids, count = options[:presenter].search_block.call(options[:params][:search], search_options)
267
- if result_ids
268
- [scope.where(:id => result_ids ), count, result_ids]
269
- else
270
- raise(SearchUnavailableError, 'Search is currently unavailable')
271
- end
151
+ return Brainstem::QueryStrategies::FilterAndSearch.new(options) if strat == :filter_and_search && searching?(options)
152
+ return Brainstem::QueryStrategies::FilterOrSearch.new(options)
272
153
  end
273
154
 
274
155
  def searching?(options)
275
- options[:params][:search] && options[:presenter].search_block.present?
276
- end
277
-
278
- def order_for_search(records, ordered_search_ids)
279
- ids_to_position = {}
280
- ordered_records = []
281
-
282
- ordered_search_ids.each_with_index do |id, index|
283
- ids_to_position[id] = index
284
- end
285
-
286
- records.each do |record|
287
- ordered_records[ids_to_position[record.id]] = record
288
- end
289
-
290
- ordered_records
291
- end
292
-
293
- def handle_ordering(scope, options)
294
- order, direction = calculate_order_and_direction(options)
295
-
296
- case order
297
- when Proc
298
- order.call(scope, direction)
299
- when nil
300
- scope
301
- else
302
- scope.order(order.to_s + " " + direction)
303
- end
304
- end
305
-
306
- def calculate_order_and_direction(options)
307
- sort_name, direction = calculate_sort_name_and_direction(options)
308
- sort_orders = (options[:presenter].sort_orders || {})
309
- order = sort_orders[sort_name]
310
-
311
- [order, direction]
156
+ options[:params][:search] && options[:primary_presenter].configuration[:search].present?
312
157
  end
313
158
 
314
- def calculate_sort_name_and_direction(options)
315
- default_column, default_direction = (options[:presenter].default_sort_order || "updated_at:desc").split(":")
316
- sort_name, direction = (options[:params][:order] || "").split(":")
317
- sort_orders = options[:presenter].sort_orders || {}
318
- unless sort_name.present? && sort_orders[sort_name]
319
- sort_name = default_column
320
- direction = default_direction
321
- end
159
+ def filter_includes(options)
160
+ allowed_associations = options[:primary_presenter].allowed_associations(options[:params][:only].present?)
322
161
 
323
- [sort_name, direction == 'desc' ? 'desc' : 'asc']
324
- end
325
-
326
- def perform_preloading(records, includes_hash)
327
- records.tap do |models|
328
- association_names_to_preload = includes_hash.values.map {|i| i.method_name }
329
- if models.first
330
- reflections = Brainstem::PresenterCollection.reflections(models.first.class)
331
- association_names_to_preload.reject! { |association| !reflections.has_key?(association.to_s) }
332
- end
333
- if association_names_to_preload.any?
334
- Brainstem::PresenterCollection.preload(models, association_names_to_preload)
335
- Brainstem.logger.info "Eager loaded #{association_names_to_preload.join(", ")}."
336
- end
337
- end
338
- end
339
-
340
- def gather_associations(models, includes_hash)
341
- record_hash = {}
342
- primary_models = []
343
-
344
- includes_hash.each do |include, include_data|
345
- record_hash[include_data.json_name] ||= [] if include_data.json_name
346
- end
347
-
348
- models.each do |model|
349
- primary_models << model
350
-
351
- includes_hash.each do |include, include_data|
352
- models = Array(include_data.call(model))
353
-
354
- if include_data.json_name
355
- record_hash[include_data.json_name] += models
356
- else
357
- # polymorphic associations' tables must be figured out now
358
- models.each do |record|
359
- json_name = record.class.table_name.to_sym
360
- record_hash[json_name] ||= []
361
- record_hash[json_name] << record
362
- end
162
+ [].tap do |selected_associations|
163
+ (options[:params][:include] || '').split(',').each do |k|
164
+ if association = allowed_associations[k]
165
+ selected_associations << association
363
166
  end
364
167
  end
365
168
  end
366
-
367
- [primary_models, record_hash]
368
169
  end
369
170
 
370
- def rewrite_keys_as_objects!(struct)
371
- (struct.keys - [:count, :results]).each do |key|
372
- struct[key] = struct[key].inject({}) {|memo, obj| memo[obj[:id] || obj["id"] || "unknown_id"] = obj; memo }
373
- end
171
+ def filter_optional_fields(options)
172
+ options[:params][:optional_fields].to_s.split(',').map(&:strip) & options[:primary_presenter].configuration[:fields].keys
374
173
  end
375
174
 
376
- # Class Methods
175
+ def set_default_filters_option!(options)
176
+ return unless options[:params].has_key?(:apply_default_filters)
377
177
 
378
- # In Rails 4.2, ActiveRecord::Base#reflections started being keyed by strings instead of symbols.
379
- def self.reflections(klass)
380
- klass.reflections.each_with_object({}) { |(key, value), memo| memo[key.to_s] = value }
178
+ options[:apply_default_filters] = [true, "true", "TRUE", 1, "1"].include? options[:params].delete(:apply_default_filters)
381
179
  end
382
180
 
383
- def self.preload(models, association_names)
384
- if Gem.loaded_specs['activerecord'].version >= Gem::Version.create('4.1')
385
- ActiveRecord::Associations::Preloader.new.preload(models, association_names)
386
- else
387
- ActiveRecord::Associations::Preloader.new(models, association_names).run
181
+ def check_for_old_options!(options)
182
+ if options[:as].present?
183
+ raise "PresenterCollection#presenting no longer accepts the :as option. Use the brainstem_key annotation in your presenters instead."
388
184
  end
389
185
  end
390
186
  end