brainstem 0.2.6.1 → 1.0.0.pre.1
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.
- checksums.yaml +5 -13
- data/CHANGELOG.md +16 -2
- data/Gemfile.lock +51 -36
- data/README.md +531 -110
- data/brainstem.gemspec +6 -2
- data/lib/brainstem.rb +25 -9
- data/lib/brainstem/concerns/controller_param_management.rb +22 -0
- data/lib/brainstem/concerns/error_presentation.rb +58 -0
- data/lib/brainstem/concerns/inheritable_configuration.rb +29 -0
- data/lib/brainstem/concerns/lookup.rb +30 -0
- data/lib/brainstem/concerns/presenter_dsl.rb +111 -0
- data/lib/brainstem/controller_methods.rb +17 -8
- data/lib/brainstem/dsl/association.rb +55 -0
- data/lib/brainstem/dsl/associations_block.rb +12 -0
- data/lib/brainstem/dsl/base_block.rb +31 -0
- data/lib/brainstem/dsl/conditional.rb +25 -0
- data/lib/brainstem/dsl/conditionals_block.rb +15 -0
- data/lib/brainstem/dsl/configuration.rb +112 -0
- data/lib/brainstem/dsl/field.rb +68 -0
- data/lib/brainstem/dsl/fields_block.rb +25 -0
- data/lib/brainstem/preloader.rb +98 -0
- data/lib/brainstem/presenter.rb +325 -134
- data/lib/brainstem/presenter_collection.rb +82 -286
- data/lib/brainstem/presenter_validator.rb +96 -0
- data/lib/brainstem/query_strategies/README.md +107 -0
- data/lib/brainstem/query_strategies/base_strategy.rb +62 -0
- data/lib/brainstem/query_strategies/filter_and_search.rb +50 -0
- data/lib/brainstem/query_strategies/filter_or_search.rb +103 -0
- data/lib/brainstem/test_helpers.rb +5 -1
- data/lib/brainstem/version.rb +1 -1
- data/spec/brainstem/concerns/controller_param_management_spec.rb +42 -0
- data/spec/brainstem/concerns/error_presentation_spec.rb +113 -0
- data/spec/brainstem/concerns/inheritable_configuration_spec.rb +210 -0
- data/spec/brainstem/concerns/presenter_dsl_spec.rb +412 -0
- data/spec/brainstem/controller_methods_spec.rb +15 -27
- data/spec/brainstem/dsl/association_spec.rb +123 -0
- data/spec/brainstem/dsl/conditional_spec.rb +93 -0
- data/spec/brainstem/dsl/configuration_spec.rb +1 -0
- data/spec/brainstem/dsl/field_spec.rb +212 -0
- data/spec/brainstem/preloader_spec.rb +137 -0
- data/spec/brainstem/presenter_collection_spec.rb +565 -244
- data/spec/brainstem/presenter_spec.rb +726 -167
- data/spec/brainstem/presenter_validator_spec.rb +209 -0
- data/spec/brainstem/query_strategies/filter_and_search_spec.rb +46 -0
- data/spec/brainstem/query_strategies/filter_or_search_spec.rb +45 -0
- data/spec/spec_helper.rb +11 -3
- data/spec/spec_helpers/db.rb +32 -65
- data/spec/spec_helpers/presenters.rb +124 -29
- data/spec/spec_helpers/rr.rb +11 -0
- data/spec/spec_helpers/schema.rb +115 -0
- metadata +126 -30
- data/lib/brainstem/association_field.rb +0 -53
- data/lib/brainstem/engine.rb +0 -4
- data/pkg/brainstem-0.2.5.gem +0 -0
- data/pkg/brainstem-0.2.6.gem +0 -0
- data/spec/spec_helpers/cleanup.rb +0 -23
@@ -0,0 +1,96 @@
|
|
1
|
+
# Brainstem::PresenterValidator is a helper class that performs validity checks on a given presenter class.
|
2
|
+
|
3
|
+
module Brainstem
|
4
|
+
class PresenterValidator
|
5
|
+
include ActiveModel::Validations
|
6
|
+
include ActiveModel::Validations::Callbacks
|
7
|
+
|
8
|
+
attr_accessor :presenter_class
|
9
|
+
|
10
|
+
def initialize(presenter_class)
|
11
|
+
@presenter_class = presenter_class
|
12
|
+
end
|
13
|
+
|
14
|
+
validate :preloads_exist
|
15
|
+
validate :fields_exist
|
16
|
+
validate :associations_exist
|
17
|
+
validate :conditionals_exist
|
18
|
+
validate :default_sort_is_used
|
19
|
+
validate :default_sort_matches_sort_order
|
20
|
+
validate :brainstem_key_is_provided
|
21
|
+
|
22
|
+
def preloads_exist
|
23
|
+
presenter_class.configuration[:preloads].each do |preload|
|
24
|
+
Array(preload.is_a?(Hash) ? preload.keys : preload).each do |association_name|
|
25
|
+
if presenter_class.presents.any? { |klass| !klass.new.respond_to?(association_name) }
|
26
|
+
errors.add(:preload, "not all presented classes respond to '#{association_name}'")
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def fields_exist(fields = presenter_class.configuration[:fields])
|
33
|
+
fields.each do |name, field_or_fields|
|
34
|
+
case field_or_fields
|
35
|
+
when DSL::Field
|
36
|
+
method_name = field_or_fields.method_name
|
37
|
+
if method_name && presenter_class.presents.any? { |klass| !klass.new.respond_to?(method_name) }
|
38
|
+
errors.add(:fields, "'#{name}' is not valid because not all presented classes respond to '#{method_name}'")
|
39
|
+
end
|
40
|
+
when DSL::Configuration
|
41
|
+
fields_exist(field_or_fields)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def associations_exist(associations = presenter_class.configuration[:associations])
|
47
|
+
associations.each do |name, association|
|
48
|
+
method_name = association.method_name
|
49
|
+
|
50
|
+
if !association.polymorphic? && !Brainstem.presenter_collection.for(association.target_class)
|
51
|
+
errors.add(:associations, "'#{name}' is not valid because no presenter could be found for the #{association.target_class} class")
|
52
|
+
end
|
53
|
+
|
54
|
+
if method_name && presenter_class.presents.any? { |klass| !klass.new.respond_to?(method_name) }
|
55
|
+
errors.add(:associations, "'#{name}' is not valid because not all presented classes respond to '#{method_name}'")
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def conditionals_exist(fields = presenter_class.configuration[:fields])
|
61
|
+
fields.each do |name, field_or_fields|
|
62
|
+
case field_or_fields
|
63
|
+
when DSL::Field
|
64
|
+
if field_or_fields.options[:if].present?
|
65
|
+
if Array.wrap(field_or_fields.options[:if]).any? { |conditional| presenter_class.configuration[:conditionals][conditional].nil? }
|
66
|
+
errors.add(:fields, "'#{name}' is not valid because one or more of the specified conditionals does not exist")
|
67
|
+
end
|
68
|
+
end
|
69
|
+
when DSL::Configuration
|
70
|
+
conditionals_exist(field_or_fields)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def default_sort_is_used
|
76
|
+
if presenter_class.configuration[:sort_orders].length > 0 && presenter_class.configuration[:default_sort_order].blank?
|
77
|
+
errors.add(:default_sort_order, "A default_sort_order is highly recommended if any sort_orders are declared")
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def default_sort_matches_sort_order
|
82
|
+
if presenter_class.configuration[:default_sort_order].present?
|
83
|
+
default_sort_order = presenter_class.configuration[:default_sort_order].split(":").first.to_sym
|
84
|
+
if !presenter_class.configuration[:sort_orders][default_sort_order]
|
85
|
+
errors.add(:default_sort_order, "The declared default_sort_order ('#{default_sort_order}') does not match an existing sort_order")
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def brainstem_key_is_provided
|
91
|
+
if !presenter_class.configuration[:brainstem_key] && presenter_class.presents.length > 1
|
92
|
+
errors.add(:brainstem_key, "a brainstem_key must be provided when multiple classes are presented.")
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
# Filtering/Searching Query Strategies
|
2
|
+
|
3
|
+
Traditionally Brainstem has ignored all filters if you defined a `search` block in your presenter.
|
4
|
+
Brainstem relies on your search implementation to do any necessary filtering. A downside of this is that you may have to
|
5
|
+
implement your filters twice: once inside your presenters and once inside
|
6
|
+
your searching solution. This causes extra work, particularly for complex queries and associations that the Brainstem DSL
|
7
|
+
is well equipped to handle.
|
8
|
+
|
9
|
+
The current version of Brainstem offers a beta feature for allowing both searching and filtering to take place. To enable it,
|
10
|
+
add the following to your presenter:
|
11
|
+
|
12
|
+
```ruby
|
13
|
+
query_strategy :filter_and_search
|
14
|
+
```
|
15
|
+
|
16
|
+
The `query_strategy` DSL method can take a symbol or a lambda. If you pass it a lambda you can programmatically
|
17
|
+
determine what strategy to use. Example:
|
18
|
+
|
19
|
+
```ruby
|
20
|
+
query_strategy lambda {
|
21
|
+
if current_user.filter_and_search?
|
22
|
+
:filter_and_search
|
23
|
+
else
|
24
|
+
:legacy
|
25
|
+
end
|
26
|
+
}
|
27
|
+
```
|
28
|
+
|
29
|
+
Utilizing this strategy will enable Brainstem to take the intersection of your search results and your filter results,
|
30
|
+
effectively giving you the best of both worlds: fast, efficient searching using something like ElasticSearch and in depth
|
31
|
+
ActiveRecord filtering provided by Brainstem.
|
32
|
+
|
33
|
+
You must take the following important notes into account when using the `filter_and_search` query strategy:
|
34
|
+
|
35
|
+
- Your search block should behave the same as it always has: it should return an array where the first element is an array
|
36
|
+
of model ids and the second element is the total number of matched records.
|
37
|
+
- This works by retrieving all possible ids from your search block (up to 10,000) and then applying your filters
|
38
|
+
on those returned ids. This has some obvious potential performance considerations as you are potentially returning
|
39
|
+
and querying off of 10,000 results.
|
40
|
+
- If you have less than 10,000 possible results you shouldn't have to worry about ordering, because the order will
|
41
|
+
be applied in Brainstem on the intersection of filter and search results. However, if there is more than 10,000 your
|
42
|
+
searching implementation *must* perform the same ordering as your Brainstem filter. Otherwise the 10,000 results
|
43
|
+
from the search might not be the same 10,000 from the filter, and the intersection of the two would be incorrect.
|
44
|
+
- This will not work if you have more than 10,000 entities and need to be able to return all of them (this will only
|
45
|
+
give you the first 10,000).
|
46
|
+
|
47
|
+
This is not a perfect solution for all situations, which is why all presenters will default to the old behavior. You
|
48
|
+
should only use the `filter_and_search` strategy if you've determined that:
|
49
|
+
|
50
|
+
A.) Your API will still be fast enough when there are 10,000 possible results.
|
51
|
+
|
52
|
+
B.) It's not critical for the user to be able to retrieve ALL possible results when searching.
|
53
|
+
|
54
|
+
C.) It's actually important for your API that it support Brainstem filters and searching at the same time.
|
55
|
+
|
56
|
+
D.) You either have less than 10,000 entities to search and filter from or do not need to be be able to return more than
|
57
|
+
10,000.
|
58
|
+
|
59
|
+
# Other strategies
|
60
|
+
|
61
|
+
- The default strategy is `filter_or_search` and is the same behavior that Brainstem has historically employed.
|
62
|
+
|
63
|
+
# Implementing a strategy
|
64
|
+
|
65
|
+
If you have a different filtering or searching strategy you would like to employ, you can create a strategy class
|
66
|
+
in `lib/brainstem/query_strategies`. Your class should inherit from `BaseStrategy` and implement an `execute` method.
|
67
|
+
The `execute` method should accept a current scope and return an array of models and the count of all possible modes.
|
68
|
+
|
69
|
+
Example:
|
70
|
+
|
71
|
+
```ruby
|
72
|
+
module Brainstem
|
73
|
+
module QueryStrategies
|
74
|
+
class MyAwesomeFilterStrat < BaseStrategy
|
75
|
+
def execute(scope)
|
76
|
+
scope = do_something_awesome(scope)
|
77
|
+
count = scope.count
|
78
|
+
scope = paginate(scope)
|
79
|
+
[scope.to_a, count]
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
```
|
85
|
+
|
86
|
+
You should then add the logic for using that strategy in the `strategy` method of `PresenterCollection`.
|
87
|
+
|
88
|
+
Example:
|
89
|
+
|
90
|
+
```ruby
|
91
|
+
def strategy(options, scope)
|
92
|
+
strat = if options[:primary_presenter].configuration.has_key? :query_strategy
|
93
|
+
options[:primary_presenter].configuration[:query_strategy]
|
94
|
+
else
|
95
|
+
:legacy
|
96
|
+
end
|
97
|
+
|
98
|
+
return Brainstem::QueryStrategies::MyAwesomeFilterStrat.new(options) if strat == :my_awesome_filter_strat
|
99
|
+
return Brainstem::QueryStrategies::FilterOrSearch.new(options)
|
100
|
+
end
|
101
|
+
```
|
102
|
+
|
103
|
+
This can then be enabled in a presenter with:
|
104
|
+
|
105
|
+
```ruby
|
106
|
+
query_strategy :my_awesome_filter_strat`.
|
107
|
+
```
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module Brainstem
|
2
|
+
module QueryStrategies
|
3
|
+
class NotImplemented < StandardError
|
4
|
+
end
|
5
|
+
|
6
|
+
class BaseStrategy
|
7
|
+
def initialize(options)
|
8
|
+
@options = options
|
9
|
+
end
|
10
|
+
|
11
|
+
def execute(scope)
|
12
|
+
raise NotImplemented, 'Your strategy class must implement an `execute` method'
|
13
|
+
end
|
14
|
+
|
15
|
+
def evaluate_scope(scope)
|
16
|
+
# Load models!
|
17
|
+
# On complex queries, MySQL can sometimes handle 'SELECT id FROM ... ORDER BY ...' much faster than
|
18
|
+
# 'SELECT * FROM ...', so we pluck the ids, then find those specific ids in a separate query.
|
19
|
+
if(ActiveRecord::Base.connection.instance_values["config"][:adapter] =~ /mysql|sqlite/i)
|
20
|
+
ids = scope.pluck("#{scope.table_name}.id")
|
21
|
+
id_lookup = {}
|
22
|
+
ids.each.with_index { |id, index| id_lookup[id] = index }
|
23
|
+
primary_models = scope.klass.where(id: id_lookup.keys).sort_by { |model| id_lookup[model.id] }
|
24
|
+
else
|
25
|
+
primary_models = scope.to_a
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def calculate_limit
|
32
|
+
[[@options[:params][:limit].to_i, 1].max, (@options[:max_per_page] || @options[:default_max_per_page]).to_i].min
|
33
|
+
end
|
34
|
+
|
35
|
+
def calculate_offset
|
36
|
+
[@options[:params][:offset].to_i, 0].max
|
37
|
+
end
|
38
|
+
|
39
|
+
def calculate_per_page
|
40
|
+
per_page = [(@options[:params][:per_page] || @options[:per_page] || @options[:default_per_page]).to_i, (@options[:max_per_page] || @options[:default_max_per_page]).to_i].min
|
41
|
+
per_page = @options[:default_per_page] if per_page < 1
|
42
|
+
per_page
|
43
|
+
end
|
44
|
+
|
45
|
+
def calculate_page
|
46
|
+
[(@options[:params][:page] || 1).to_i, 1].max
|
47
|
+
end
|
48
|
+
|
49
|
+
def filter_includes
|
50
|
+
allowed_associations = @options[:primary_presenter].allowed_associations(@options[:params][:only].present?)
|
51
|
+
|
52
|
+
[].tap do |selected_associations|
|
53
|
+
(@options[:params][:include] || '').split(',').each do |k|
|
54
|
+
if association = allowed_associations[k]
|
55
|
+
selected_associations << association
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module Brainstem
|
2
|
+
module QueryStrategies
|
3
|
+
class FilterAndSearch < BaseStrategy
|
4
|
+
def execute(scope)
|
5
|
+
ordered_search_ids = run_search(scope, filter_includes.map(&:name))
|
6
|
+
scope = scope.where(id: ordered_search_ids)
|
7
|
+
scope = @options[:primary_presenter].apply_filters_to_scope(scope, @options[:params], @options)
|
8
|
+
scope = @options[:primary_presenter].apply_ordering_to_scope(scope, @options[:params])
|
9
|
+
count = scope.count
|
10
|
+
scope = paginate(scope)
|
11
|
+
primary_models = evaluate_scope(scope)
|
12
|
+
|
13
|
+
[primary_models, count]
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def run_search(scope, includes)
|
19
|
+
sort_name, direction = @options[:primary_presenter].calculate_sort_name_and_direction @options[:params]
|
20
|
+
search_options = HashWithIndifferentAccess.new(
|
21
|
+
include: includes,
|
22
|
+
order: { sort_order: sort_name, direction: direction },
|
23
|
+
limit: @options[:default_max_filter_and_search_page],
|
24
|
+
offset: 0
|
25
|
+
)
|
26
|
+
|
27
|
+
search_options.reverse_merge!(@options[:primary_presenter].extract_filters(@options[:params], @options))
|
28
|
+
|
29
|
+
result_ids, _ = @options[:primary_presenter].run_search(@options[:params][:search], search_options)
|
30
|
+
if result_ids
|
31
|
+
result_ids
|
32
|
+
else
|
33
|
+
raise(SearchUnavailableError, 'Search is currently unavailable')
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def paginate(scope)
|
38
|
+
if @options[:params][:limit].present? && @options[:params][:offset].present?
|
39
|
+
limit = calculate_limit
|
40
|
+
offset = calculate_offset
|
41
|
+
else
|
42
|
+
limit = calculate_per_page
|
43
|
+
offset = limit * (calculate_page - 1)
|
44
|
+
end
|
45
|
+
|
46
|
+
scope.limit(limit).offset(offset).distinct
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
module Brainstem
|
2
|
+
module QueryStrategies
|
3
|
+
class FilterOrSearch < BaseStrategy
|
4
|
+
def execute(scope)
|
5
|
+
if searching?
|
6
|
+
# Search
|
7
|
+
sort_name, direction = @options[:primary_presenter].calculate_sort_name_and_direction @options[:params]
|
8
|
+
scope, count, ordered_search_ids = run_search(scope, filter_includes.map(&:name), sort_name, direction)
|
9
|
+
|
10
|
+
# Load models!
|
11
|
+
primary_models = scope.to_a
|
12
|
+
|
13
|
+
primary_models = order_for_search(primary_models, ordered_search_ids)
|
14
|
+
else
|
15
|
+
# Filter
|
16
|
+
|
17
|
+
scope = @options[:primary_presenter].apply_filters_to_scope(scope, @options[:params], @options)
|
18
|
+
|
19
|
+
if @options[:params][:only].present?
|
20
|
+
# Handle Only
|
21
|
+
scope, count = handle_only(scope, @options[:params][:only])
|
22
|
+
else
|
23
|
+
# Paginate
|
24
|
+
scope, count = paginate scope
|
25
|
+
end
|
26
|
+
|
27
|
+
count = count.keys.length if count.is_a?(Hash)
|
28
|
+
|
29
|
+
# Ordering
|
30
|
+
scope = @options[:primary_presenter].apply_ordering_to_scope(scope, @options[:params])
|
31
|
+
|
32
|
+
primary_models = evaluate_scope(scope)
|
33
|
+
end
|
34
|
+
|
35
|
+
[primary_models, count]
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def searching?
|
41
|
+
@options[:params][:search] && @options[:primary_presenter].configuration[:search].present?
|
42
|
+
end
|
43
|
+
|
44
|
+
def run_search(scope, includes, sort_name, direction)
|
45
|
+
return scope unless searching?
|
46
|
+
|
47
|
+
search_options = HashWithIndifferentAccess.new(
|
48
|
+
include: includes,
|
49
|
+
order: { sort_order: sort_name, direction: direction },
|
50
|
+
)
|
51
|
+
|
52
|
+
if @options[:params][:limit].present? && @options[:params][:offset].present?
|
53
|
+
search_options[:limit] = calculate_limit
|
54
|
+
search_options[:offset] = calculate_offset
|
55
|
+
else
|
56
|
+
search_options[:per_page] = calculate_per_page
|
57
|
+
search_options[:page] = calculate_page
|
58
|
+
end
|
59
|
+
|
60
|
+
search_options.reverse_merge!(@options[:primary_presenter].extract_filters(@options[:params], @options))
|
61
|
+
|
62
|
+
result_ids, count = @options[:primary_presenter].run_search(@options[:params][:search], search_options)
|
63
|
+
if result_ids
|
64
|
+
[scope.where(id: result_ids), count, result_ids]
|
65
|
+
else
|
66
|
+
raise(SearchUnavailableError, 'Search is currently unavailable')
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def paginate(scope)
|
71
|
+
if @options[:params][:limit].present? && @options[:params][:offset].present?
|
72
|
+
limit = calculate_limit
|
73
|
+
offset = calculate_offset
|
74
|
+
else
|
75
|
+
limit = calculate_per_page
|
76
|
+
offset = limit * (calculate_page - 1)
|
77
|
+
end
|
78
|
+
|
79
|
+
[scope.limit(limit).offset(offset).distinct, scope.select("distinct #{scope.connection.quote_table_name @options[:table_name]}.id").count]
|
80
|
+
end
|
81
|
+
|
82
|
+
def handle_only(scope, only)
|
83
|
+
ids = (only || "").split(",").select {|id| id =~ /\A\d+\z/}.uniq
|
84
|
+
[scope.where(:id => ids), scope.where(:id => ids).count]
|
85
|
+
end
|
86
|
+
|
87
|
+
def order_for_search(records, ordered_search_ids)
|
88
|
+
ids_to_position = {}
|
89
|
+
ordered_records = []
|
90
|
+
|
91
|
+
ordered_search_ids.each_with_index do |id, index|
|
92
|
+
ids_to_position[id] = index
|
93
|
+
end
|
94
|
+
|
95
|
+
records.each do |record|
|
96
|
+
ordered_records[ids_to_position[record.id]] = record
|
97
|
+
end
|
98
|
+
|
99
|
+
ordered_records.compact
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -36,6 +36,10 @@ module Brainstem
|
|
36
36
|
@json = JSON.parse(response_body)
|
37
37
|
end
|
38
38
|
|
39
|
+
def results
|
40
|
+
BrainstemHelperCollection.new(@json['results'].map { |ref| @json[ref['key']][ref['id']] })
|
41
|
+
end
|
42
|
+
|
39
43
|
def method_missing(name)
|
40
44
|
data = @json[name.to_s].try(:values)
|
41
45
|
BrainstemHelperCollection.new(data) unless data.nil?
|
@@ -51,7 +55,7 @@ module Brainstem
|
|
51
55
|
end
|
52
56
|
|
53
57
|
def ids
|
54
|
-
map { |item| item.id
|
58
|
+
map { |item| item.id }
|
55
59
|
end
|
56
60
|
|
57
61
|
def by_id(id)
|
data/lib/brainstem/version.rb
CHANGED