brainstem 0.2.6.1 → 1.0.0.pre.1

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 (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