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,12 @@
1
+ module Brainstem
2
+ module Concerns
3
+ module PresenterDSL
4
+ class AssociationsBlock < BaseBlock
5
+ def association(name, target_class, *args)
6
+ description, options = parse_args(args)
7
+ configuration[:associations][name] = DSL::Association.new(name, target_class, description, block_options.merge(options))
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,31 @@
1
+ module Brainstem
2
+ module Concerns
3
+ module PresenterDSL
4
+ class BaseBlock
5
+ attr_accessor :configuration, :block_options
6
+
7
+ def initialize(configuration, block_options = {}, &block)
8
+ @configuration = configuration
9
+ @block_options = block_options
10
+ block.arity < 1 ? self.instance_eval(&block) : block.call(self) if block_given?
11
+ end
12
+
13
+ def with_options(new_options = {}, &block)
14
+ descend self.class, configuration, new_options, &block
15
+ end
16
+
17
+ protected
18
+
19
+ def descend(klass, new_config = configuration, new_options = {}, &block)
20
+ klass.new(new_config, block_options.merge(new_options), &block)
21
+ end
22
+
23
+ def parse_args(args)
24
+ options = args.last.is_a?(Hash) ? args.pop : {}
25
+ description = args.shift
26
+ [description, options]
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,25 @@
1
+ module Brainstem
2
+ module DSL
3
+ class Conditional
4
+ attr_reader :name, :type, :action, :description
5
+
6
+ def initialize(name, type, action, description)
7
+ @name = name
8
+ @type = type
9
+ @action = action
10
+ @description = description
11
+ end
12
+
13
+ def matches?(model, helper_instance = Object.new, conditional_cache = { model: {}, request: {} })
14
+ case type
15
+ when :model
16
+ conditional_cache[:model].fetch(name) { conditional_cache[:model][name] = helper_instance.instance_exec(model, &action) }
17
+ when :request
18
+ conditional_cache[:request].fetch(name) { conditional_cache[:request][name] = helper_instance.instance_exec(&action) }
19
+ else
20
+ raise "Unknown Brainstem Conditional type #{type}"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,15 @@
1
+ module Brainstem
2
+ module Concerns
3
+ module PresenterDSL
4
+ class ConditionalsBlock < BaseBlock
5
+ def request(name, action, description = nil)
6
+ configuration[:conditionals][name] = DSL::Conditional.new(name, :request, action, description)
7
+ end
8
+
9
+ def model(name, action, description = nil)
10
+ configuration[:conditionals][name] = DSL::Conditional.new(name, :model, action, description)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,112 @@
1
+ require 'active_support/hash_with_indifferent_access'
2
+
3
+ # A hash-like object that accepts a parent configuration object that defers to
4
+ # the parent in the absence of one of its own keys (thus simulating inheritance).
5
+ module Brainstem
6
+ module DSL
7
+ class Configuration
8
+
9
+ # Returns a new configuration object.
10
+ #
11
+ # @params [Object] parent_configuration The parent configuration object
12
+ # which the new configuration object should use as a base.
13
+ def initialize(parent_configuration = nil)
14
+ @parent_configuration = parent_configuration || ActiveSupport::HashWithIndifferentAccess.new
15
+ @storage = ActiveSupport::HashWithIndifferentAccess.new
16
+ end
17
+
18
+ def [](key)
19
+ get!(key)
20
+ end
21
+
22
+ def []=(key, value)
23
+ existing_value = get!(key)
24
+ if existing_value.is_a?(Configuration)
25
+ raise 'You cannot override a nested value'
26
+ elsif existing_value.is_a?(InheritableAppendSet)
27
+ raise 'You cannot override an inheritable array once set'
28
+ else
29
+ @storage[key] = value
30
+ end
31
+ end
32
+
33
+ def nest!(key)
34
+ get!(key)
35
+ @storage[key] ||= Configuration.new
36
+ end
37
+
38
+ def array!(key)
39
+ get!(key)
40
+ @storage[key] ||= InheritableAppendSet.new
41
+ end
42
+
43
+ def keys
44
+ @parent_configuration.keys | @storage.keys
45
+ end
46
+
47
+ def has_key?(key)
48
+ @storage.has_key?(key) || @parent_configuration.has_key?(key)
49
+ end
50
+
51
+ def length
52
+ keys.length
53
+ end
54
+
55
+ def each
56
+ keys.each do |key|
57
+ yield key, get!(key)
58
+ end
59
+ end
60
+
61
+ delegate :empty?, to: :keys
62
+
63
+ private
64
+
65
+ # @api private
66
+ #
67
+ # Retrieves the value stored at key.
68
+ #
69
+ # - If +key+ is already defined, it returns that;
70
+ # - If +key+ in the parent is a +Configuration+, returns a new
71
+ # +Configuration+ with the parent set;
72
+ # - If +key+ in the parent is an +InheritableAppendSet+, returns a new
73
+ # +InheritableAppendSet+ with the parent set;
74
+ # - Elsewise returns the parent configuration's value for the key.
75
+ def get!(key)
76
+ @storage[key] || begin
77
+ if @parent_configuration[key].is_a?(Configuration)
78
+ @storage[key] = Configuration.new(@parent_configuration[key])
79
+ elsif @parent_configuration[key].is_a?(InheritableAppendSet)
80
+ @storage[key] = InheritableAppendSet.new(@parent_configuration[key])
81
+ else
82
+ @parent_configuration[key]
83
+ end
84
+ end
85
+ end
86
+
87
+ # An Array-like object that provides `push`, `concat`, `each`, `empty?`, and `to_a` methods that act the combination
88
+ # of its own entries and those of a parent InheritableAppendSet, if present.
89
+ class InheritableAppendSet
90
+ def initialize(parent_array = nil)
91
+ @parent_array = parent_array || []
92
+ @storage = []
93
+ end
94
+
95
+ def push(item)
96
+ @storage.push item
97
+ end
98
+ alias_method :<<, :push
99
+
100
+ def concat(items)
101
+ @storage.concat items
102
+ end
103
+
104
+ def to_a
105
+ @parent_array.to_a + @storage
106
+ end
107
+
108
+ delegate :each, :empty?, to: :to_a
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,68 @@
1
+ require 'brainstem/concerns/lookup'
2
+
3
+ module Brainstem
4
+ module DSL
5
+ class Field
6
+ include Brainstem::Concerns::Lookup
7
+
8
+ attr_reader :name, :type, :description, :conditionals, :options
9
+
10
+ def initialize(name, type, description, options)
11
+ @name = name.to_s
12
+ @type = type
13
+ @description = description
14
+ @conditionals = [options[:if]].flatten.compact
15
+ @options = options
16
+ end
17
+
18
+ def conditional?
19
+ conditionals.length > 0
20
+ end
21
+
22
+ def method_name
23
+ if options[:dynamic] || options[:lookup]
24
+ nil
25
+ else
26
+ (options[:via].presence || name).to_s
27
+ end
28
+ end
29
+
30
+ def optioned?(requested_optional_fields)
31
+ !optional? || requested_optional_fields.include?(@name)
32
+ end
33
+
34
+ def optional?
35
+ options[:optional]
36
+ end
37
+
38
+ def run_on(model, context, helper_instance = Object.new)
39
+ if options[:lookup]
40
+ run_on_with_lookup(model, context, helper_instance)
41
+ elsif options[:dynamic]
42
+ proc = options[:dynamic]
43
+ if proc.arity == 1
44
+ helper_instance.instance_exec(model, &proc)
45
+ else
46
+ helper_instance.instance_exec(&proc)
47
+ end
48
+ else
49
+ model.send(method_name)
50
+ end
51
+ end
52
+
53
+ def conditionals_match?(model, presenter_conditionals, helper_instance = Object.new, conditional_cache = { model: {}, request: {} })
54
+ return true unless conditional?
55
+
56
+ conditionals.all? { |conditional|
57
+ presenter_conditionals[conditional].matches?(model, helper_instance, conditional_cache)
58
+ }
59
+ end
60
+
61
+ private
62
+
63
+ def key_for_lookup
64
+ :fields
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,25 @@
1
+ module Brainstem
2
+ module Concerns
3
+ module PresenterDSL
4
+ class FieldsBlock < BaseBlock
5
+ def field(name, type, *args)
6
+ description, options = parse_args(args)
7
+ configuration[name] = DSL::Field.new(name, type, description, smart_merge(block_options, options))
8
+ end
9
+
10
+ def fields(name, &block)
11
+ descend FieldsBlock, configuration.nest!(name), &block
12
+ end
13
+
14
+ private
15
+
16
+ def smart_merge(block_options, options)
17
+ if_clause = ([block_options[:if]] + [options[:if]]).flatten(2).compact.uniq
18
+ block_options.merge(options).tap do |opts|
19
+ opts.merge!(if: if_clause) if if_clause.present?
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,98 @@
1
+ module Brainstem
2
+ # Takes a list of arbitrarily nested objects, compacts them, de-duplicates
3
+ # them, and passes them to ActiveRecord to preload.
4
+ #
5
+ # The following is considered a valid data structure:
6
+ #
7
+ # [:workspaces, {"workspaces" => [:projects], "users" }
8
+ #
9
+ # Which will produce the following for ActiveRecord:
10
+ #
11
+ # {"workspaces" => [:projects], "users" => []}
12
+ #
13
+ class Preloader
14
+
15
+ ################################################################################
16
+ # Class API
17
+ ################################################################################
18
+ class << self
19
+ def preload(*args)
20
+ new(*args).call
21
+ end
22
+ end
23
+
24
+ ################################################################################
25
+ # Instance API
26
+ ################################################################################
27
+ attr_accessor :models,
28
+ :preloads,
29
+ :reflections,
30
+ :valid_preloads
31
+
32
+ private :valid_preloads=
33
+
34
+ attr_writer :preload_method
35
+
36
+ def initialize(models, preloads, reflections, preload_method = nil)
37
+ self.models = models
38
+ self.preloads = preloads.compact
39
+ self.reflections = reflections
40
+ self.preload_method = preload_method
41
+ self.valid_preloads = {}
42
+ end
43
+
44
+ def call
45
+ clean!
46
+ preload!
47
+ end
48
+
49
+ ################################################################################
50
+ private
51
+ ################################################################################
52
+
53
+ # De-duplicates, reformats, and prunes requested preloads into an acceptable
54
+ # format for the preloader
55
+ def clean!
56
+ dedupe!
57
+ remove_unreflected_preloads!
58
+ end
59
+
60
+ def preload!
61
+ preload_method.call(models, valid_preloads) if valid_preloads.keys.any?
62
+ end
63
+
64
+ # Returns a proc that takes two arguments, +models+ and +association_names+,
65
+ # which, when called, preloads those.
66
+ #
67
+ # @return [Proc] A callable proc
68
+ def preload_method
69
+ @preload_method ||= begin
70
+ if Gem.loaded_specs['activerecord'].version >= Gem::Version.create('4.1')
71
+ ActiveRecord::Associations::Preloader.new.method(:preload)
72
+ else
73
+ Proc.new do |models, association_names|
74
+ ActiveRecord::Associations::Preloader.new(models, association_names).run
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ def dedupe!
81
+ preloads.each do |preload_name|
82
+ case preload_name
83
+ when Hash
84
+ preload_name.each do |key, value|
85
+ (valid_preloads[key.to_s] ||= Array.new) << value
86
+ end
87
+ when NilClass
88
+ else
89
+ valid_preloads[preload_name.to_s] ||= []
90
+ end
91
+ end
92
+ end
93
+
94
+ def remove_unreflected_preloads!
95
+ valid_preloads.select! { |preload_name, _| reflections.has_key?(preload_name.to_s) }
96
+ end
97
+ end
98
+ end
@@ -1,133 +1,274 @@
1
1
  require 'date'
