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.
- 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
|
+
[](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
|