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
data/brainstem.gemspec CHANGED
@@ -17,12 +17,16 @@ Gem::Specification.new do |gem|
17
17
  gem.require_paths = ["lib"]
18
18
  gem.version = Brainstem::VERSION
19
19
 
20
- gem.add_dependency "activerecord", ">= 3.2"
20
+ gem.add_dependency "activerecord", ">= 4.1"
21
+ gem.add_dependency "activesupport", ">= 4.1"
21
22
 
22
23
  gem.add_development_dependency "rake"
23
24
  gem.add_development_dependency "redcarpet" # for markdown in yard
24
25
  gem.add_development_dependency "rr"
25
- gem.add_development_dependency "rspec"
26
+ gem.add_development_dependency "rspec", "~> 3.5"
26
27
  gem.add_development_dependency "sqlite3"
28
+ gem.add_development_dependency "database_cleaner"
27
29
  gem.add_development_dependency "yard"
30
+ gem.add_development_dependency "pry"
31
+ gem.add_development_dependency "pry-nav"
28
32
  end
data/lib/brainstem.rb CHANGED
@@ -2,6 +2,9 @@ require "brainstem/version"
2
2
  require "brainstem/presenter"
3
3
  require "brainstem/presenter_collection"
4
4
  require "brainstem/controller_methods"
5
+ require "brainstem/query_strategies/base_strategy"
6
+ require "brainstem/query_strategies/filter_and_search"
7
+ require "brainstem/query_strategies/filter_or_search"
5
8
 
6
9
  # The Brainstem module itself contains a +default_namespace+ class attribute and a few helpers that make managing +PresenterCollections+ and their corresponding namespaces easier.
7
10
  module Brainstem
@@ -26,18 +29,13 @@ module Brainstem
26
29
  @presenter_collection[namespace.to_s.downcase] ||= PresenterCollection.new
27
30
  end
28
31
 
32
+ # TODO: pull these into the presenter
33
+
29
34
  # Helper method to quickly add presenter classes that are in a namespace. For example, +add_presenter_class(Api::V1::UserPresenter, "User")+ would add +UserPresenter+ to the PresenterCollection for the +:v1+ namespace as the presenter for the +User+ class.
30
35
  # @param [Brainstem::Presenter] presenter_class The presenter class that is being registered.
31
36
  # @param [Array<String, Class>] klasses Classes that will be presented by the given presenter.
32
- def self.add_presenter_class(presenter_class, *klasses)
33
- presenter_collection(namespace_of(presenter_class)).add_presenter_class(presenter_class, *klasses)
34
- end
35
-
36
- # @param [Class] klass The Ruby class whose namespace we would like to know.
37
- # @return [String] The name of the module containing the passed-in class.
38
- def self.namespace_of(klass)
39
- names = klass.to_s.split("::")
40
- names[-2] ? names[-2] : default_namespace
37
+ def self.add_presenter_class(presenter_class, namespace, *klasses)
38
+ presenter_collection(namespace).add_presenter_class(presenter_class, *klasses)
41
39
  end
42
40
 
43
41
  # @return [Logger] The Brainstem logger. If Rails is loaded, defaults to the Rails logger. If Rails is not loaded, defaults to a STDOUT logger.
@@ -58,4 +56,22 @@ module Brainstem
58
56
  def self.logger=(logger)
59
57
  @logger = logger
60
58
  end
59
+
60
+ # Reset all PresenterCollection's Presenters, clear the known collections, and reset the default namespace.
61
+ # This is mostly intended for resetting between tests.
62
+ def self.reset!
63
+ if @presenter_collection
64
+ @presenter_collection.each do |namespace, collection|
65
+ collection.presenters.each do |klass, presenter|
66
+ presenter.reset! if presenter.respond_to?(:reset!)
67
+ end
68
+ end
69
+ end
70
+
71
+ Brainstem::Presenter.reset!
72
+ Brainstem::Presenter.reset_configuration!
73
+
74
+ @presenter_collection = {}
75
+ @default_namespace = nil
76
+ end
61
77
  end