2
- require 'brainstem/association_field'
3
2
  require 'brainstem/time_classes'
3
+ require 'brainstem/preloader'
4
+ require 'brainstem/concerns/presenter_dsl'
4
5
 
5
6
  module Brainstem
6
7
  # @abstract Subclass and override {#present} to implement a presenter.
7
8
  class Presenter
9
+ include Concerns::PresenterDSL
8
10
 
9
11
  # Class methods
10
12
 
11
- # Accepts a list of classes this presenter knows how to present.
13
+ # Accepts a list of classes that this specific presenter knows how to present. These are not inherited.
12
14
  # @param [String, [String]] klasses Any number of names of classes this presenter presents.
13
15
  def self.presents(*klasses)
14
- Brainstem.add_presenter_class(self, *klasses)
15
- end
16
-
17
- # @overload default_sort_order(sort_string)
18
- # Sets a default sort order.
19
- # @param [String] sort_string The sort order to apply by default while presenting. The string must contain the name of a sort order that has explicitly been declared using {sort_order}. The string may end in +:asc+ or +:desc+ to indicate the default order's direction.
20
- # @return [String] The new default sort order.
21
- # @overload default_sort_order
22
- # @return [String] The default sort order, or nil if one is not set.
23
- def self.default_sort_order(sort_string = nil)
24
- if sort_string
25
- @default_sort_order = sort_string
26
- else
27
- @default_sort_order
16
+ @presents ||= []
17
+ if klasses.length > 0
18
+ if klasses.any? { |klass| klass.is_a?(String) || klass.is_a?(Symbol) }
19
+ raise "Brainstem Presenter#presents now expects a Class instead of a class name"
20
+ end
21
+ @presents.concat(klasses).uniq!
22
+ Brainstem.add_presenter_class(self, namespace, *klasses)
28
23
  end
