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