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