brainstem 0.2.1 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- brainstem (0.2.1)
4
+ brainstem (0.2.2)
5
5
  activerecord (~> 3.0)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Brainstem
2
2
 
3
+ [![Build Status](https://travis-ci.org/mavenlink/brainstem.png)](https://travis-ci.org/mavenlink/brainstem)
4
+
3
5
  Brainstem is designed to power rich APIs in Rails. The Brainstem gem provides a presenter library that handles converting ActiveRecord objects into structured JSON and a set of API abstractions that allow users to request sorts, filters, and association loads, allowing for simpler implementations, fewer requests, and smaller responses.
4
6
 
5
7
  ## Why Brainstem?
@@ -12,6 +14,8 @@ Brainstem is designed to power rich APIs in Rails. The Brainstem gem provides a
12
14
  * Prevent data duplication by pulling associations into top-level hashes, easily indexable by ID.
13
15
  * Easy integration with Backbone.js. "It's like Ember Data for Backbone.js!"
14
16
 
17
+ Please [watch our talk about Brainstem from RailsConf 2013](http://www.confreaks.com/videos/2457-railsconf2013-introducing-brainstem-your-companion-for-rich-rails-apis).
18
+
15
19
  ## Installation
16
20
 
17
21
  Add this line to your application's Gemfile:
@@ -3,19 +3,26 @@ module Brainstem
3
3
  # @api private
4
4
  class AssociationField
5
5
  # @!attribute [r] method_name
6
- # @return [Symbol] The name of the method that is being proxied.
6
+ # @return [String] The name of the method that is being proxied.
7
7
  attr_reader :method_name
8
8
 
9
9
  # @!attribute [r] json_name
10
- # @return [Symbol] The name of the top-level JSON key for objects provided by this association.
10
+ # @return [String] The name of the top-level JSON key for objects provided by this association.
11
11
  attr_accessor :json_name
12
12
 
13
+ # @!attribute [r] block
14
+ # @return [Proc] The block to be called when fetching models instead of calling a method on the model
15
+ attr_reader :block
16
+
13
17
  # @param method_name The name of the method being proxied. Not required if
14
18
  # a block is passed instead.
15
19
  # @option options [Boolean] :json_name The name of the top-level JSON key for objects provided by this association.
16
- def initialize(method_name = nil, options = {}, &block)
20
+ def initialize(*args, &block)
21
+ options = args.last.is_a?(Hash) ? args.pop : {}
22
+ method_name = args.first.to_sym if args.first.is_a?(String) || args.first.is_a?(Symbol)
17
23
  @json_name = options[:json_name]
18
24
  if block_given?
25
+ raise ArgumentError, "options[:json_name] is required when using a block" unless options[:json_name]
19
26
  raise ArgumentError, "Method name is invalid with a block" if method_name
20
27
  @block = block
21
28
  elsif method_name
@@ -29,7 +36,7 @@ module Brainstem
29
36
  # @param model The object to call the proxied method on.
30
37
  # @return The value returned by calling the method or block being proxied.
31
38
  def call(model)
32
- @block ? @block.call : model.send(@method_name)
39
+ @block ? @block.call(model) : model.send(@method_name)
33
40
  end
34
41
  end
35
42
  end
@@ -38,7 +38,7 @@ module Brainstem
38
38
  # @raise [ArgumentError] if neither an order string or block is given.
39
39
  def self.sort_order(name, order = nil, &block)
40
40
  raise ArgumentError, "A sort order must be given" unless block_given? || order
41
- @sort_orders ||= {}
41
+ @sort_orders ||= HashWithIndifferentAccess.new
42
42
  @sort_orders[name] = (block_given? ? block : order)
43
43
  end
44
44
 
@@ -56,7 +56,7 @@ module Brainstem
56
56
  # @yieldparam arg [Object] The argument passed when the filter was requested.
57
57
  # @yieldreturn [ActiveRecord::Relation] A new scope that filters the scope that was yielded.
58
58
  def self.filter(name, options = {}, &block)
59
- @filters ||= {}
59
+ @filters ||= HashWithIndifferentAccess.new
60
60
  @filters[name] = [options, (block_given? ? block : nil)]
61
61
  end
62
62
 
@@ -160,9 +160,9 @@ module Brainstem
160
160
  if reflection && reflection.options[:polymorphic]
161
161
  struct["#{key.to_s.singularize}_type".to_sym] = model.send("#{value.method_name}_type")
162
162
  end
163
- elsif associations.include?(key)
163
+ elsif associations.include?(key.to_s)
164
164
  result = value.call(model)
165
- if result.is_a?(Array)
165
+ if result.is_a?(Array) || result.is_a?(ActiveRecord::Relation)
166
166
  struct["#{key.to_s.singularize}_ids".to_sym] = result.map {|a| to_s_except_nil(a.is_a?(ActiveRecord::Base) ? a.id : a) }
167
167
  else
168
168
  if result.is_a?(ActiveRecord::Base)
@@ -199,8 +199,8 @@ module Brainstem
199
199
  end
200
200
 
201
201
  # An association on the object being presented that should be included in the presented data.
202
- def association(method_name = nil, options = {}, &block)
203
- AssociationField.new method_name, options, &block
202
+ def association(*args, &block)
203
+ AssociationField.new *args, &block
204
204
  end
205
205
 
206
206
  def to_s_except_nil(thing)
@@ -1,4 +1,5 @@
1
1
  require 'brainstem/association_field'
2
+ require 'brainstem/search_unavailable_error'
2
3
 
3
4
  module Brainstem
4
5
  class PresenterCollection
@@ -18,6 +19,8 @@ module Brainstem
18
19
  end
19
20
 
20
21
  # The main presentation method, converting a model name and an optional scope into a hash structure, ready to be converted into JSON.
22
+ # 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.
21
24
  # @param [Class, String] name The class of the objects to be presented.
22
25
  # @param [Hash] options The options that will be applied as the objects are converted.
23
26
  # @option options [Hash] :params The +params+ hash included in a request for the presented object.
@@ -29,10 +32,11 @@ module Brainstem
29
32
  # @yield Must return a scope on the model +name+, which will then be presented.
30
33
  # @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.
31
34
  def presenting(name, options = {}, &block)
32
- options[:params] ||= {}
35
+ options[:params] = HashWithIndifferentAccess.new(options[:params] || {})
33
36
  presented_class = (options[:model] || name)
34
37
  presented_class = presented_class.classify.constantize if presented_class.is_a?(String)
35
38
  scope = presented_class.instance_eval(&block)
39
+ count = 0
36
40
 
37
41
  # grab the presenter that knows about filters and sorting etc.
38
42
  options[:presenter] = for!(presented_class)
@@ -43,29 +47,36 @@ module Brainstem
43
47
  # key these models will use in the struct that is output
44
48
  options[:as] = (options[:as] || name.to_s.tableize).to_sym
45
49
 
46
- # Filter
47
- scope = run_filters scope, options
48
-
49
- # Search
50
- scope = run_search scope, options
50
+ allowed_includes = calculate_allowed_includes options[:presenter], presented_class
51
+ includes_hash = filter_includes options[:params][:include], allowed_includes
51
52
 
52
- if options[:params][:only].present?
53
- # Handle Only
54
- scope, count = handle_only(scope, options[:params][:only])
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)
55
57
  else
56
- # Paginate
57
- scope, count = paginate scope, options
58
- end
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
59
68
 
60
- # Ordering
61
- scope = handle_ordering scope, options
69
+ count = count.keys.length if count.is_a?(Hash)
70
+
71
+ # Ordering
72
+ scope = handle_ordering scope, options
73
+ end
62
74
 
63
75
  # Load Includes
64
76
  records = scope.to_a
77
+ records = order_for_search(records, ordered_search_ids) if searching? options
65
78
  model = records.first
66
79
 
67
- allowed_includes = calculate_allowed_includes options[:presenter], presented_class, records
68
- includes_hash = filter_includes options[:params][:include], allowed_includes
69
80
  models = perform_preloading records, includes_hash
70
81
  primary_models, associated_models = gather_associations(models, includes_hash)
71
82
  struct = { :count => count, options[:as] => [], :results => [] }
@@ -76,7 +87,7 @@ module Brainstem
76
87
 
77
88
  if models.length > 0
78
89
  presenter = for!(models.first.class)
79
- assoc = includes_hash.to_a.find { |k, v| v[:json_name] == json_name }
90
+ assoc = includes_hash.to_a.find { |k, v| v.json_name == json_name }
80
91
  struct[json_name] = presenter.group_present(models, [])
81
92
  else
82
93
  struct[json_name] = []
@@ -118,34 +129,38 @@ module Brainstem
118
129
  self.for(klass) || raise(ArgumentError, "Unable to find a presenter for class #{klass}")
119
130
  end
120
131
 
121
- private
132
+ private
122
133
 
123
134
  def paginate(scope, options)
124
- max_per_page = (options[:max_per_page] || default_max_per_page).to_i
125
- per_page = (options[:params][:per_page] || options[:per_page] || default_per_page).to_i
126
- per_page = max_per_page if per_page > max_per_page
127
- per_page = (options[:per_page] || default_per_page).to_i if per_page < 1
128
-
129
- page = (options[:params][:page] || 1).to_i
130
- page = 1 if page < 1
135
+ per_page = calculate_per_page(options)
136
+ page = calculate_page(options)
131
137
 
132
138
  [scope.limit(per_page).offset(per_page * (page - 1)).uniq, scope.select("distinct `#{options[:table_name]}`.id").count] # as of Rails 3.2.5, uniq.count generates the wrong SQL.
133
139
  end
134
140
 
141
+ def calculate_per_page(options)
142
+ 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
143
+ per_page = default_per_page if per_page < 1
144
+ per_page
145
+ end
146
+
147
+ def calculate_page(options)
148
+ [(options[:params][:page] || 1).to_i, 1].max
149
+ end
150
+
135
151
  # Gather allowed includes by inspecting the presented hash. For now, this requires that a new instance of the
136
152
  # presented class always be presentable.
137
- def calculate_allowed_includes(presenter, presented_class, records)
153
+ def calculate_allowed_includes(presenter, presented_class)
138
154
  allowed_includes = {}
139
- model = records.first || presented_class.new
155
+ model = presented_class.new
140
156
  presenter.present(model).each do |k, v|
141
157
  next unless v.is_a?(AssociationField)
142
-
143
158
  if v.json_name
144
- v.json_name = v.json_name.tableize
159
+ v.json_name = v.json_name.tableize.to_sym
145
160
  else
146
161
  association = model.class.reflections[v.method_name]
147
162
  if !association.options[:polymorphic]
148
- v.json_name = association && association.table_name
163
+ v.json_name = association && association.table_name.to_sym
149
164
  if v.json_name.nil?
150
165
  raise ":json_name is a required option for method-based associations (#{presented_class}##{v.method_name})"
151
166
  end
@@ -158,16 +173,13 @@ module Brainstem
158
173
 
159
174
  def filter_includes(user_includes, allowed_includes)
160
175
  filtered_includes = {}
161
- (user_includes || "").split(',').each do |k|
176
+
177
+ (user_includes || '').split(',').each do |k|
162
178
  allowed = allowed_includes[k]
163
179
  if allowed
164
- filtered_includes[k.to_sym] = {
165
- :association => allowed.method_name.to_sym,
166
- :json_name => allowed.json_name.try(:to_sym)
167
- }
180
+ filtered_includes[k] = allowed
168
181
  end
169
182
  end
170
-
171
183
  filtered_includes
172
184
  end
173
185
 
@@ -177,6 +189,22 @@ module Brainstem
177
189
  end
178
190
 
179
191
  def run_filters(scope, options)
192
+ extract_filters(options).each do |filter_name, arg|
193
+ next if arg.nil?
194
+ filter_lambda = options[:presenter].filters[filter_name][1]
195
+
196
+ if filter_lambda
197
+ scope = filter_lambda.call(scope, *arg)
198
+ else
199
+ scope = scope.send(filter_name, *arg)
200
+ end
201
+ end
202
+
203
+ scope
204
+ end
205
+
206
+ def extract_filters(options)
207
+ filters_hash = {}
180
208
  run_defaults = options.has_key?(:apply_default_filters) ? options[:apply_default_filters] : true
181
209
 
182
210
  (options[:presenter].filters || {}).each do |filter_name, filter|
@@ -184,52 +212,92 @@ module Brainstem
184
212
  requested = requested.present? ? requested.to_s : nil
185
213
  requested = requested == "true" ? true : (requested == "false" ? false : requested)
186
214
 
187
- filter_options, filter_lambda = filter
188
- args = run_defaults ? (requested || filter_options[:default]) : requested
189
- next if args.nil?
215
+ filter_options = filter[0]
216
+ args = run_defaults && requested.nil? ? filter_options[:default] : requested
217
+ filters_hash[filter_name] = args unless args.nil?
218
+ end
190
219
 
191
- if filter_lambda
192
- scope = filter_lambda.call(scope, *args)
193
- else
194
- scope = scope.send(filter_name, *args)
195
- end
220
+ filters_hash
221
+ end
222
+
223
+ # Runs the current search_block and returns an array of [scope of the resulting ids, result count, result ids]
224
+ # If the search_block returns a falsy value a SearchUnavailableError is raised.
225
+ # Your search block should return a list of ids and the count of ids found, or false if search is unavailable.
226
+ def run_search(scope, includes, sort_name, direction, options)
227
+ return scope unless searching? options
228
+
229
+ search_options = HashWithIndifferentAccess.new(
230
+ :include => includes,
231
+ :order => { :sort_order => sort_name, :direction => direction },
232
+ :per_page => calculate_per_page(options),
233
+ :page => calculate_page(options)
234
+ )
235
+
236
+ search_options.reverse_merge!(extract_filters(options))
237
+
238
+ result_ids, count = options[:presenter].search_block.call(options[:params][:search], search_options)
239
+ if result_ids
240
+ [scope.where(:id => result_ids ), count, result_ids]
241
+ else
242
+ raise(SearchUnavailableError, 'Search is currently unavailable')
196
243
  end
244
+ end
197
245
 
198
- scope
246
+ def searching?(options)
247
+ options[:params][:search] && options[:presenter].search_block.present?
199
248
  end
200
249
 
201
- def run_search(scope, options)
202
- return scope unless options[:params][:search] && options[:presenter].search_block.present?
250
+ def order_for_search(records, ordered_search_ids)
251
+ ids_to_position = {}
252
+ ordered_records = []
203
253
 
204
- result_ids = options[:presenter].search_block.call(options[:params][:search])
205
- scope.where(:id => result_ids )
254
+ ordered_search_ids.each_with_index do |id, index|
255
+ ids_to_position[id] = index
256
+ end
257
+
258
+ records.each do |record|
259
+ ordered_records[ids_to_position[record.id]] = record
260
+ end
261
+
262
+ ordered_records
206
263
  end
207
264
 
208
265
  def handle_ordering(scope, options)
209
- default_column, default_direction = (options[:presenter].default_sort_order || "updated_at:desc").split(":")
210
- sort_name, direction = (options[:params][:order] || "").split(":")
266
+ order, direction = calculate_order_and_direction(options)
267
+
268
+ case order
269
+ when Proc
270
+ order.call(scope, direction == "desc" ? "desc" : "asc")
271
+ when nil
272
+ scope
273
+ else
274
+ scope.order(order.to_s + " " + (direction == "desc" ? "desc" : "asc"))
275
+ end
276
+ end
277
+
278
+ def calculate_order_and_direction(options)
279
+ sort_name, direction = calculate_sort_name_and_direction(options)
211
280
  sort_orders = (options[:presenter].sort_orders || {})
281
+ order = sort_orders[sort_name]
212
282
 
213
- if sort_name.present? && sort_orders[sort_name.to_sym]
214
- order = sort_orders[sort_name.to_sym]
215
- else
216
- order = sort_orders[default_column.to_sym]
283
+ [order, direction]
284
+ end
285
+
286
+ def calculate_sort_name_and_direction(options)
287
+ default_column, default_direction = (options[:presenter].default_sort_order || "updated_at:desc").split(":")
288
+ sort_name, direction = (options[:params][:order] || "").split(":")
289
+ sort_orders = options[:presenter].sort_orders || {}
290
+ unless sort_name.present? && sort_orders[sort_name]
291
+ sort_name = default_column
217
292
  direction = default_direction
218
293
  end
219
294
 
220
- case order
221
- when Proc
222
- order.call(scope, direction == "desc" ? "desc" : "asc")
223
- when nil
224
- scope
225
- else
226
- scope.order(order.to_s + " " + (direction == "desc" ? "desc" : "asc"))
227
- end
295
+ [sort_name, direction]
228
296
  end
229
297
 
230
298
  def perform_preloading(records, includes_hash)
231
299
  records.tap do |models|
232
- association_names_to_preload = includes_hash.values.map {|i| i[:association] }
300
+ association_names_to_preload = includes_hash.values.map {|i| i.method_name }
233
301
  if models.first
234
302
  reflections = models.first.reflections
235
303
  association_names_to_preload.reject! { |association| !reflections.has_key?(association) }
@@ -246,16 +314,17 @@ module Brainstem
246
314
  primary_models = []
247
315
 
248
316
  includes_hash.each do |include, include_data|
249
- record_hash[include_data[:json_name]] ||= [] if include_data[:json_name]
317
+ record_hash[include_data.json_name] ||= [] if include_data.json_name
250
318
  end
251
319
 
252
320
  models.each do |model|
253
321
  primary_models << model
254
322
 
255
323
  includes_hash.each do |include, include_data|
256
- models = Array(model.send(include_data[:association]))
257
- if include_data[:json_name]
258
- record_hash[include_data[:json_name]] += models
324
+ models = Array(include_data.call(model))
325
+
326
+ if include_data.json_name
327
+ record_hash[include_data.json_name] += models
259
328
  else
260
329
  # polymorphic associations' tables must be figured out now
261
330
  models.each do |record|
@@ -0,0 +1,4 @@
1
+ module Brainstem
2
+ class SearchUnavailableError < StandardError
3
+ end
4
+ end
@@ -1,3 +1,3 @@
1
1
  module Brainstem
2
- VERSION = "0.2.1"
2
+ VERSION = "0.2.2"
3
3
  end
Binary file
Binary file
@@ -10,6 +10,10 @@ describe Brainstem::PresenterCollection do
10
10
  @presenter_collection = Brainstem.presenter_collection
11
11
  end
12
12
 
13
+ let(:bob) { User.where(:username => "bob").first }
14
+ let(:bob_workspaces_ids) { bob.workspaces.map(&:id) }
15
+ let(:jane) { User.where(:username => "jane").first }
16
+
13
17
  describe "#presenting" do
14
18
  describe "#pagination" do
15
19
  before do
@@ -23,6 +27,7 @@ describe Brainstem::PresenterCollection do
23
27
 
24
28
  it "will not accept a per_page less than 1" do
25
29
  @presenter_collection.presenting("workspaces", :params => { :per_page => 0 }) { Workspace.order('id desc') }[:workspaces].length.should == 2
30
+ @presenter_collection.presenting("workspaces", :per_page => 0) { Workspace.order('id desc') }[:workspaces].length.should == 2
26
31
  end
27
32
 
28
33
  it "will accept strings" do
@@ -187,6 +192,12 @@ describe Brainstem::PresenterCollection do
187
192
  result[:users][Workspace.first.lead_user.id.to_s].should be_present
188
193
  end
189
194
 
195
+ it "can accept a lambda for the association and uses that when present" do
196
+ result = @presenter_collection.presenting("users", :params => { :include => "odd_workspaces" }) { User.where(:id => 1) }
197
+ result[:odd_workspaces][Workspace.first.id.to_s].should be_present
198
+ result[:users][Workspace.first.lead_user.id.to_s].should be_present
199
+ end
200
+
190
201
  describe "polymorphic associations" do
191
202
  it "works with polymorphic associations" do
192
203
  result = @presenter_collection.presenting("posts", :params => { :include => "subject" }) { Post.order('id desc') }
@@ -235,9 +246,6 @@ describe Brainstem::PresenterCollection do
235
246
  WorkspacePresenter.filter(:title) { |scope, title| scope.where(:title => title) }
236
247
  end
237
248
 
238
- let(:bob) { User.where(:username => "bob").first }
239
- let(:bob_workspaces_ids) { bob.workspaces.map(&:id) }
240
-
241
249
  it "limits records to those matching given filters" do
242
250
  result = @presenter_collection.presenting("workspaces", :params => { :owned_by => bob.id.to_s }) { Workspace.order("id desc") } # hit the API, filtering on owned_by:bob
243
251
  result[:workspaces].should be_present
@@ -260,9 +268,10 @@ describe Brainstem::PresenterCollection do
260
268
  end
261
269
 
262
270
  it "converts boolean parameters from strings to booleans" do
263
- WorkspacePresenter.filter(:owned_by_bob) { |scope, boolean| boolean ? scope.where(:user_id => bob.id) : scope }
271
+ WorkspacePresenter.filter(:owned_by_bob) { |scope, boolean| boolean ? scope.where(:user_id => bob.id) : scope.where(:user_id => jane.id) }
264
272
  result = @presenter_collection.presenting("workspaces", :params => { :owned_by_bob => "false" }) { Workspace.scoped }
265
273
  result[:workspaces].values.find { |workspace| workspace[:title].include?("jane") }.should be
274
+ result[:workspaces].values.find { |workspace| workspace[:title].include?("bob") }.should_not be
266
275
  end
267
276
 
268
277
  it "ensures arguments are strings" do
@@ -320,6 +329,10 @@ describe Brainstem::PresenterCollection do
320
329
  it "allows the default value to be overridden" do
321
330
  result = @presenter_collection.presenting("workspaces", :params => { :owner => jane.id.to_s }) { Workspace.order('id desc') }
322
331
  result[:workspaces].keys.should match_array(jane.workspaces.map(&:id).map(&:to_s))
332
+
333
+ WorkspacePresenter.filter(:include_early_workspaces, :default => true) { |scope, bool| bool ? scope : scope.where("id > 3") }
334
+ result = @presenter_collection.presenting("workspaces", :params => { :include_early_workspaces => "false" }) { Workspace.unscoped }
335
+ result[:workspaces]["2"].should_not be_present
323
336
  end
324
337
  end
325
338
 
@@ -342,14 +355,19 @@ describe Brainstem::PresenterCollection do
342
355
  result[:workspaces].keys.should eq(jane.workspaces.pluck(:id).map(&:to_s))
343
356
  end
344
357
 
345
- it "allows scopes that take no arguments" do
358
+ it "can use filters without lambdas in the presenter or model, but behaves strangely when false is given" do
346
359
  WorkspacePresenter.filter(:numeric_description)
360
+
347
361
  result = @presenter_collection.presenting("workspaces") { Workspace.scoped }
348
- result[:workspaces].keys.should eq(bob.workspaces.pluck(:id).map(&:to_s))
362
+ result[:workspaces].keys.should eq(%w[1 2 3 4])
363
+
349
364
  result = @presenter_collection.presenting("workspaces", :params => { :numeric_description => "true" }) { Workspace.scoped }
350
- result[:workspaces].keys.should =~ ["2", "4"]
351
- result = @presenter_collection.presenting("workspaces", :params => { :numeric_description => "fadlse" }) { Workspace.scoped }
352
- result[:workspaces].keys.should eq(bob.workspaces.pluck(:id).map(&:to_s))
365
+ result[:workspaces].keys.should eq(%w[2 4])
366
+
367
+ # This is probably not the behavior that the developer or user intends. You should always use a one-argument lambda in your
368
+ # model scope declaration!
369
+ result = @presenter_collection.presenting("workspaces", :params => { :numeric_description => "false" }) { Workspace.scoped }
370
+ result[:workspaces].keys.should eq(%w[2 4])
353
371
  end
354
372
  end
355
373
  end
@@ -358,14 +376,133 @@ describe Brainstem::PresenterCollection do
358
376
  context "with search method defined" do
359
377
  before do
360
378
  WorkspacePresenter.search do |string|
361
- [3,5]
379
+ [[5, 3], 2]
362
380
  end
363
381
  end
364
382
 
365
383
  context "and a search request is made" do
366
- it "calls the search method" do
384
+ it "calls the search method and maintains the resulting order" do
385
+ result = @presenter_collection.presenting("workspaces", :params => { :search => "blah" }) { Workspace.order("id asc") }
386
+ result[:workspaces].keys.should eq(%w[5 3])
387
+ result[:count].should eq(2)
388
+ end
389
+
390
+ it "does not apply filters" do
391
+ mock(@presenter_collection).run_filters(anything, anything).times(0)
367
392
  result = @presenter_collection.presenting("workspaces", :params => { :search => "blah" }) { Workspace.order("id asc") }
368
- result[:workspaces].keys.should eq(%w[3 5])
393
+ end
394
+
395
+ it "does not apply ordering" do
396
+ mock(@presenter_collection).handle_ordering(anything, anything).times(0)
397
+ result = @presenter_collection.presenting("workspaces", :params => { :search => "blah" }) { Workspace.order("id asc") }
398
+ end
399
+
400
+ it "does not try to handle only's" do
401
+ mock(@presenter_collection).handle_only(anything, anything).times(0)
402
+ result = @presenter_collection.presenting("workspaces", :params => { :search => "blah" }) { Workspace.order("id asc") }
403
+ end
404
+
405
+ it "does not apply pagination" do
406
+ mock(@presenter_collection).paginate(anything, anything).times(0)
407
+ result = @presenter_collection.presenting("workspaces", :params => { :search => "blah" }) { Workspace.order("id asc") }
408
+ end
409
+
410
+ it "keeps the records in the order returned by search" do
411
+ result = @presenter_collection.presenting("workspaces", :params => { :search => "blah" }) { Workspace.unscoped }
412
+
413
+ end
414
+
415
+ it "throws a SearchUnavailableError if the search block returns false" do
416
+ WorkspacePresenter.search do |string|
417
+ false
418
+ end
419
+
420
+ lambda {
421
+ @presenter_collection.presenting("workspaces", :params => { :search => "blah" }) { Workspace.unscoped }
422
+ }.should raise_error(Brainstem::SearchUnavailableError)
423
+ end
424
+
425
+ describe "passing options to the search block" do
426
+ it "passes the search method, the search string, includes, order, and paging options" do
427
+ WorkspacePresenter.filter(:owned_by) { |scope| scope }
428
+ WorkspacePresenter.search do |string, options|
429
+ string.should == "blah"
430
+ options[:include].should == ["tasks", "lead_user"]
431
+ options[:owned_by].should == false
432
+ options[:order][:sort_order].should == "updated_at"
433
+ options[:order][:direction].should == "desc"
434
+ options[:page].should == 2
435
+ options[:per_page].should == 5
436
+ [[1], 1] # returned ids, count - not testing this in this set of specs
437
+ end
438
+
439
+ @presenter_collection.presenting("workspaces", :params => { :search => "blah", :include => "tasks,lead_user", :owned_by => "false", :order => "updated_at:desc", :page => 2, :per_page => 5 }) { Workspace.order("id asc") }
440
+ end
441
+
442
+ describe "includes" do
443
+ it "throws out requested inlcudes that the presenter does not have associations for" do
444
+ WorkspacePresenter.search do |string, options|
445
+ options[:include].should == []
446
+ [[1], 1]
447
+ end
448
+
449
+ @presenter_collection.presenting("workspaces", :params => { :search => "blah", :include => "users"}) { Workspace.order("id asc") }
450
+ end
451
+ end
452
+
453
+ describe "filters" do
454
+ it "passes through the default filters if no filter is requested" do
455
+ WorkspacePresenter.filter(:owned_by, :default => true) { |scope| scope }
456
+ WorkspacePresenter.search do |string, options|
457
+ options[:owned_by].should == true
458
+ [[1], 1]
459
+ end
460
+
461
+ @presenter_collection.presenting("workspaces", :params => { :search => "blah" }) { Workspace.order("id asc") }
462
+ end
463
+
464
+ it "throws out requested filters that the presenter does not have" do
465
+ WorkspacePresenter.search do |string, options|
466
+ options[:highest_rated].should be_nil
467
+ [[1], 1]
468
+ end
469
+
470
+ @presenter_collection.presenting("workspaces", :params => { :search => "blah", :highest_rated => true}) { Workspace.order("id asc") }
471
+ end
472
+
473
+ it "does not pass through existing non-default filters that are not requested" do
474
+ WorkspacePresenter.filter(:owned_by) { |scope| scope }
475
+ WorkspacePresenter.search do |string, options|
476
+ options.has_key?(:owned_by).should == false
477
+ [[1], 1]
478
+ end
479
+
480
+ @presenter_collection.presenting("workspaces", :params => { :search => "blah"}) { Workspace.order("id asc") }
481
+ end
482
+ end
483
+
484
+ describe "orders" do
485
+ it "passes through the default sort order if no order is requested" do
486
+ WorkspacePresenter.default_sort_order("description:desc")
487
+ WorkspacePresenter.search do |string, options|
488
+ options[:order][:sort_order].should == "description"
489
+ options[:order][:direction].should == "desc"
490
+ [[1], 1]
491
+ end
492
+
493
+ @presenter_collection.presenting("workspaces", :params => { :search => "blah"}) { Workspace.order("id asc") }
494
+ end
495
+
496
+ it "makes the sort order 'updated_at:desc' if the requested order doesn't match an existing sort order and there is no default" do
497
+ WorkspacePresenter.search do |string, options|
498
+ options[:order][:sort_order].should == "updated_at"
499
+ options[:order][:direction].should == "desc"
500
+ [[1], 1]
501
+ end
502
+
503
+ @presenter_collection.presenting("workspaces", :params => { :search => "blah", :order => "created_at:asc"}) { Workspace.order("id asc") }
504
+ end
505
+ end
369
506
  end
370
507
  end
371
508
 
@@ -428,16 +565,33 @@ describe Brainstem::PresenterCollection do
428
565
  result[:results].map {|i| result[:workspaces][i[:id]][:description] }.should eq(%w(1 2 3 a b c))
429
566
  end
430
567
 
431
- it "cleans the direction param" do
568
+ it "cleans the params" do
569
+ WorkspacePresenter.sort_order(:description, "workspaces.description")
570
+ WorkspacePresenter.default_sort_order("description:desc")
571
+
432
572
  result = @presenter_collection.presenting("workspaces", :params => { :order => "updated_at:drop table" }) { Workspace.where("id is not null") }
433
573
  result.keys.should =~ [:count, :workspaces, :results]
574
+
575
+ result = @presenter_collection.presenting("workspaces", :params => { :order => "drop table:desc" }) { Workspace.where("id is not null") }
576
+ result.keys.should =~ [:count, :workspaces, :results]
577
+ result[:results].map {|i| result[:workspaces][i[:id]][:description] }.should eq(%w(c b a 3 2 1))
434
578
  end
435
579
 
436
580
  it "can take a proc" do
437
- WorkspacePresenter.sort_order(:description){ Workspace.order("workspaces.description") }
438
- WorkspacePresenter.default_sort_order("description:asc")
581
+ WorkspacePresenter.sort_order(:id) { |scope, direction| scope.order("workspaces.id #{direction}") }
582
+ WorkspacePresenter.default_sort_order("id:asc")
583
+
584
+ # Default
439
585
  result = @presenter_collection.presenting("workspaces") { Workspace.where("id is not null") }
440
- result[:results].map {|i| result[:workspaces][i[:id]][:description] }.should eq(%w(1 2 3 a b c))
586
+ result[:results].map {|i| result[:workspaces][i[:id]][:description] }.should eq(%w(a 1 b 2 c 3))
587
+
588
+ # Asc
589
+ result = @presenter_collection.presenting("workspaces", :params => { :order => "id:asc" }) { Workspace.where("id is not null") }
590
+ result[:results].map {|i| result[:workspaces][i[:id]][:description] }.should eq(%w(a 1 b 2 c 3))
591
+
592
+ # Desc
593
+ result = @presenter_collection.presenting("workspaces", :params => { :order => "id:desc" }) { Workspace.where("id is not null") }
594
+ result[:results].map {|i| result[:workspaces][i[:id]][:description] }.should eq(%w(3 c 2 b 1 a))
441
595
  end
442
596
  end
443
597
 
@@ -447,6 +601,24 @@ describe Brainstem::PresenterCollection do
447
601
  result.keys.should eq([:count, :my_workspaces, :results])
448
602
  end
449
603
  end
604
+
605
+ describe "the count top level key" do
606
+ it "should return the total number of matched records" do
607
+ WorkspacePresenter.filter(:owned_by) { |scope, user_id| scope.owned_by(user_id.to_i) }
608
+
609
+ result = @presenter_collection.presenting("workspaces") { Workspace.where(:id => 1) }
610
+ result[:count].should == 1
611
+
612
+ result = @presenter_collection.presenting("workspaces") { Workspace.unscoped }
613
+ result[:count].should == Workspace.count
614
+
615
+ result = @presenter_collection.presenting("workspaces", :params => { :owned_by => bob.to_param }) { Workspace.unscoped }
616
+ result[:count].should == Workspace.owned_by(bob.to_param).count
617
+
618
+ result = @presenter_collection.presenting("workspaces", :params => { :owned_by => bob.to_param }) { Workspace.group(:id) }
619
+ result[:count].should == Workspace.owned_by(bob.to_param).count
620
+ end
621
+ end
450
622
  end
451
623
 
452
624
  describe "collection methods" do
@@ -71,7 +71,7 @@ describe Brainstem::Presenter do
71
71
 
72
72
  it "creates an entry in the filters class ivar" do
73
73
  @klass.filter(:foo, :default => true) { 1 }
74
- @klass.filters[:foo][0].should eq({:default => true})
74
+ @klass.filters[:foo][0].should eq({"default" => true})
75
75
  @klass.filters[:foo][1].should be_a(Proc)
76
76
  end
77
77
 
@@ -189,7 +189,8 @@ describe Brainstem::Presenter do
189
189
  :user => association(:user),
190
190
  :something => association(:user),
191
191
  :lead_user => association(:lead_user),
192
- :lead_user_with_lambda => association { model.user },
192
+ :lead_user_with_lambda => association(:json_name => "users") { |model| model.user },
193
+ :tasks_with_lambda => association(:json_name => "tasks") { |model| Task.where(:workspace_id => model) },
193
194
  :synthetic => association(:synthetic)
194
195
  }
195
196
  end
@@ -206,23 +207,24 @@ describe Brainstem::Presenter do
206
207
 
207
208
  it "should convert requested has_many associations (includes) into the <association>_ids format" do
208
209
  @workspace.tasks.length.should > 0
209
- @presenter.present_and_post_process(@workspace, [:tasks])[:task_ids].should =~ @workspace.tasks.map(&:id).map(&:to_s)
210
+ @presenter.present_and_post_process(@workspace, ["tasks"])[:task_ids].should =~ @workspace.tasks.map(&:id).map(&:to_s)
210
211
  end
211
212
 
212
213
  it "should convert requested belongs_to and has_one associations into the <association>_id format when requested" do
213
- @presenter.present_and_post_process(@workspace, [:user])[:user_id].should == @workspace.user.id.to_s
214
+ @presenter.present_and_post_process(@workspace, ["user"])[:user_id].should == @workspace.user.id.to_s
214
215
  end
215
216
 
216
217
  it "converts non-association models into <model>_id format when they are requested" do
217
- @presenter.present_and_post_process(@workspace, [:lead_user])[:lead_user_id].should == @workspace.lead_user.id.to_s
218
+ @presenter.present_and_post_process(@workspace, ["lead_user"])[:lead_user_id].should == @workspace.lead_user.id.to_s
218
219
  end
219
220
 
220
221
  it "handles associations provided with lambdas" do
221
- @presenter.present_and_post_process(@workspace, [:lead_user_with_lambda])[:lead_user_with_lambda_id].should == @workspace.lead_user.id.to_s
222
+ @presenter.present_and_post_process(@workspace, ["lead_user_with_lambda"])[:lead_user_with_lambda_id].should == @workspace.lead_user.id.to_s
223
+ @presenter.present_and_post_process(@workspace, ["tasks_with_lambda"])[:tasks_with_lambda_ids].should == @workspace.tasks.map(&:id).map(&:to_s)
222
224
  end
223
225
 
224
226
  it "should return <association>_id fields when the given association ids exist on the model whether it is requested or not" do
225
- @presenter.present_and_post_process(@workspace, [:user])[:user_id].should == @workspace.user_id.to_s
227
+ @presenter.present_and_post_process(@workspace, ["user"])[:user_id].should == @workspace.user_id.to_s
226
228
 
227
229
  json = @presenter.present_and_post_process(@workspace, [])
228
230
  json.keys.should =~ [:user_id, :something_id, :id, :updated_at]
@@ -233,10 +235,10 @@ describe Brainstem::Presenter do
233
235
  it "should return null, not empty string when ids are missing" do
234
236
  @workspace.user = nil
235
237
  @workspace.tasks = []
236
- @presenter.present_and_post_process(@workspace, [:lead_user_with_lambda])[:lead_user_with_lambda_id].should == nil
237
- @presenter.present_and_post_process(@workspace, [:user])[:user_id].should == nil
238
- @presenter.present_and_post_process(@workspace, [:something])[:something_id].should == nil
239
- @presenter.present_and_post_process(@workspace, [:tasks])[:task_ids].should == []
238
+ @presenter.present_and_post_process(@workspace, ["lead_user_with_lambda"])[:lead_user_with_lambda_id].should == nil
239
+ @presenter.present_and_post_process(@workspace, ["user"])[:user_id].should == nil
240
+ @presenter.present_and_post_process(@workspace, ["something"])[:something_id].should == nil
241
+ @presenter.present_and_post_process(@workspace, ["tasks"])[:task_ids].should == []
240
242
  end
241
243
 
242
244
  context "when the model has an <association>_id method but no column" do
@@ -24,7 +24,10 @@ end
24
24
  class UserPresenter < Brainstem::Presenter
25
25
  def present(model)
26
26
  {
27
- :username => model.username
27
+ :username => model.username,
28
+ :odd_workspaces => association(:json_name => "odd_workspaces") { |user|
29
+ user.workspaces.select { |workspace| workspace.id % 2 == 1 }
30
+ }
28
31
  }
29
32
  end
30
33
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brainstem
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2013-04-29 00:00:00.000000000 Z
12
+ date: 2013-05-25 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activerecord
@@ -140,11 +140,14 @@ files:
140
140
  - lib/brainstem/engine.rb
141
141
  - lib/brainstem/presenter.rb
142
142
  - lib/brainstem/presenter_collection.rb
143
+ - lib/brainstem/search_unavailable_error.rb
143
144
  - lib/brainstem/time_classes.rb
144
145
  - lib/brainstem/version.rb
145
146
  - lib/brainstem.rb
146
147
  - LICENSE
147
148
  - pkg/brainstem-0.0.2.gem
149
+ - pkg/brainstem-0.2.1.gem
150
+ - pkg/brainstem-0.2.2.gem
148
151
  - pkg/brainstem-0.2.gem
149
152
  - Rakefile
150
153
  - README.md
@@ -171,7 +174,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
171
174
  version: '0'
172
175
  segments:
173
176
  - 0
174
- hash: 1746661046555689868
177
+ hash: 1101488451032288843
175
178
  required_rubygems_version: !ruby/object:Gem::Requirement
176
179
  none: false
177
180
  requirements:
@@ -180,7 +183,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
180
183
  version: '0'
181
184
  segments:
182
185
  - 0
183
- hash: 1746661046555689868
186
+ hash: 1101488451032288843
184
187
  requirements: []
185
188
  rubyforge_project:
186
189
  rubygems_version: 1.8.25