@@ -0,0 +1,22 @@
1
+ # Provide `brainstem_model_name` and `brainstem_plural_model_name` in controllers for use when accessing the `params` hash.
2
+
3
+ module Brainstem
4
+ module Concerns
5
+ module ControllerParamManagement
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ class_attribute :brainstem_plural_model_name, :brainstem_model_name,
10
+ instance_accessor: false, instance_reader: false, instance_writer: false
11
+ end
12
+
13
+ def brainstem_model_name
14
+ self.class.brainstem_model_name.to_s.presence || controller_name.singularize
15
+ end
16
+
17
+ def brainstem_plural_model_name
18
+ self.class.brainstem_plural_model_name.to_s.presence || brainstem_model_name.pluralize
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,58 @@
1
+ module Brainstem
2
+ module Concerns
3
+ module ErrorPresentation
4
+ extend ActiveSupport::Concern
5
+
6
+ # Given one or more error messages, return Brainstem-style errors, defaulting to type 'system'.
7
+ def brainstem_system_error(*messages)
8
+ options = messages.last.is_a?(Hash) ? messages.pop : {}
9
+ response = { :errors => [] }
10
+ messages.flatten.uniq.each do |message|
11
+ response[:errors] << {
12
+ :type => options[:type] || :system,
13
+ :message => message
14
+ }
15
+ end
16
+ response
17
+ end
18
+
19
+ # Given a model or models, outputs Brainstem-style errors, for example:
20
+ # { :errors => [{ :type => 'validation', :field => :thing_id, :message => "Thing is required" }] }
21
+ # If given a rewrite_params hash, it will convert from an internal column name to an external name.
22
+ # Note: you must validate models prior to passing them into this method. It does not call `valid?` on them.
23
+ def brainstem_model_error(object_or_objects, options = {})
24
+ json = { :errors => [] }
25
+
26
+ [object_or_objects].flatten.each.with_index do |object, index|
27
+ case object
28
+ when Hash
29
+ attribute = object[:field] || :base
30
+ json[:errors] << {
31
+ :type => object[:type] || 'validation',
32
+ :field => (options[:rewrite_params] || {}).reverse_merge(attribute => attribute).invert[attribute],
33
+ :message => object[:message] || raise(ArgumentError, "message required")
34
+ }
35
+ when String
36
+ json[:errors] << { :type => 'validation', :field => :base, :message => object }
37
+ else
38
+ object.errors.each do |attribute, attribute_error|
39
+ json[:errors] << {
40
+ :type => 'validation',
41
+ :field => (options[:rewrite_params] || {}).reverse_merge(attribute => attribute).invert[attribute],
42
+ :message => brainstem_full_error_message(object, attribute, attribute_error),
43
+ :index => index
44
+ }
45
+ end
46
+ end
47
+ end
48
+ json
49
+ end
50
+
51
+ # Helper to convert an attribute name (e.g., "thing_id") and error (e.g., "is invalid") into a combined full message.
52
+ # Also handles traditional "^You messed up"-style errors that should not be combined with humanized attribute names.
53
+ def brainstem_full_error_message(object, attribute, text)
54
+ text[0] == "^" ? text[1..-1] : object.errors.full_message(attribute, text)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,29 @@
1
+ require 'brainstem/dsl/configuration'
2
+
3
+ module Brainstem
4
+ module Concerns
5
+ module InheritableConfiguration
6
+ extend ActiveSupport::Concern
7
+
8
+ module ClassMethods
9
+ def configuration
10
+ @configuration ||= begin
11
+ if superclass.respond_to?(:configuration)
12
+ DSL::Configuration.new(superclass.configuration)
13
+ else
14
+ DSL::Configuration.new
15
+ end
16
+ end
17
+ end
18
+
19
+ def clear_configuration!
20
+ @configuration = nil
21
+ end
22
+ end
23
+
24
+ def configuration
25
+ self.class.configuration
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,30 @@
1
+ module Brainstem
2
+ module Concerns
3
+ module Lookup
4
+ extend ActiveSupport::Concern
5
+
6
+ def run_on_with_lookup(model, context, helper_instance)
7
+ context[:lookup][key_for_lookup][name] ||= begin
8
+ proc = options[:lookup]
9
+ lookup = helper_instance.instance_exec(context[:models], &proc)
10
+ if !options[:lookup_fetch].present? && !lookup.respond_to?(:[])
11
+ raise(StandardError, 'Brainstem expects the return result of the `lookup` to be a Hash since it must respond to [] in order to access the model\'s assocation(s). Default: lookup_fetch: lambda { |lookup, model| lookup[model.id] }`')
12
+ end
13
+
14
+ lookup
15
+ end
16
+
17
+ if options[:lookup_fetch]
18
+ proc = options[:lookup_fetch]
19
+ helper_instance.instance_exec(context[:lookup][key_for_lookup][name], model, &proc)
20
+ else
21
+ context[:lookup][key_for_lookup][name][model.id]
22
+ end
23
+ end
24
+
25
+ def key_for_lookup
26
+ raise(StandardError 'Implement `key_for_lookup` when including Lookup Module.')
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,111 @@
1
+ require 'brainstem/concerns/inheritable_configuration'
2
+ require 'brainstem/dsl/association'
3
+ require 'brainstem/dsl/field'
4
+ require 'brainstem/dsl/conditional'
5
+
6
+ require 'brainstem/dsl/base_block'
7
+ require 'brainstem/dsl/conditionals_block'
8
+ require 'brainstem/dsl/fields_block'
9
+ require 'brainstem/dsl/associations_block'
10
+
11
+
12
+ module Brainstem
13
+ module Concerns
14
+ module PresenterDSL
15
+ extend ActiveSupport::Concern
16
+ include Brainstem::Concerns::InheritableConfiguration
17
+
18
+ included do
19
+ reset_configuration!
20
+ end
21
+
22
+ module ClassMethods
23
+ def preload(*args)
24
+ configuration.array!(:preloads).concat args
25
+ end
26
+
27
+ def conditionals(&block)
28
+ ConditionalsBlock.new(configuration, &block)
29
+ end
30
+
31
+ def fields(&block)
32
+ FieldsBlock.new(configuration[:fields], &block)
33
+ end
34
+
35
+ def associations(&block)
36
+ AssociationsBlock.new(configuration, &block)
37
+ end
38
+
39
+ # Declare a helper module or block whose methods will be available in dynamic fields and associations.
40
+ def helper(mod = nil, &block)
41
+ if mod
42
+ configuration[:helpers] << mod
43
+ end
44
+
45
+ if block
46
+ configuration[:helpers] << Module.new.tap { |mod| mod.module_eval(&block) }
47
+ end
48
+ end
49
+
50
+ # @overload default_sort_order(sort_string)
51
+ # Sets a default sort order.
52
+ # @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.
53
+ # @return [String] The new default sort order.
54
+ # @overload default_sort_order
55
+ # @return [String] The default sort order, or nil if one is not set.
56
+ def default_sort_order(sort_string = nil)
57
+ configuration[:default_sort_order] = sort_string if sort_string
58
+ configuration[:default_sort_order]
59
+ end
60
+
61
+ # @overload sort_order(name, order)
62
+ # @param [Symbol] name The name of the sort order.
63
+ # @param [String] order The SQL string to use to sort the presented data.
64
+ # @overload sort_order(name, &block)
65
+ # @yieldparam scope [ActiveRecord::Relation] The scope representing the data being presented.
66
+ # @yieldreturn [ActiveRecord::Relation] A new scope that adds ordering requirements to the scope that was yielded.
67
+ # 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.
68
+ # @raise [ArgumentError] if neither an order string or block is given.
69
+ def sort_order(name, order = nil, &block)
70
+ raise ArgumentError, "A sort order must be given" unless block_given? || order
71
+ configuration[:sort_orders][name] = (block_given? ? block : order)
72
+ end
73
+
74
+ # @overload filter(name, options = {})
75
+ # @param [Symbol] name The name of the scope that may be applied as a filter.
76
+ # @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.
77
+ # @overload filter(name, options = {}, &block)
78
+ # @param [Symbol] name The filter can be requested using this name.
79
+ # @yieldparam scope [ActiveRecord::Relation] The scope that the filter should use as a base.
80
+ # @yieldparam arg [Object] The argument passed when the filter was requested.
81
+ # @yieldreturn [ActiveRecord::Relation] A new scope that filters the scope that was yielded.
82
+ def filter(name, options = {}, &block)
83
+ configuration[:filters][name] = [options, (block_given? ? block : nil)]
84
+ end
85
+
86
+ def search(&block)
87
+ configuration[:search] = block
88
+ end
89
+
90
+ def brainstem_key(key)
91
+ configuration[:brainstem_key] = key.to_s
92
+ end
93
+
94
+ def query_strategy(strategy)
95
+ configuration[:query_strategy] = strategy
96
+ end
97
+
98
+ # @api private
99
+ def reset_configuration!
100
+ configuration.array!(:preloads)
101
+ configuration.array!(:helpers)
102
+ configuration.nest!(:conditionals)
103
+ configuration.nest!(:fields)
104
+ configuration.nest!(:filters)
105
+ configuration.nest!(:sort_orders)
106
+ configuration.nest!(:associations)
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -1,32 +1,38 @@
1
+ require 'brainstem/concerns/controller_param_management'
2
+ require 'brainstem/concerns/error_presentation'
3
+
1
4
  module Brainstem