24
+ @presents
29
25
  end
30
26
 
31
- # @overload sort_order(name, order)
32
- # @param [Symbol] name The name of the sort order.
33
- # @param [String] order The SQL string to use to sort the presented data.
34
- # @overload sort_order(name, &block)
35
- # @yieldparam scope [ActiveRecord::Relation] The scope representing the data being presented.
36
- # @yieldreturn [ActiveRecord::Relation] A new scope that adds ordering requirements to the scope that was yielded.
37
- # Create a named sort order, either containing a string to use as ORDER in a query, or with a block that adds an order Arel predicate to a scope.
38
- # @raise [ArgumentError] if neither an order string or block is given.
39
- def self.sort_order(name, order = nil, &block)
40
- raise ArgumentError, "A sort order must be given" unless block_given? || order
41
- @sort_orders ||= HashWithIndifferentAccess.new
42
- @sort_orders[name] = (block_given? ? block : order)
27
+ # Return the second-to-last module in the name of this presenter, which Brainstem considers to be the 'namespace'.
28
+ # E.g., Api::V1::FooPresenter has a namespace of "V1".
29
+ # @return [String] The name of the second-to-last module containing this presenter.
30
+ def self.namespace
31
+ self.to_s.split("::")[-2].try(:downcase)
43
32
  end
