brainstem 0.2.1 → 0.2.2
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile.lock +1 -1
- data/README.md +4 -0
- data/lib/brainstem/association_field.rb +11 -4
- data/lib/brainstem/presenter.rb +6 -6
- data/lib/brainstem/presenter_collection.rb +137 -68
- data/lib/brainstem/search_unavailable_error.rb +4 -0
- data/lib/brainstem/version.rb +1 -1
- data/pkg/brainstem-0.2.1.gem +0 -0
- data/pkg/brainstem-0.2.2.gem +0 -0
- data/spec/brainstem/presenter_collection_spec.rb +188 -16
- data/spec/brainstem/presenter_spec.rb +13 -11
- data/spec/spec_helpers/presenters.rb +4 -1
- metadata +7 -4
data/Gemfile.lock
CHANGED
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 [
|
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 [
|
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(
|
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
|
data/lib/brainstem/presenter.rb
CHANGED
@@ -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(
|
203
|
-
AssociationField.new
|
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
|
-
|
47
|
-
|
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
|
53
|
-
#
|
54
|
-
|
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
|
-
#
|
57
|
-
scope
|
58
|
-
|
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
|
-
|
61
|
-
|
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
|
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
|
-
|
132
|
+
private
|
122
133
|
|
123
134
|
def paginate(scope, options)
|
124
|
-
|
125
|
-
|
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
|
153
|
+
def calculate_allowed_includes(presenter, presented_class)
|
138
154
|
allowed_includes = {}
|
139
|
-
model =
|
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
|
-
|
176
|
+
|
177
|
+
(user_includes || '').split(',').each do |k|
|
162
178
|
allowed = allowed_includes[k]
|
163
179
|
if allowed
|
164
|
-
filtered_includes[k
|
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
|
188
|
-
args = run_defaults
|
189
|
-
|
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
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
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
|
-
|
246
|
+
def searching?(options)
|
247
|
+
options[:params][:search] && options[:presenter].search_block.present?
|
199
248
|
end
|
200
249
|
|
201
|
-
def
|
202
|
-
|
250
|
+
def order_for_search(records, ordered_search_ids)
|
251
|
+
ids_to_position = {}
|
252
|
+
ordered_records = []
|
203
253
|
|
204
|
-
|
205
|
-
|
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
|
-
|
210
|
-
|
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
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
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
|
-
|
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
|
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
|
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(
|
257
|
-
|
258
|
-
|
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|
|
data/lib/brainstem/version.rb
CHANGED
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 "
|
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(
|
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
|
351
|
-
|
352
|
-
|
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,
|
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
|
-
|
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
|
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(:
|
438
|
-
WorkspacePresenter.default_sort_order("
|
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
|
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({
|
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, [
|
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, [
|
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, [
|
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, [
|
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, [
|
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, [
|
237
|
-
@presenter.present_and_post_process(@workspace, [
|
238
|
-
@presenter.present_and_post_process(@workspace, [
|
239
|
-
@presenter.present_and_post_process(@workspace, [
|
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.
|
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-
|
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:
|
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:
|
186
|
+
hash: 1101488451032288843
|
184
187
|
requirements: []
|
185
188
|
rubyforge_project:
|
186
189
|
rubygems_version: 1.8.25
|