brainstem 0.2.6.1 → 1.0.0.pre.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. checksums.yaml +5 -13
  2. data/CHANGELOG.md +16 -2
  3. data/Gemfile.lock +51 -36
  4. data/README.md +531 -110
  5. data/brainstem.gemspec +6 -2
  6. data/lib/brainstem.rb +25 -9
  7. data/lib/brainstem/concerns/controller_param_management.rb +22 -0
  8. data/lib/brainstem/concerns/error_presentation.rb +58 -0
  9. data/lib/brainstem/concerns/inheritable_configuration.rb +29 -0
  10. data/lib/brainstem/concerns/lookup.rb +30 -0
  11. data/lib/brainstem/concerns/presenter_dsl.rb +111 -0
  12. data/lib/brainstem/controller_methods.rb +17 -8
  13. data/lib/brainstem/dsl/association.rb +55 -0
  14. data/lib/brainstem/dsl/associations_block.rb +12 -0
  15. data/lib/brainstem/dsl/base_block.rb +31 -0
  16. data/lib/brainstem/dsl/conditional.rb +25 -0
  17. data/lib/brainstem/dsl/conditionals_block.rb +15 -0
  18. data/lib/brainstem/dsl/configuration.rb +112 -0
  19. data/lib/brainstem/dsl/field.rb +68 -0
  20. data/lib/brainstem/dsl/fields_block.rb +25 -0
  21. data/lib/brainstem/preloader.rb +98 -0
  22. data/lib/brainstem/presenter.rb +325 -134
  23. data/lib/brainstem/presenter_collection.rb +82 -286
  24. data/lib/brainstem/presenter_validator.rb +96 -0
  25. data/lib/brainstem/query_strategies/README.md +107 -0
  26. data/lib/brainstem/query_strategies/base_strategy.rb +62 -0
  27. data/lib/brainstem/query_strategies/filter_and_search.rb +50 -0
  28. data/lib/brainstem/query_strategies/filter_or_search.rb +103 -0
  29. data/lib/brainstem/test_helpers.rb +5 -1
  30. data/lib/brainstem/version.rb +1 -1
  31. data/spec/brainstem/concerns/controller_param_management_spec.rb +42 -0
  32. data/spec/brainstem/concerns/error_presentation_spec.rb +113 -0
  33. data/spec/brainstem/concerns/inheritable_configuration_spec.rb +210 -0
  34. data/spec/brainstem/concerns/presenter_dsl_spec.rb +412 -0
  35. data/spec/brainstem/controller_methods_spec.rb +15 -27
  36. data/spec/brainstem/dsl/association_spec.rb +123 -0
  37. data/spec/brainstem/dsl/conditional_spec.rb +93 -0
  38. data/spec/brainstem/dsl/configuration_spec.rb +1 -0
  39. data/spec/brainstem/dsl/field_spec.rb +212 -0
  40. data/spec/brainstem/preloader_spec.rb +137 -0
  41. data/spec/brainstem/presenter_collection_spec.rb +565 -244
  42. data/spec/brainstem/presenter_spec.rb +726 -167
  43. data/spec/brainstem/presenter_validator_spec.rb +209 -0
  44. data/spec/brainstem/query_strategies/filter_and_search_spec.rb +46 -0
  45. data/spec/brainstem/query_strategies/filter_or_search_spec.rb +45 -0
  46. data/spec/spec_helper.rb +11 -3
  47. data/spec/spec_helpers/db.rb +32 -65
  48. data/spec/spec_helpers/presenters.rb +124 -29
  49. data/spec/spec_helpers/rr.rb +11 -0
  50. data/spec/spec_helpers/schema.rb +115 -0
  51. metadata +126 -30
  52. data/lib/brainstem/association_field.rb +0 -53
  53. data/lib/brainstem/engine.rb +0 -4
  54. data/pkg/brainstem-0.2.5.gem +0 -0
  55. data/pkg/brainstem-0.2.6.gem +0 -0
  56. data/spec/spec_helpers/cleanup.rb +0 -23
@@ -1,30 +1,7 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  describe Brainstem::Presenter do
4
- describe "class methods" do
5
-
6
- describe "presents method" do
7
- before do
8
- @klass = Class.new(Brainstem::Presenter)
9
- end
10
-
11
- it "records itself as the presenter for the named class as a string" do
12
- @klass.presents "String"
13
- expect(Brainstem.presenter_collection.for(String)).to be_a(@klass)
14
- end
15
-
16
- it "records itself as the presenter for the given class" do
17
- @klass.presents String
18
- expect(Brainstem.presenter_collection.for(String)).to be_a(@klass)
19
- end
20
-
21
- it "records itself as the presenter for the named classes" do
22
- @klass.presents String, Array
23
- expect(Brainstem.presenter_collection.for(String)).to be_a(@klass)
24
- expect(Brainstem.presenter_collection.for(Array)).to be_a(@klass)
25
- end
26
- end
27
-
4
+ describe "class behavior" do
28
5
  describe "implicit namespacing" do
29
6
  module V1
30
7
  class SomePresenter < Brainstem::Presenter
@@ -33,7 +10,8 @@ describe Brainstem::Presenter do
33
10
 
34
11
  it "uses the closest module name as the presenter namespace" do
35
12
  V1::SomePresenter.presents String
36
- expect(Brainstem.presenter_collection(:v1).for(String)).to be_a(V1::SomePresenter)
13
+ expect(Brainstem.presenter_collection('v1').for(String)).to be_a(V1::SomePresenter)
14
+ expect(V1::SomePresenter.namespace).to eq 'v1'
37
15
  end
38
16
 
39
17
  it "does not map namespaced presenters into the default namespace" do
@@ -42,67 +20,264 @@ describe Brainstem::Presenter do
42
20
  end
43
21
  end
44
22
 