44
33
 
45
- # @return [Hash] All defined sort orders, keyed by their name.
46
- def self.sort_orders
47
- @sort_orders
48
- end
34
+ def self.merged_helper_class
35
+ @helper_classes ||= {}
49
36
 
50
- # @overload filter(name, options = {})
51
- # @param [Symbol] name The name of the scope that may be applied as a filter.
52
- # @option options [Object] :default If set, causes this filter to be applied to every request. If the filter accepts parameters, the value given here will be passed to the filter when it is applied.
53
- # @overload filter(name, options = {}, &block)
54
- # @param [Symbol] name The filter can be requested using this name.
55
- # @yieldparam scope [ActiveRecord::Relation] The scope that the filter should use as a base.
56
- # @yieldparam arg [Object] The argument passed when the filter was requested.
57
- # @yieldreturn [ActiveRecord::Relation] A new scope that filters the scope that was yielded.
58
- def self.filter(name, options = {}, &block)
59
- @filters ||= HashWithIndifferentAccess.new
60
- @filters[name] = [options, (block_given? ? block : nil)]
37
+ @helper_classes[configuration[:helpers].to_a.map(&:object_id)] ||= begin
38
+ Class.new.tap do |klass|
39
+ (configuration[:helpers] || []).each do |helper|
40
+ klass.send :include, helper
41
+ end
42
+ end
43
+ end
61
44
  end
62
45
 
63
- # @return [Hash] All defined filters, keyed by their name.
64
- def self.filters
65
- @filters
46
+ def self.reset!
47
+ clear_configuration!
48
+ @helper_classes = @presents = nil
66
49
  end
67
50
 
68
- def self.search(&block)
69
- @search_block = block
51
+ # In Rails 4.2, ActiveRecord::Base#reflections started being keyed by strings instead of symbols.
52
+ def self.reflections(klass)
53
+ klass.reflections.each_with_object({}) { |(key, value), memo| memo[key.to_s] = value }
70
54
  end
71
55
 
72
- def self.search_block
73
- @search_block
56
+ # Instance methods
57
+
58
+ # @deprecated
59
+ def present(model)
60
+ raise "#present is now deprecated"
74
61
  end
75
62
 
76
- # Declares a helper module whose methods will be available in instances of the presenter class and available inside sort and filter blocks.
77
- # @param [Module] mod A module whose methods will be made available to filter and sort blocks, as well as inside the {#present} method.
78
- # @return [self]
79
- def self.helper(mod)
80
- include mod
81
- extend mod
63
+ def get_query_strategy
64
+ if configuration.has_key? :query_strategy
65
+ strat = configuration[:query_strategy]
66
+ strat.respond_to?(:call) ? fresh_helper_instance.instance_exec(&strat) : strat
67
+ end
82
68
  end
83
69
 