2
5
 
3
6
  # ControllerMethods are intended to be included into controllers that will be handling requests for presented objects.
4
7
  # The present method will pass through +params+, so that any allowed and requested includes, filters, sort orders
5
8
  # will be applied to the presented data.
6
9
  module ControllerMethods
10
+ extend ActiveSupport::Concern
11
+ include Concerns::ControllerParamManagement
12
+ include Concerns::ErrorPresentation
7
13
 
8
14
  # Return a Ruby hash that contains models requested by the user's params and allowed
9
15
  # by the +name+ presenter's configuration.
10
16
  #
11
17
  # Pass the returned hash to the render method to convert it into a useful format.
12
18
  # For example:
13
- # render :json => present("post"){ Post.where(:draft => false) }
19
+ # render :json => brainstem_present("post"){ Post.where(:draft => false) }
14
20
  # @param (see PresenterCollection#presenting)
15
21
  # @option options [String] :namespace ("none") the namespace to be presented from
16
22
  # @yield (see PresenterCollection#presenting)
17
23
  # @return (see PresenterCollection#presenting)
18
- def present(name, options = {}, &block)
24
+ def brainstem_present(name, options = {}, &block)
19
25
  Brainstem.presenter_collection(options[:namespace]).presenting(name, options.reverse_merge(:params => params), &block)
