brainstem 0.2.1 → 0.2.2

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.
@@ -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