70
+ # Calls {#custom_preload} and then presents all models.
71
+ # @params [ActiveRecord::Relation, Array] models
72
+ # @params [Array] requested_associations An array of permitted lower-case string association names, e.g. 'post'
73
+ # @params [Hash] options The options passed to `load_associations!`
74
+ def group_present(models, requested_associations = [], options = {})
75
+ association_objects_by_name = requested_associations.each_with_object({}) do |assoc_name, memo|
76
+ memo[assoc_name.to_s] = configuration[:associations][assoc_name] if configuration[:associations][assoc_name]
77
+ end
84
78
 
85
- # Instance methods
79
+ # It's slightly ugly, but more efficient if we pre-load everything we
80
+ # need and pass it through.
81
+ context = {
82
+ conditional_cache: { request: {} },
83
+ fields: configuration[:fields],
84
+ conditionals: configuration[:conditionals],
85
+ associations: configuration[:associations],
86
+ reflections: reflections_for_model(models.first),
87
+ association_objects_by_name: association_objects_by_name,
88
+ optional_fields: options[:optional_fields] || [],
89
+ models: models,
90
+ lookup: empty_lookup_cache(configuration[:fields].keys, association_objects_by_name.keys)
91
+ }
86
92
 
87
- # @raise [RuntimeError] if this method has not been overridden in the presenter subclass.
88
- def present(model)
89
- raise "Please override #present(model) in your subclass of Brainstem::Presenter"
93
+ sanitized_association_names = association_objects_by_name.values.map(&:method_name)
94
+ preload_associations! models, sanitized_association_names, context[:reflections]
95
+
96
+ # Legacy: Overridable for custom preload behavior.
97
+ custom_preload(models, association_objects_by_name.keys)
98
+
99
+ models.map do |model|
100
+ context[:conditional_cache][:model] = {}
101
+ context[:helper_instance] = fresh_helper_instance
102
+ result = present_fields(model, context, context[:fields])
103
+ load_associations!(model, result, context, options)
104
+ add_id!(model, result)
105
+ datetimes_to_json(result)
106
+ end
90
107
  end
91
108
 
92
- # @api private
93
- # Calls {#post_process} on the output from {#present}.
94
- # @return (see #post_process)
95
- def present_and_post_process(model, associations = [])
96
- post_process(present(model), model, associations)
109
+ def present_model(model, requested_associations = [], options = {})
110
+ group_present([model], requested_associations, options).first
97
111
  end
98
112
 
99
113
  # @api private
100
- # Loads associations and converts dates to epoch strings.
101
- # @return [Hash] The hash representing the models and associations, ready to be converted to JSON.
102
- def post_process(struct, model, associations = [])
103
- add_id(model, struct)
104
- load_associations!(model, struct, associations)
105
- datetimes_to_json(struct)
114
+ #
115
+ # Returns the reflections for a model's class if the model is not nil.
116
+ def reflections_for_model(model)
117
+ model && Brainstem::Presenter.reflections(model.class)
106
118
  end
119
+ private :reflections_for_model
107
120
 
108
121
  # @api private
109
- # Adds :id as a string from the given model.
110
- def add_id(model, struct)
111
- if model.class.respond_to?(:primary_key)
112
- struct[:id] = model[model.class.primary_key].to_s
122
+ # Determines which associations are valid for inclusion in the current context.
123
+ # Mostly just removes only-restricted associations when needed.
124
+ # @return [Hash] The associations that can be included.
125
+ def allowed_associations(is_only_query)
126
+ ActiveSupport::HashWithIndifferentAccess.new.tap do |associations|
127
+ configuration[:associations].each do |name, association|
128
+ associations[name] = association unless association.options[:restrict_to_only] && !is_only_query
129
+ end
113
130
  end
114
131
  end
115
132
 
133
+ # Subclasses can define this if they wish. This method will be called by {#group_present}.
134
+ def custom_preload(models, requested_associations = [])
135
+ end
136
+
137
+ # Given user params, build a hash of validated filter names to their unsanitized arguments.
138
+ def extract_filters(user_params, options = {})
139
+ filters_hash = {}
140
+
141
+ apply_default_filters = options.fetch(:apply_default_filters) { true }
142
+
143
+ configuration[:filters].each do |filter_name, filter|
144
+ user_value = format_filter_value(user_params[filter_name])
145
+
146
+ filter_options = filter[0]
147
+ filter_arg = apply_default_filters && user_value.nil? ? filter_options[:default] : user_value
148
+ filters_hash[filter_name] = filter_arg unless filter_arg.nil?
149
+ end
150
+
151
+ filters_hash
152
+ end
153
+
116
154
  # @api private
