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.
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
@@ -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.to_i }
58
+ map { |item| item.id }
55
59
  end
56
60
 
57
61
  def by_id(id)
@@ -1,3 +1,3 @@
1
1
  module Brainstem
2
- VERSION = "0.2.6.1"
2
+ VERSION = "1.0.0.pre.1"
3
3
  end