45
- describe "helper method" do
46
- before do
47
- @klass = Class.new(Brainstem::Presenter) do
48
- def call_helper
49
- foo
23
+ describe '.presents' do
24
+ let!(:presenter_class) { Class.new(Brainstem::Presenter) }
25
+
26
+ it 'records itself as the presenter for the given class' do
27
+ presenter_class.presents String
28
+ expect(Brainstem.presenter_collection.for(String)).to be_a(presenter_class)
29
+ end
30
+
31
+ it 'records itself as the presenter for the given classes' do
32
+ presenter_class.presents String, Array
33
+ expect(Brainstem.presenter_collection.for(String)).to be_a(presenter_class)
34
+ expect(Brainstem.presenter_collection.for(Array)).to be_a(presenter_class)
35
+ end
36
+
37
+ it 'can be called more than once' do
38
+ presenter_class.presents String
39
+ presenter_class.presents Array
40
+ expect(Brainstem.presenter_collection.for(String)).to be_a(presenter_class)
41
+ expect(Brainstem.presenter_collection.for(Array)).to be_a(presenter_class)
42
+ end
43
+
44
+ it 'returns the set of presented classes' do
45
+ expect(presenter_class.presents(String)).to eq([String])
46
+ expect(presenter_class.presents(Array)).to eq([String, Array])
47
+ expect(presenter_class.presents).to eq([String, Array])
48
+ end
49
+
50
+ it 'removes duplicates when called more then once' do
51
+ expect(presenter_class.presents(Array)).to eq([Array])
52
+ expect(presenter_class.presents(Array, Array)).to eq([Array])
53
+ end
54
+
55
+ it 'should not be inherited' do
56
+ presenter_class.presents(String)
57
+ expect(presenter_class.presents).to eq [String]
58
+ subclass = Class.new(presenter_class)
59
+ expect(subclass.presents).to eq []
60
+ subclass.presents(Array)
61
+ expect(subclass.presents).to eq [Array]
62
+ expect(presenter_class.presents).to eq [String]
63
+ end
64
+
65
+ it 'raises an error when given a string' do
66
+ expect(lambda {
67
+ presenter_class.presents 'Array'
68
+ }).to raise_error(/Brainstem Presenter#presents now expects a Class instead of a class name/)
69
+ end
70
+ end
71
+ end
72
+
73
+ describe "#group_present" do
74
+ let(:presenter_class) do
75
+ Class.new(Brainstem::Presenter) do
76
+ presents Workspace
77
+
78
+ helper do
79
+ def helper_method(model)
80
+ Task.where(:workspace_id => model.id)[0..1]
50
81
  end
51
82
  end
52
- @helper = Module.new do
53
- def foo
54
- "I work"
55
- end
83
+
84
+ fields do
85
+ field :updated_at, :datetime
56
86
  end
57
- end
58
87
 
59
- it "includes and extends the given module" do
60
- expect { @klass.new.call_helper }.to raise_error
61
- @klass.helper @helper
62
- expect(@klass.new.call_helper).to eq("I work")
63
- expect(@klass.foo).to eq("I work")
88
+ associations do
89
+ association :tasks, Task
90
+ association :user, User
91
+ association :missing_user, User
92
+ association :something, User, via: :user
93
+ association :lead_user, User
94
+ association :lead_user_with_lambda, User, dynamic: lambda { |model| model.user }
95
+ association :tasks_with_lambda, Task, dynamic: lambda { |model| Task.where(:workspace_id => model.id) }
96
+ association :tasks_with_helper_lambda, Task, dynamic: lambda { |model| helper_method(model) }
97
+ association :tasks_with_lookup, Task, lookup: lambda { |models| Task.where(workspace_id: models.map(&:id)).group_by { |task| task.workspace_id } }
98
+ association :tasks_with_lookup_fetch, Task,
99
+ lookup: lambda { |models| Task.where(workspace_id: models.map(&:id)).group_by { |task| task.workspace_id } },
100
+ lookup_fetch: lambda { |lookup, model| lookup[model.id] }
101
+ association :synthetic, :polymorphic
102
+ end
64
103
  end
65
104
  end
105
+
106
+ let(:workspace) { Workspace.find_by_title("bob workspace 1") }
107
+ let(:presenter) { presenter_class.new }
66
108
 
67
- describe "filter method" do
68
- before do
69
- @klass = Class.new(Brainstem::Presenter)
109
+ describe "the field DSL" do
110
+ let(:presenter_class) { Class.new(WorkspacePresenter) }
111
+ let(:presenter) { presenter_class.new }
112
+ let(:model) { Workspace.find(1) }
113
+
114
+ it 'calls named methods' do
115
+ expect(presenter.group_present([model]).first['title']).to eq model.title
70
116
  end
71
117
 
72
- it "creates an entry in the filters class ivar" do
73
- @klass.filter(:foo, :default => true) { 1 }
74
- expect(@klass.filters[:foo][0]).to eq({"default" => true})
75
- expect(@klass.filters[:foo][1]).to be_a(Proc)
118
+ it 'can call methods with :via' do
119
+ presenter.configuration[:fields][:title].options[:via] = :description
120
+ expect(presenter.group_present([model]).first['title']).to eq model.description
76
121
  end
77
122
 
78
- it "accepts names without blocks" do
79
- @klass.filter(:foo)
80
- expect(@klass.filters[:foo][1]).to be_nil
123
+ it 'can call a dynamic lambda' do
124
+ expect(presenter.group_present([model]).first['dynamic_title']).to eq "title: #{model.title}"
81
125
  end
82
- end
83
126
 
84
- describe "search method" do
85
- before do
86
- @klass = Class.new(Brainstem::Presenter)
127
+ it 'can call a lookup lambda' do
128
+ expect(presenter.group_present([model]).first['lookup_title']).to eq "lookup_title: #{model.title}"
129
+ end
130
+
131
+ it 'can call a lookup_fetch lambda' do
132
+ expect(presenter.group_present([model]).first['lookup_fetch_title']).to eq "lookup_fetch_title: #{model.title}"
87
133
  end
88
134
 
89
- it "creates an entry in the search class ivar" do
90
- @klass.search do end
91
- expect(@klass.search_block).to be_a(Proc)
135
+ it 'handles nesting' do
136
+ expect(presenter.group_present([model]).first['permissions']['access_level']).to eq 2
137
+ end
138
+
139
+ describe 'handling of conditional fields' do
140
+ it 'does not return conditional fields when their :if conditionals do not match' do
141
+ expect(presenter.group_present([model]).first['secret']).to be_nil
142
+ expect(presenter.group_present([model]).first['bob_title']).to be_nil
143
+ end
144
+
145
+ it 'returns conditional fields when their :if matches' do
146
+ model.title = 'hello'
147
+ expect(presenter.group_present([model]).first['hello_title']).to eq 'title is hello'
148
+ end
149
+
150
+ it 'returns fields with the :if option only when all of the conditionals in that :if are true' do
151
+ model.title = 'hello'
152
+ presenter.class.helper do
153
+ def current_user
154
+ 'not bob'
155
+ end
156
+ end
157
+ expect(presenter.group_present([model]).first['secret']).to be_nil
158
+ presenter.class.helper do
159
+ def current_user
160
+ 'bob'
161
+ end
162
+ end
163
+ expect(presenter.group_present([model]).first['secret']).to eq model.secret_info
164
+ end
165
+
166
+ describe "caching of conditional evaluations" do
167
+ it 'only runs model conditionals once per model' do
168
+ model_id_call_count = { 1 => 0, 2 => 0 }
169
+
170
+ presenter_class.conditionals do
171
+ model :model_id_is_two, lambda { |model| model_id_call_count[model.id] += 1; model.id == 2 }
172
+ end
173
+
174
+ presenter_class.fields do
175
+ field :only_on_model_two, :string,
176
+ dynamic: lambda { "some value" },
177
+ if: :model_id_is_two
178
+ field :another_only_on_model_two, :string,
179
+ dynamic: lambda { "some value" },
180
+ if: :model_id_is_two
181
+ end
182
+
183
+ results = presenter.group_present([Workspace.find(1), Workspace.find(2)])
184
+ expect(results.first['only_on_model_two']).not_to be_present
185
+ expect(results.last['only_on_model_two']).to be_present
186
+ expect(results.first['another_only_on_model_two']).not_to be_present
187
+ expect(results.last['another_only_on_model_two']).to be_present
188
+ expect(results.first['id']).to eq '1'
189
+ expect(results.last['id']).to eq '2'
190
+
191
+ expect(model_id_call_count).to eq({ 1 => 1, 2 => 1 })
192
+ end
193
+
194
+ it 'only runs request conditionals once per request' do
195
+ call_count = 0
196
+
197
+ presenter_class.conditionals do
198
+ request :new_request_conditional, lambda { call_count += 1 }
199
+ end
200
+
201
+ presenter_class.fields do
202
+ field :new_field, :string,
203
+ dynamic: lambda { "new_field value" },
204
+ if: :new_request_conditional
205
+ field :new_field2, :string,
206
+ dynamic: lambda { "new_field2 value" },
207
+ if: :new_request_conditional
208
+ end
209
+
210
+ presenter.group_present([Workspace.find(1), Workspace.find(2)])
211
+
212
+ expect(call_count).to eq 1
213
+ end
214
+ end
215
+ end
216
+
217
+ describe 'helpers in dynamic fields' do
218
+ let(:presenter_class) do
219
+ Class.new(Brainstem::Presenter) do
220
+ helper do
221
+ def counter
222
+ @count ||= 0
223
+ @count += 1
224
+ end
225
+ end
226
+
227
+ fields do
228
+ field :memoized_helper_value1, :integer, dynamic: lambda { |model| counter }
229
+ field :memoized_helper_value2, :integer, dynamic: lambda { |model| counter }
230
+ field :memoized_helper_value3, :integer, dynamic: lambda { |model| counter }
231
+ field :memoized_helper_value4, :integer, dynamic: lambda { |model| counter }
232
+ end
233
+ end
234
+ end
235
+
236
+ let(:presenter) { presenter_class.new }
237
+
238
+ it 'shares the helper instance across fields, but not across instances' do
239
+ fields = presenter.group_present([model, model])
240
+ expect(fields[0].slice(*%w[memoized_helper_value1 memoized_helper_value2 memoized_helper_value3 memoized_helper_value4]).values).to match_array [1, 2, 3, 4]
241
+ expect(fields[1].slice(*%w[memoized_helper_value1 memoized_helper_value2 memoized_helper_value3 memoized_helper_value4]).values).to match_array [1, 2, 3, 4]
242
+ end
243
+ end
244
+
245
+ describe 'handling of optional fields' do
246
+ it 'does not include optional fields by default' do
247
+ expect(presenter.group_present([model]).first).not_to have_key('expensive_title')
248
+ end
249
+
250
+ it 'includes optional fields when explicitly requested' do
251
+ presented_workspace = presenter.group_present([model], [], optional_fields: ['expensive_title', 'expensive_title2']).first
252
+
253
+ expect(presented_workspace).to have_key('expensive_title')
254
+ expect(presented_workspace).to have_key('expensive_title2')
255
+ expect(presented_workspace).not_to have_key('expensive_title3')
256
+ end
257
+
258
+ context 'handling of conditional' do
259
+ it 'does not include field when condition is not met' do
260
+ model.title = 'Not hello'
261
+ presented_workspace = presenter.group_present([model], [], optional_fields: ['conditional_expensive_title']).first
262
+ expect(presented_workspace).not_to have_key('conditional_expensive_title')
263
+ end
264
+
265
+ it 'includes field when condition is met' do
266
+ model.title = 'hello'
267
+ presented_workspace = presenter.group_present([model], [], optional_fields: ['conditional_expensive_title']).first
268
+ expect(presented_workspace).to have_key('conditional_expensive_title')
269
+ end
270
+ end
92
271
  end
93
272
  end
94
- end
95
273
 
96
- describe "post_process hooks" do
97
274
  describe "adding object ids as strings" do
98
275
  before do
99
276
  post_presenter = Class.new(Brainstem::Presenter) do
100
277
  presents Post
101
278
 
102
- def present(model)
103
- {
104
- :body => model.body,
105
- }
279
+ fields do
280
+ field :body, :string
106
281
  end
107
282
  end
108
283
 
@@ -111,167 +286,551 @@ describe Brainstem::Presenter do
111
286
  end
112
287
 
113
288
  it "outputs the associated object's id and type" do
114
- data = @presenter.present_and_post_process(@post)
115
- expect(data[:id]).to eq(@post.id.to_s)
116
- expect(data[:body]).to eq(@post.body)
289
+ data = @presenter.group_present([@post]).first
290
+ expect(data['id']).to eq(@post.id.to_s)
291
+ expect(data['body']).to eq(@post.body)
117
292
  end
118
293
  end
119
294
 
120
295
  describe "converting dates and times" do
121
296
  it "should convert all Time-and-date-like objects to iso8601" do
122
- class TimePresenter < Brainstem::Presenter
123
- def present(model)
124
- {
125
- :time => Time.now,
126
- :date => Date.new,
127
- :recursion => {
128
- :time => Time.now,
129
- :something => [Time.now, :else],
130
- :foo => :bar
131
- }
132
- }
297
+ presenter = Class.new(Brainstem::Presenter) do
298
+ fields do
299
+ field :time, :datetime, dynamic: lambda { Time.now }
300
+ field :date, :date, dynamic: lambda { Date.new }
301
+ fields :recursion do
302
+ field :time, :datetime, dynamic: lambda { Time.now }
303
+ field :something, :datetime, dynamic: lambda { [Time.now, :else] }
304
+ field :foo, :string, dynamic: lambda { :bar }
305
+ end
133
306
  end
134
307
  end
135
308
 
136
309
  iso8601_time = /\d{4}-\d{2}-\d{2}T\d{2}\:\d{2}\:\d{2}[-+]\d{2}:\d{2}/
137
310
  iso8601_date = /\d{4}-\d{2}-\d{2}/
138
311
 
139
- struct = TimePresenter.new.present_and_post_process(Workspace.first)
140
- expect(struct[:time]).to match(iso8601_time)
141
- expect(struct[:date]).to match(iso8601_date)
142
- expect(struct[:recursion][:time]).to match(iso8601_time)
143
- expect(struct[:recursion][:something].first).to match(iso8601_time)
144
- expect(struct[:recursion][:something].last).to eq(:else)
145
- expect(struct[:recursion][:foo]).to eq(:bar)
312
+ struct = presenter.new.group_present([Workspace.first]).first
313
+ expect(struct['time']).to match(iso8601_time)
314
+ expect(struct['date']).to match(iso8601_date)
315
+ expect(struct['recursion']['time']).to match(iso8601_time)
316
+ expect(struct['recursion']['something'].first).to match(iso8601_time)
317
+ expect(struct['recursion']['something'].last).to eq(:else)
318
+ expect(struct['recursion']['foo']).to eq(:bar)
146
319
  end
147
320
  end
148
321
 
149
- describe "outputting polymorphic associations" do
150
- before do
151
- some_presenter = Class.new(Brainstem::Presenter) do
152
- presents Post
322
+ describe "outputting associations" do
323
+ it "should not convert or return non-included associations, but should return <association>_id for belongs_to relationships, plus all fields" do
324
+ json = presenter.group_present([workspace], []).first
325
+ expect(json.keys).to match_array %w[id updated_at something_id user_id]
326
+ end
153
327
 
154
- def present(model)
155
- {
156
- :body => model.body,
157
- :subject => association(:subject),
158
- :another_subject => association(:subject),
159
- :something_else => association(:subject, :ignore_type => true)
160
- }
161
- end
162
- end
328
+ it "should convert requested has_many associations (includes) into the <association>_ids format" do
329
+ expect(workspace.tasks.length).to be > 0
330
+ expect(presenter.group_present([workspace], ['tasks']).first['task_ids']).to match_array(workspace.tasks.map(&:id).map(&:to_s))
331
+ end
332
+
333
+ it "should ignore unknown associations" do
334
+ result = presenter.group_present(Workspace.all.to_a, ['tasks', 'unknown'])
335
+ expect(result.length).to eq Workspace.count
336
+ expect(result.last['task_ids']).to eq Workspace.last.tasks.pluck(:id).map(&:to_s)
337
+ end
338
+
339
+ it "should allow has_many associations to work on groups of models" do
340
+ result = presenter.group_present(Workspace.all.to_a, ['tasks'])
341
+ expect(result.length).to eq Workspace.count
342
+ first_workspace_tasks = Workspace.first.tasks.pluck(:id).map(&:to_s)
343
+ last_workspace_tasks = Workspace.last.tasks.pluck(:id).map(&:to_s)
344
+ expect(last_workspace_tasks.length).to eq 1
345
+ expect(result.last['task_ids']).to eq last_workspace_tasks
346
+ expect(result.first['task_ids']).to eq first_workspace_tasks
347
+ end
348
+
349
+ it "should convert requested belongs_to and has_one associations into the <association>_id format when requested" do
350
+ expect(presenter.group_present([workspace], ['user']).first['user_id']).to eq(workspace.user.id.to_s)
351
+ end
352
+
353
+ it "should convert requested belongs_to and has_one associations into the <association>_id format when requested, even if they're not found" do
354
+ expect(presenter.group_present([workspace], ['missing_user']).first).to have_key('missing_user_id')
355
+ expect(presenter.group_present([workspace], ['missing_user']).first['missing_user_id']).to eq(nil)
356
+ end
357
+
358
+ it "converts non-association models into <model>_id format when they are requested" do
359
+ expect(presenter.group_present([workspace], ['lead_user']).first['lead_user_id']).to eq(workspace.lead_user.id.to_s)
360
+ end
361
+
362
+ it "handles associations provided with lambdas" do
363
+ expect(presenter.group_present([workspace], ['lead_user_with_lambda']).first['lead_user_with_lambda_id']).to eq(workspace.lead_user.id.to_s)
364
+ expect(presenter.group_present([workspace], ['tasks_with_lambda']).first['tasks_with_lambda_ids']).to eq(workspace.tasks.map(&:id).map(&:to_s))
365
+ end
366
+
367
+ it "handles associations provided with a lookup" do
368
+ expect(presenter.group_present([workspace], ['tasks_with_lookup']).first['tasks_with_lookup_ids']).to eq(workspace.tasks.map(&:id).map(&:to_s))
369
+ end
370
+
371
+ it "handles associations provided with a lookup_fetch" do
372
+ expect(presenter.group_present([workspace], ['tasks_with_lookup_fetch']).first['tasks_with_lookup_fetch_ids']).to eq(workspace.tasks.map(&:id).map(&:to_s))
373
+ end
163
374
 
164
- @presenter = some_presenter.new
375
+ it "handles helpers method calls in association lambdas" do
376
+ expect(presenter.group_present([workspace], ['tasks_with_helper_lambda']).first['tasks_with_helper_lambda_ids']).to eq(workspace.tasks.map(&:id).map(&:to_s)[0..1])
165
377
  end
166
378
 
167
- let(:presented_data) { @presenter.present_and_post_process(post) }
379
+ it "should return <association>_id fields when the given association ids exist on the model whether it is requested or not" do
380
+ expect(presenter.group_present([workspace], ['user']).first['user_id']).to eq(workspace.user_id.to_s)
168
381
 
169
- context "when polymorphic association exists" do
170
- let(:post) { Post.find(1) }
382
+ json = presenter.group_present([workspace], []).first
383
+ expect(json.keys).to match_array %w[user_id something_id id updated_at]
384
+ expect(json['user_id']).to eq(workspace.user_id.to_s)
385
+ expect(json['something_id']).to eq(workspace.user_id.to_s)
386
+ end
171
387
 
388
+ it "should return null, not empty string when ids are missing" do
389
+ workspace.user = nil
390
+ workspace.tasks = []
391
+ expect(presenter.group_present([workspace], ['lead_user_with_lambda']).first['lead_user_with_lambda_id']).to eq(nil)
392
+ expect(presenter.group_present([workspace], ['user']).first['user_id']).to eq(nil)
393
+ expect(presenter.group_present([workspace], ['something']).first['something_id']).to eq(nil)
394
+ expect(presenter.group_present([workspace], ['tasks']).first['task_ids']).to eq([])
395
+ end
172
396
 
173
- it "outputs the object as a hash with the id & class table name" do
174
- expect(presented_data[:subject_ref]).to eq({ :id => post.subject.id.to_s,
175
- :key => post.subject.class.table_name })
397
+ describe "polymorphic associations" do
398
+ let(:some_presenter) do
399
+ Class.new(Brainstem::Presenter) do
400
+ presents Post
401
+
402
+ fields do
403
+ field :body, :string
404
+ end
405
+
406
+ associations do
407
+ association :subject, :polymorphic
408
+ association :another_subject, :polymorphic, via: :subject
409
+ association :forced_model, Workspace, via: :subject
410
+ association :things, :polymorphic
411
+ end
412
+ end
176
413
  end
177
414
 
178
- it "outputs custom names for the object as a hash with the id & class table name" do
179
- expect(presented_data[:another_subject_ref]).to eq({ :id => post.subject.id.to_s,
180
- :key => post.subject.class.table_name })
415
+ let(:presenter) { some_presenter.new }
416
+
417
+ context "when asking for the association" do
418
+ let(:presented_data) { presenter.group_present([post], %w[subject another_subject forced_model things]).first }
419
+
420
+ context "when polymorphic association exists" do
421
+ let(:post) { Post.find(1) }
422
+
423
+ it "outputs the object as a hash with the id & class table name" do
424
+ expect(presented_data['subject_ref']).to eq({ 'id' => post.subject.id.to_s,
425
+ 'key' => 'workspaces' })
426
+ end
427
+
428
+ it "outputs custom names for the object as a hash with the id & class table name" do
429
+ expect(presented_data['another_subject_ref']).to eq({ 'id' => post.subject.id.to_s,
430
+ 'key' => 'workspaces' })
431
+ end
432
+
433
+ context "presenting a mixture of things" do
434
+ it 'will return a *_refs array' do
435
+ expect(presented_data['thing_refs']).to eq [
436
+ { 'id' => '1', 'key' => 'workspaces' },
437
+ { 'id' => '1', 'key' => 'posts' },
438
+ { 'id' => '1', 'key' => 'tasks' }
439
+ ]
440
+ end
441
+ end
442
+
443
+ context "for STI targets" do
444
+ let(:post) { Post.create!(subject: Attachments::PostAttachment.first, user: User.first, body: '1 2 3') }
445
+
446
+ it "uses the brainstem_key from the presenter" do
447
+ expect(presented_data['subject_ref']).to eq({ 'id' => post.subject_id.to_s,
448
+ 'key' => 'attachments' })
449
+ end
450
+
451
+ it "uses the correct namespace when finding a presenter" do
452
+ module V2
453
+ class NewPostPresenter < Brainstem::Presenter
454
+ presents Post
455
+
456
+ associations do
457
+ association :subject, :polymorphic
458
+ end
459
+ end
460
+ end
461
+
462
+ expect {
463
+ V2::NewPostPresenter.new.group_present([post], %w[subject]).first
464
+ }.to raise_error(/Unable to find a presenter for class Attachments::PostAttachment/)
465
+ end
466
+ end
467
+
468
+ it "skips the polymorphic handling when a model is given" do
469
+ expect(presented_data['forced_model_id']).to eq(post.subject.id.to_s)
470
+ expect(presented_data).not_to have_key('forced_model_type')
471
+ expect(presented_data).not_to have_key('forced_model_ref')
472
+ end
473
+
474
+ describe "the legacy :always_return_ref_with_sti_base option" do
475
+ before do
476
+ some_presenter.associations do
477
+ association :always_subject, :polymorphic, via: :subject,
478
+ always_return_ref_with_sti_base: true
479
+ end
480
+ end
481
+
482
+ let(:post) { Post.create!(subject: Attachments::PostAttachment.first, user: User.first, body: '1 2 3') }
483
+
484
+ describe 'when the presenter can be found' do
485
+ before do
486
+ Class.new(Brainstem::Presenter) do
487
+ presents Attachments::Base
488
+
489
+ brainstem_key :foo
490
+ end
491
+ end
492
+
493
+ it "always returns the *_ref object, even when not included" do
494
+ expect(presented_data['always_subject_ref']).to eq({ 'id' => post.subject.id.to_s,
495
+ 'key' => 'foo' })
496
+ end
497
+ end
498
+
499
+ # It tries to find the key based on the *_type value in the DB (which will be the STI base class, and may error if no presenter exists)
500
+ describe 'when the presenter cannot be found' do
501
+ it "raises an error" do
502
+ expect { presented_data['always_subject_ref'] }.to raise_error(/Unable to find a presenter for class Attachments::Base/)
503
+ end
504
+ end
505
+ end
506
+ end
507
+
508
+ context "when polymorphic association does not exist" do
509
+ let(:post) { Post.find(3) }
510
+
511
+ it "outputs nil" do
512
+ expect(presented_data).to have_key('subject_ref')
513
+ expect(presented_data['subject_ref']).to be_nil
514
+ end
515
+
516
+ it "outputs nil" do
517
+ expect(presented_data).to have_key('another_subject_ref')
518
+ expect(presented_data['another_subject_ref']).to be_nil
519
+ end
520
+ end
181
521
  end
182
522
 
183
- it "skips the polymorphic handling when ignore_type is true" do
184
- expect(presented_data[:something_else_id]).to eq(post.subject.id.to_s)
185
- expect(presented_data).not_to have_key(:something_else_type)
186
- expect(presented_data).not_to have_key(:something_else_ref)
523
+ context "when not asking for the association" do
524
+ let(:presented_data) { presenter.group_present([post]).first }
525
+ let(:post) { Post.find(1) }
526
+
527
+ it "does not include the reference" do
528
+ expect(presented_data).to_not have_key('subject_ref')
529
+ expect(presented_data).to_not have_key('another_subject_ref')
530
+ end
187
531
  end
188
532
  end
189
533
 
190
- context "when polymorphic association does not exist" do
191
- let(:post) { Post.find(3) }
534
+ context "when the model has an <association>_id method but no column" do
535
+ it "does not include the <association>_id field" do
536
+ def workspace.synthetic_id
537
+ raise "this explodes because it's not an association"
538
+ end
539
+ expect(presenter.group_present([workspace], []).first).not_to have_key('synthetic_id')
540
+ end
541
+ end
542
+ end
192
543
 
193
- it "outputs nil" do
194
- expect(presented_data[:subject_ref]).to be_nil
544
+ describe "preloading" do
545
+
546
+ #
547
+ # We have three strategies for introspecting what the AR Preloader
548
+ # receives:
549
+ #
550
+ # 1. Actually looking at what AR receives.
551
+ #
552
+ # This is sub-optimal because AR works differently between versions
553
+ # 3 and 4, so introspecting is difficult.
554
+ #
555
+ # 2. Looking at what the proc that encapsulates the AR methods is
556
+ # called with.
557
+ #
558
+ # This is difficult to do because we don't control the instantiation
559
+ # of the Preloader, which makes injecting this hard.
560
+ #
561
+ # 3. Intercept the instantiating parent function and append the
562
+ # inspectable proc.
563
+ #
564
+ # This is probably the grossest of all the above options, and I
565
+ # suspect it's the greatest indication that we're testing
566
+ # inappropriately here -- a fact that I think bears weight given
567
+ # we're making unit-level assertions in an integration spec.
568
+ # However, there's also some value in asserting that these are
569
+ # passed through without digging into Rails internals. However,
570
+ # further 'purity' improvements could be made by introspecting on
571
+ # AR's actual data structures.
572
+ #
573
+ # That's about three shades on the side of overkill, though.
574
+ #
575
+ def preloader_should_receive(hsh)
576
+ preload_method = Object.new
577
+ mock(preload_method).call(anything, anything) do |models, args|
578
+ expect(args).to eq(hsh)
195
579
  end
196
580
 
197
- it "outputs nil" do
198
- expect(presented_data[:another_subject_ref]).to be_nil
581
+ stub(Brainstem::Preloader).preload(anything, anything, anything) do |*args|
582
+ args << preload_method
583
+ Brainstem::Preloader.new(*args).call
199
584
  end
200
585
  end
586
+
587
+
588
+ it "preloads associations when they are full model-level associations" do
589
+ preloader_should_receive("tasks" => [], "user" => [])
590
+ presenter.group_present(Workspace.order('id desc'), %w[tasks user lead_user tasks_with_lambda])
591
+ end
592
+
593
+ it "includes any associations declared via the preload DSL directive" do
594
+ preloader_should_receive("tasks" => [], "posts" => [])
595
+ presenter_class.preload :posts
596
+
597
+ presenter.group_present(Workspace.order('id desc'), %w[tasks lead_user tasks_with_lambda])
598
+ end
599
+
600
+ it "includes any string associations declared via the preload DSL directive" do
601
+ preloader_should_receive("tasks" => [], "user" => [])
602
+ presenter_class.preload 'user'
603
+
604
+ presenter.group_present(Workspace.order('id desc'), %w[tasks user lead_user tasks_with_lambda])
605
+ end
606
+
607
+ it "includes any nested hash associations declared via the preload DSL directive" do
608
+ preloader_should_receive("tasks" => [], "user" => [:workspaces], "posts" => ["subject", "user"])
609
+ presenter_class.preload :tasks, "user", "unknown", { "posts" => "subject", "foo" => "bar" },{ :user => :workspaces, "posts" => "user" }
610
+
611
+ presenter.group_present(Workspace.order('id desc'), %w[tasks user lead_user tasks_with_lambda])
612
+ end
201
613
  end
614
+ end
202
615
 
203
- describe "outputting associations" do
204
- before do
205
- some_presenter = Class.new(Brainstem::Presenter) do
206
- presents Workspace
207
-
208
- def present(model)
209
- {
210
- :updated_at => model.updated_at,
211
- :tasks => association(:tasks),
212
- :user => association(:user),
213
- :something => association(:user),
214
- :lead_user => association(:lead_user),
215
- :lead_user_with_lambda => association(:json_name => "users") { |model| model.user },
216
- :tasks_with_lambda => association(:json_name => "tasks") { |model| Task.where(:workspace_id => model) },
217
- :synthetic => association(:synthetic)
218
- }
616
+ describe "#extract_filters" do
617
+ let(:presenter_class) { WorkspacePresenter }
618
+ let(:presenter) { presenter_class.new }
619
+
620
+ it 'returns only known filters' do
621
+ presenter_class.filter :owned_by
622
+ presenter_class.filter(:bar) { |scope| scope }
623
+ expect(presenter.extract_filters({ 'foo' => 'hi' })).to eq({})
624
+ expect(presenter.extract_filters({ 'owned_by' => '2' })).to eq({ 'owned_by' => '2' })
625
+ expect(presenter.extract_filters({ 'owned_by' => [2] })).to eq({ 'owned_by' => [2] })
626
+ expect(presenter.extract_filters({ 'owned_by' => { :ids => [2], 2 => [1] }})).to eq({ 'owned_by' => { :ids => [2], 2 => [1] }})
627
+ end
628
+
629
+ it "converts 'true' and 'false' into true and false" do
630
+ presenter_class.filter :owned_by
631
+ expect(presenter.extract_filters({ 'owned_by' => 'true' })).to eq({ 'owned_by' => true })
632
+ expect(presenter.extract_filters({ 'owned_by' => 'TRUE' })).to eq({ 'owned_by' => true })
633
+ expect(presenter.extract_filters({ 'owned_by' => 'false' })).to eq({ 'owned_by' => false })
634
+ expect(presenter.extract_filters({ 'owned_by' => 'FALSE' })).to eq({ 'owned_by' => false })
635
+ expect(presenter.extract_filters({ 'owned_by' => 'hi' })).to eq({ 'owned_by' => 'hi' })
636
+ end
637
+
638
+ it 'defaults to applying default filters' do
639
+ presenter_class.filter :owned_by, default: '2'
640
+ expect(presenter.extract_filters({ 'owned_by' => '3' })).to eq({ 'owned_by' => '3' })
641
+ expect(presenter.extract_filters({})).to eq({ 'owned_by' => '2' })
642
+ end
643
+
644
+ it 'will skip default filters when asked' do
645
+ presenter_class.filter :owned_by, default: '2'
646
+ expect(presenter.extract_filters({ 'owned_by' => '3' }, apply_default_filters: false)).to eq({ 'owned_by' => '3' })
647
+ expect(presenter.extract_filters({}, apply_default_filters: false)).to eq({})
648
+ end
649
+
650
+ it 'ignores nil and blank values' do
651
+ presenter_class.filter :owned_by
652
+ expect(presenter.extract_filters({ 'owned_by' => nil })).to eq({})
653
+ expect(presenter.extract_filters({ 'owned_by' => '' })).to eq({})
654
+ end
655
+ end
656
+
657
+ describe "#apply_filters_to_scope" do
658
+ let(:presenter_class) { WorkspacePresenter }
659
+ let(:presenter) { presenter_class.new }
660
+ let(:scope) { Workspace.where(nil) }
661
+ let(:params) { { 'bar' => 'foo' } }
662
+ let(:options) { { apply_default_filters: true } }
663
+
664
+ before do
665
+ presenter_class.filter :owned_by, default: '2'
666
+ presenter_class.filter(:bar) { |scope| scope.where(id: 6) }
667
+ mock(presenter).extract_filters(params, options) { { 'bar' => 'foo', 'owned_by' => '2' } }
668
+ end
669
+
670
+ it 'extracts valid filters from the params' do
671
+ presenter.apply_filters_to_scope(scope, params, options)
672
+ end
673
+
674
+ it 'runs lambdas in the scope of the helper instance' do
675
+ expect(presenter.apply_filters_to_scope(scope, params, options).to_sql).to match(/id.\s*=\s*6/)
676
+ end
677
+
678
+ it 'sends symbols to the scope' do
679
+ expect(presenter.apply_filters_to_scope(scope, params, options).to_sql).to match(/id.\s*=\s*2/)
680
+ end
681
+ end
682
+
683
+ describe '#apply_ordering_to_scope' do
684
+ let(:presenter_class) { WorkspacePresenter }
685
+ let(:presenter) { presenter_class.new }
686
+ let(:scope) { Workspace.where(nil) }
687
+
688
+ it 'uses #calculate_sort_name_and_direction to extract a sort name and direction from user params' do
689
+ presenter_class.sort_order :title, 'workspaces.title'
690
+ mock(presenter).calculate_sort_name_and_direction('order' => 'title:desc') { ['title', 'desc'] }
691
+ presenter.apply_ordering_to_scope(scope, 'order' => 'title:desc')
692
+ end
693
+
694
+ context 'when the sort is a proc' do
695
+ it 'runs procs in the context of any helpers' do
696
+ presenter_class.helper do
697
+ def some_method
219
698
  end
220
699
  end
221
700
 
222
- @presenter = some_presenter.new
223
- @workspace = Workspace.find_by_title "bob workspace 1"
701
+ direction = nil
702
+ presenter_class.sort_order(:title) do |scope, d|
703
+ some_method
704
+ direction = d
705
+ scope
706
+ end
707
+
708
+ presenter.apply_ordering_to_scope(scope, 'order' => 'title:asc')
709
+ expect(direction).to eq 'asc'
224
710
  end
225
711
 
226
- it "should not convert or return non-included associations, but should return <association>_id for belongs_to relationships, plus all fields" do
227
- json = @presenter.present_and_post_process(@workspace, [])
228
- expect(json.keys).to match_array([:id, :updated_at, :something_id, :user_id])
712
+ it 'can chain multiple sorts together' do
713
+ presenter_class.sort_order(:title) do |scope|
714
+ scope.order('workspaces.title desc').order('workspaces.id desc')
715
+ end
716
+
717
+ sql = presenter.apply_ordering_to_scope(scope, 'order' => 'title').to_sql
718
+ expect(sql).to match(/order by workspaces.title desc, workspaces.id desc, "workspaces"."id" ASC/i)
719
+ # this should be ok, since the first id sort will never have a tie
229
720
  end
230
721
 
231
- it "should convert requested has_many associations (includes) into the <association>_ids format" do
232
- expect(@workspace.tasks.length).to be > 0
233
- expect(@presenter.present_and_post_process(@workspace, ["tasks"])[:task_ids]).to match_array(@workspace.tasks.map(&:id).map(&:to_s))
722
+ it 'chains the primary key onto the end' do
723
+ presenter_class.sort_order(:title) do |scope|
724
+ scope.order('workspaces.title desc')
725
+ end
726
+
727
+ sql = presenter.apply_ordering_to_scope(scope, 'order' => 'title').to_sql
728
+ expect(sql).to match(/order by workspaces.title desc, "workspaces"."id" ASC/i)
234
729
  end
730
+ end
235
731
 
236
- it "should convert requested belongs_to and has_one associations into the <association>_id format when requested" do
237
- expect(@presenter.present_and_post_process(@workspace, ["user"])[:user_id]).to eq(@workspace.user.id.to_s)
732
+ context 'when the sort is not a proc' do
733
+ before do
734
+ presenter_class.sort_order :title, 'workspaces.title'
735
+ presenter_class.sort_order :id, 'workspaces.id'
238
736
  end
239
737
 
240
- it "converts non-association models into <model>_id format when they are requested" do
241
- expect(@presenter.present_and_post_process(@workspace, ["lead_user"])[:lead_user_id]).to eq(@workspace.lead_user.id.to_s)
738
+ context 'and is a string' do
739
+ let(:order) { { 'order' => 'title:asc' } }
740
+
741
+ it 'applies the named ordering in the given direction and adds the primary key as a fallback sort' do
742
+ sql = presenter.apply_ordering_to_scope(scope, order).to_sql
743
+ expect(sql).to match(/ORDER BY workspaces.title asc, "workspaces"."id" ASC/i)
744
+ end
242
745
  end
243
746
 
244
- it "handles associations provided with lambdas" do
245
- expect(@presenter.present_and_post_process(@workspace, ["lead_user_with_lambda"])[:lead_user_with_lambda_id]).to eq(@workspace.lead_user.id.to_s)
246
- expect(@presenter.present_and_post_process(@workspace, ["tasks_with_lambda"])[:tasks_with_lambda_ids]).to eq(@workspace.tasks.map(&:id).map(&:to_s))
747
+ context 'and is a symbol' do
748
+ let(:order) { { 'order' => :title } }
749
+
750
+ it 'applies the named ordering in the given direction and adds the primary key as a fallback sort' do
751
+ sql = presenter.apply_ordering_to_scope(scope, order).to_sql
752
+ expect(sql).to match(/order by workspaces.title asc, "workspaces"."id" ASC/i)
753
+ end
247
754
  end
755
+ end
248
756
 
249
- it "should return <association>_id fields when the given association ids exist on the model whether it is requested or not" do
250
- expect(@presenter.present_and_post_process(@workspace, ["user"])[:user_id]).to eq(@workspace.user_id.to_s)
757
+ context 'when the sort is not present' do
758
+ let(:order) { '' }
251
759
 
252
- json = @presenter.present_and_post_process(@workspace, [])
253
- expect(json.keys).to match_array([:user_id, :something_id, :id, :updated_at])
254
- expect(json[:user_id]).to eq(@workspace.user_id.to_s)
255
- expect(json[:something_id]).to eq(@workspace.user_id.to_s)
760
+ it 'orders by the primary key' do
761
+ sql = presenter.apply_ordering_to_scope(scope, order).to_sql
762
+ expect(sql).to match(/order by "workspaces"."id" ASC/i)
256
763
  end
764
+ end
257
765
 
258
- it "should return null, not empty string when ids are missing" do
259
- @workspace.user = nil
260
- @workspace.tasks = []
261
- expect(@presenter.present_and_post_process(@workspace, ["lead_user_with_lambda"])[:lead_user_with_lambda_id]).to eq(nil)
262
- expect(@presenter.present_and_post_process(@workspace, ["user"])[:user_id]).to eq(nil)
263
- expect(@presenter.present_and_post_process(@workspace, ["something"])[:something_id]).to eq(nil)
264
- expect(@presenter.present_and_post_process(@workspace, ["tasks"])[:task_ids]).to eq([])
766
+ context 'when the table has no primary key' do
767
+ let(:scope) { Cthulhu.where(nil) }
768
+ let(:order) { { 'order' => 'updated_at:asc' } }
769
+
770
+ before do
771
+ class Cthulhu < Workspace
772
+ self.primary_key = nil
773
+ end
774
+
775
+ presenter_class.sort_order :updated_at, 'workspaces.updated_at'
265
776
  end
266
777
 
267
- context "when the model has an <association>_id method but no column" do
268
- it "does not include the <association>_id field" do
269
- def @workspace.synthetic_id
270
- raise "this explodes because it's not an association"
271
- end
272
- expect(@presenter.present_and_post_process(@workspace, [])).not_to have_key(:synthetic_id)
778
+ it 'does not add a fallback deterministic sort, and you deserve whatever fate befalls you' do
779
+ sql = presenter.apply_ordering_to_scope(scope, order).to_sql.squish
780
+ expect(sql).to eq("SELECT \"workspaces\".* FROM \"workspaces\" WHERE \"workspaces\".\"type\" IN ('Cthulhu') ORDER BY workspaces.updated_at asc")
781
+ end
782
+ end
783
+ end
784
+
785
+ describe "#calculate_sort_name_and_direction" do
786
+ let(:presenter_class) { WorkspacePresenter }
787
+ let(:presenter) { presenter_class.new }
788
+
789
+ it 'uses default_sort_order by default when present' do
790
+ presenter_class.default_sort_order 'foo:asc'
791
+ expect(presenter.calculate_sort_name_and_direction).to eq ['foo', 'asc']
792
+ end
793
+
794
+ it 'uses updated_at:desc when no default has been set' do
795
+ expect(presenter.calculate_sort_name_and_direction).to eq ['updated_at', 'desc']
796
+ end
797
+
798
+ it 'ignores unknown sorts' do
799
+ presenter_class.sort_order :foo, 'workspaces.foo'
800
+ expect(presenter.calculate_sort_name_and_direction('order' => 'hello:desc')).to eq ['updated_at', 'desc']
801
+ expect(presenter.calculate_sort_name_and_direction('order' => 'foo:desc')).to eq ['foo', 'desc']
802
+ end
803
+
804
+ it 'sanitizes the direction' do
805
+ presenter_class.sort_order :foo, 'workspaces.foo'
806
+ expect(presenter.calculate_sort_name_and_direction('order' => 'foo:drop table')).to eq ['foo', 'asc']
807
+ expect(presenter.calculate_sort_name_and_direction('order' => 'foo:')).to eq ['foo', 'asc']
808
+ expect(presenter.calculate_sort_name_and_direction('order' => 'foo:hi')).to eq ['foo', 'asc']
809
+ expect(presenter.calculate_sort_name_and_direction('order' => 'foo:DESCE')).to eq ['foo', 'asc']
810
+ expect(presenter.calculate_sort_name_and_direction('order' => 'foo:asc')).to eq ['foo', 'asc']
811
+ expect(presenter.calculate_sort_name_and_direction('order' => 'foo:;;;droptable::;;')).to eq ['foo', 'asc']
812
+ end
813
+ end
814
+
815
+ describe '#allowed_associations' do
816
+ let(:presenter_class) do
817
+ Class.new(Brainstem::Presenter) do
818
+ associations do
819
+ association :user, User
820
+ association :workspace, Workspace
821
+ association :task, Task, restrict_to_only: true
273
822
  end
274
823
  end
275
824
  end
825
+
826
+ let(:presenter_instance) { presenter_class.new }
827
+
828
+ it 'returns all associations that are not restrict_to_only' do
829
+ expect(presenter_instance.allowed_associations(is_only_query = false).keys).to match_array %w[user workspace]
830
+ end
831
+
832
+ it 'returns associations that are restrict_to_only if is_only_query is true' do
833
+ expect(presenter_instance.allowed_associations(is_only_query = true).keys).to match_array %w[user workspace task]
834
+ end
276
835
  end
277
836
  end