117
- # Calls {#custom_preload}, and then {#present} and {#post_process}, for each model.
118
- def group_present(models, associations = [])
119
- custom_preload models, associations
155
+ # @param [Array, Hash, String, Boolean, nil] value
156
+ #
157
+ # @return [Array, Hash, String, Boolean, nil]
158
+ def format_filter_value(value)
159
+ return value if value.is_a?(Array) || value.is_a?(Hash)
160
+ return nil if value.blank?
120
161
 
121
- models.map do |model|
122
- present_and_post_process model, associations
162
+ value = value.to_s
163
+ case value
164
+ when 'true', 'TRUE' then true
165
+ when 'false', 'FALSE' then false
166
+ else
167
+ value
123
168
  end
124
169
  end
170
+ private :format_filter_value
171
+
172
+ # Given user params, build a hash of validated filter names to their unsanitized arguments.
173
+ def apply_filters_to_scope(scope, user_params, options)
174
+ helper_instance = fresh_helper_instance
175
+
176
+ requested_filters = extract_filters(user_params, options)
177
+ requested_filters.each do |filter_name, filter_arg|
178
+ filter_lambda = configuration[:filters][filter_name][1]
179
+
180
+ args_for_filter_lambda = [filter_arg]
181
+ args_for_filter_lambda << requested_filters if configuration[:filters][filter_name][0][:include_params]
125
182
 
126
- # Subclasses can define this if they wish. This method will be called before {#present}.
127
- def custom_preload(models, associations = [])
183
+ if filter_lambda
184
+ scope = helper_instance.instance_exec(scope, *args_for_filter_lambda, &filter_lambda)
185
+ else
186
+ scope = scope.send(filter_name, *args_for_filter_lambda)
187
+ end
188
+ end
189
+
190
+ scope
128
191
  end
129
192
 
130
- # @api private
193
+ # Given user params, apply a validated sort order to the given scope.
194
+ def apply_ordering_to_scope(scope, user_params)
195
+ sort_name, direction = calculate_sort_name_and_direction(user_params)
196
+ order = configuration[:sort_orders][sort_name]
197
+
198
+ ordered_scope = case order
199
+ when Proc
200
+ fresh_helper_instance.instance_exec(scope, direction, &order)
201
+ when nil
202
+ scope
203
+ else
204
+ scope.reorder(order.to_s + " " + direction)
205
+ end
206
+
207
+ fallback_deterministic_sort = assemble_primary_key_sort(scope)
208
+ # Chain on a tiebreaker sort to ensure deterministic ordering of multiple pages of data
209
+
210
+ if fallback_deterministic_sort
211
+ ordered_scope.order(fallback_deterministic_sort)
212
+ else
213
+ ordered_scope
214
+ end
215
+ end
216
+
217
+ def assemble_primary_key_sort(scope)
218
+ table_name = scope.table.name
219
+ primary_key = scope.model.primary_key
220
+
221
+ if table_name && primary_key
222
+ "#{scope.connection.quote_table_name(table_name)}.#{scope.connection.quote_column_name(primary_key)} ASC"
223
+ else
224
+ nil
225
+ end
226
+ end
227
+ private :assemble_primary_key_sort
228
+
229
+ # Execute the stored search block
230
+ def run_search(query, search_options)
231
+ fresh_helper_instance.instance_exec(query, search_options, &configuration[:search])
232
+ end
233
+
234
+ # Clean and validate a sort order and direction from user params.
235
+ def calculate_sort_name_and_direction(user_params = {})
236
+ default_column, default_direction = (configuration[:default_sort_order] || "updated_at:desc").split(":")
237
+ sort_name, direction = user_params['order'].to_s.split(":")
238
+ unless sort_name.present? && configuration[:sort_orders][sort_name]
239
+ sort_name = default_column
240
+ direction = default_direction
241
+ end
242
+
243
+ [sort_name, direction == 'desc' ? 'desc' : 'asc']
244
+ end
245
+
246
+ protected
247
+
248
+ # @api protected
249
+ # Run preloading on the given models, asking Rails to include both any named associations and any preloads declared in the Brainstem DSL..
250
+ def preload_associations!(models, sanitized_association_names, memoized_reflections)
251
+ return unless models.any?
252
+
253
+ preloads = sanitized_association_names + configuration[:preloads].to_a
254
+ Brainstem::Preloader.preload(models, preloads, memoized_reflections)
255
+ end
256
+
257
+ # @api protected
258
+ # Instantiate and return a new instance of the merged helper class for this presenter.
259
+ def fresh_helper_instance
260
+ self.class.merged_helper_class.new
261
+ end
262
+
263
+ # @api protected
264
+ # Adds :id as a string from the given model.
265
+ def add_id!(model, struct)
266
+ if model.class.respond_to?(:primary_key)
267
+ struct['id'] = model[model.class.primary_key].to_s
268
+ end
269
+ end
270
+
271
+ # @api protected
131
272
  # Recurses through any nested Hash/Array data structure, converting dates and times to JSON standard values.