20
26
  end
21
27
 
22
- # Similar to ControllerMethods#present, but always returns all of the given objects, not just those that match any provided
23
- # filters.
28
+ # Similar to ControllerMethods#brainstem_present, but always returns all of the given objects, not just those that
29
+ # match any provided filters.
24
30
  # @option options [String] :namespace ("none") the namespace to be presented from
25
31
  # @option options [Hash] :key_map a Hash from Class name to json key name, if desired.
26
32
  # e.g., map 'SystemWidgets' objects to the 'widgets' key in the JSON. This is
27
33
  # only required if the name cannot be inferred.
28
34
  # @return (see PresenterCollection#presenting)
29
- def present_object(objects, options = {})
35
+ def brainstem_present_object(objects, options = {})
30
36
  options.merge!(:params => params, :apply_default_filters => false)
31
37
 
32
38
  if objects.is_a?(ActiveRecord::Relation) || objects.is_a?(Array)
@@ -39,9 +45,12 @@ module Brainstem
39
45
  options[:params][:only] = ids.to_s
40
46
  end
41
47
 
42
- options[:as] = (options[:key_map] || {})[klass.to_s] || klass.table_name
43
- present(klass, options) { klass.where(:id => ids) }
48
+ if options[:key_map]
49
+ raise "brainstem_present_object no longer accepts a :key_map. Use brainstem_key annotations on your presenters instead."
50
+ end
51
+
52
+ brainstem_present(klass, options) { klass.where(:id => ids) }
44
53
  end
45
- alias_method :present_objects, :present_object
54
+ alias_method :brainstem_present_objects, :brainstem_present_object
46
55
  end
47
56
  end
@@ -0,0 +1,55 @@
1
+ require 'brainstem/concerns/lookup'
2
+
3
+ module Brainstem
4
+ module DSL
5
+ class Association
6
+ include Brainstem::Concerns::Lookup
7
+
8
+ attr_reader :name, :target_class, :description, :options
9
+
10
+ def initialize(name, target_class, description, options)
11
+ @name = name.to_s
12
+ @target_class = target_class
13
+ @description = description
14
+ @options = options
15
+ end
16
+
17
+ def method_name
18
+ if options[:dynamic] || options[:lookup]
19
+ nil
20
+ else
21
+ (options[:via].presence || name).to_s
22
+ end
23
+ end
24
+
25
+ def run_on(model, context, helper_instance = Object.new)
26
+ if options[:lookup]
27
+ run_on_with_lookup(model, context, helper_instance)
28
+ elsif options[:dynamic]
29
+ proc = options[:dynamic]
30
+ if proc.arity == 1
31
+ helper_instance.instance_exec(model, &proc)
32
+ else
33
+ helper_instance.instance_exec(&proc)
34
+ end
35
+ else
36
+ model.send(method_name)
37
+ end
38
+ end
39
+
40
+ def polymorphic?
41
+ target_class == :polymorphic
42
+ end
43
+
44
+ def always_return_ref_with_sti_base?
45
+ options[:always_return_ref_with_sti_base]
46
+ end
47
+
48
+ private
49
+
50
+ def key_for_lookup
51
+ :associations
52
+ end
53
+ end
54
+ end
55
+ end