132
273
  def datetimes_to_json(struct)
133
274
  case struct
@@ -146,74 +287,124 @@ module Brainstem
146
287
  end
147
288
  end
148
289
 
149
- # @api private
150
- # Makes sure that associations are loaded and converted into ids.
151
- def load_associations!(model, struct, associations)
152
- reflections = Brainstem::PresenterCollection.reflections(model.class)
153
- struct.to_a.each do |key, value|
154
- if value.is_a?(AssociationField)
155
- struct.delete key
156
- id_attr = value.method_name ? "#{value.method_name}_id" : nil
157
-
158
- if id_attr && model.class.columns_hash.has_key?(id_attr)
159
- reflection = value.method_name && reflections[value.method_name.to_s]
160
- if reflection && reflection.options[:polymorphic] && !value.ignore_type
161
- struct["#{key.to_s.singularize}_ref".to_sym] = begin
162
- if (id_attr = model.send(id_attr)).present?
163
- {
164
- :id => to_s_except_nil(id_attr),
165
- :key => model.send("#{value.method_name}_type").try(:constantize).try(:table_name)
166
- }
167
- end
168
- end
169
- else
170
- struct["#{key}_id".to_sym] = to_s_except_nil(model.send(id_attr))
290
+ # @api protected
291
+ # Uses the fields DSL to output a presented model.
292
+ # @return [Hash] A hash representation of the model.
293
+ def present_fields(model, context, fields, result = {})
294
+ fields.each do |name, field|
295
+ case field
296
+ when DSL::Field
297
+ if field.conditionals_match?(model, context[:conditionals], context[:helper_instance], context[:conditional_cache]) && field.optioned?(context[:optional_fields])
298
+ result[name] = field.run_on(model, context, context[:helper_instance])
171
299
  end
172
- elsif associations.include?(key.to_s)
173
- result = value.call(model)
174
- if result.is_a?(Array) || result.is_a?(ActiveRecord::Relation)
175
- struct["#{key.to_s.singularize}_ids".to_sym] = result.map {|a| to_s_except_nil(a.is_a?(ActiveRecord::Base) ? a.id : a) }
176
- else
177
- if result.is_a?(ActiveRecord::Base)
178
- struct["#{key.to_s.singularize}_id".to_sym] = to_s_except_nil(result.id)
179
- else
180
- struct["#{key.to_s.singularize}_id".to_sym] = to_s_except_nil(result)
181
- end
300
+ when DSL::Configuration
301
+ result[name] ||= {}
302
+ present_fields(model, context, field, result[name])
303
+ else
304
+ raise "Unknown Brianstem Field type encountered: #{field}"
305
+ end
306
+ end
307
+ result
308
+ end
309
+
310
+ # @api protected
311
+ # Makes sure that associations are loaded and converted into ids.
312
+ def load_associations!(model, struct, context, options)
313
+ context[:associations].each do |external_name, association|
314
+ method_name = association.method_name && association.method_name.to_s
315
+ id_attr = method_name && "#{method_name}_id"
316
+
317
+ # If this association has been explictly requested, execute the association here. Additionally, store
318
+ # the loaded models in the :load_associations_into hash for later use.
319
+ if context[:association_objects_by_name][external_name]
320
+ associated_model_or_models = association.run_on(model, context, context[:helper_instance])
321
+
322
+ if options[:load_associations_into]
323
+ Array(associated_model_or_models).flatten.each do |associated_model|
324
+ key = presenter_collection.brainstem_key_for!(associated_model.class)
325
+ options[:load_associations_into][key] ||= {}
326
+ options[:load_associations_into][key][associated_model.id.to_s] = associated_model
182
327
  end
183
328
  end
184
329
  end
330
+
331
+ if id_attr && model.class.columns_hash.has_key?(id_attr) && !association.polymorphic?
332
+ # We return *_id keys when they exist in the database, because it's free to do so.
333
+ struct["#{external_name}_id"] = to_s_except_nil(model.send(id_attr))
334
+ elsif association.always_return_ref_with_sti_base?
335
+ # Deprecated support for legacy always-return-ref mode without loading the association.
336
+ struct["#{external_name}_ref"] = legacy_polymorphic_base_ref(model, id_attr, method_name)
337
+ elsif context[:association_objects_by_name][external_name]
338
+ # This association has been explicitly requested. Add the *_id, *_ids, *_ref, or *_refs keys to the presented data.
339
+ add_ids_or_refs_to_struct!(struct, association, external_name, associated_model_or_models)
340
+ end
185
341
  end
186
342
  end
187
343
 
188
- # @!attribute [r] default_sort_order
189
- # The default sort order set on this presenter's class.
190
- def default_sort_order
191
- self.class.default_sort_order
344
+ # @api protected
345
+ # Inject 'foo_ids' keys into the presented data if the foos association has been requested.
346
+ def add_ids_or_refs_to_struct!(struct, association, external_name, associated_model_or_models)
347
+ singular_external_name = external_name.to_s.singularize
348
+ if association.polymorphic?
349
+ if associated_model_or_models.is_a?(Array) || associated_model_or_models.is_a?(ActiveRecord::Relation)
350
+ struct["#{singular_external_name}_refs"] = associated_model_or_models.map { |associated_model| make_model_ref(associated_model) }
351
+ else
352
+ struct["#{singular_external_name}_ref"] = make_model_ref(associated_model_or_models)
353
+ end
354
+ else
355
+ if associated_model_or_models.is_a?(Array) || associated_model_or_models.is_a?(ActiveRecord::Relation)
356
+ struct["#{singular_external_name}_ids"] = associated_model_or_models.map { |associated_model| to_s_except_nil(associated_model.try(:id)) }
357
+ else
358
+ struct["#{singular_external_name}_id"] = to_s_except_nil(associated_model_or_models.try(:id))
359
+ end
360
+ end
192
361
  end
193
362
 
194
- # @!attribute [r] sort_orders
195
- # The sort orders that were declared in the definition of this presenter.
196
- def sort_orders
197
- self.class.sort_orders
363
+ # @api protected
364
+ # Deprecated support for legacy always-return-ref mode without loading the association.
365
+ # This 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)
366
+ def legacy_polymorphic_base_ref(model, id_attr, method_name)
367
+ if (id = model.send(id_attr)).present?
368
+ {
369
+ 'id' => to_s_except_nil(id),
370
+ 'key' => presenter_collection.brainstem_key_for!(model.send("#{method_name}_type").try(:constantize))
371
+ }
372
+ end
198
373
  end
199
374
 
200
- # @!attribute [r] filters
201
- # The filters that were declared in the definition of this presenter.
202
- def filters
203
- self.class.filters
375
+ # @api protected
376
+ # Call to_s on the input unless the input is nil.
377
+ def to_s_except_nil(thing)
378
+ thing.nil? ? nil : thing.to_s
204
379
  end
205
380
 
206
- def search_block
207
- self.class.search_block
381
+ # @api protected
382
+ # Return a polymorphic id/key object for a model, or nil if no model was given.
383
+ def make_model_ref(model)
384
+ if model
385
+ {
386
+ 'id' => to_s_except_nil(model.id),
387
+ 'key' => presenter_collection.brainstem_key_for!(model.class)
388
+ }
389
+ else
390
+ nil
391
+ end
208
392
  end
209
393
 
210
- # An association on the object being presented that should be included in the presented data.
211
- def association(*args, &block)
212
- AssociationField.new *args, &block
394
+ # @api protected
395
+ # Find the global presenter collection for our namespace.
396
+ def presenter_collection
397
+ Brainstem.presenter_collection(self.class.namespace)
213
398
  end
214
399
 
215
- def to_s_except_nil(thing)
216
- thing.nil? ? nil : thing.to_s
400
+ # @api protected
401
+ # Create an empty lookup cache with the fields and associations as keys and nil for the values
402
+ # @return [Hash]
403
+ def empty_lookup_cache(field_keys, association_keys)
404
+ {
405
+ fields: Hash[field_keys.map { |key| [key, nil] }],
406
+ associations: Hash[association_keys.map { |key| [key, nil] }]
407
+ }
217
408
  end
218
409
  end
219
410
  end