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.
- checksums.yaml +5 -13
- data/CHANGELOG.md +16 -2
- data/Gemfile.lock +51 -36
- data/README.md +531 -110
- data/brainstem.gemspec +6 -2
- data/lib/brainstem.rb +25 -9
- data/lib/brainstem/concerns/controller_param_management.rb +22 -0
- data/lib/brainstem/concerns/error_presentation.rb +58 -0
- data/lib/brainstem/concerns/inheritable_configuration.rb +29 -0
- data/lib/brainstem/concerns/lookup.rb +30 -0
- data/lib/brainstem/concerns/presenter_dsl.rb +111 -0
- data/lib/brainstem/controller_methods.rb +17 -8
- data/lib/brainstem/dsl/association.rb +55 -0
- data/lib/brainstem/dsl/associations_block.rb +12 -0
- data/lib/brainstem/dsl/base_block.rb +31 -0
- data/lib/brainstem/dsl/conditional.rb +25 -0
- data/lib/brainstem/dsl/conditionals_block.rb +15 -0
- data/lib/brainstem/dsl/configuration.rb +112 -0
- data/lib/brainstem/dsl/field.rb +68 -0
- data/lib/brainstem/dsl/fields_block.rb +25 -0
- data/lib/brainstem/preloader.rb +98 -0
- data/lib/brainstem/presenter.rb +325 -134
- data/lib/brainstem/presenter_collection.rb +82 -286
- data/lib/brainstem/presenter_validator.rb +96 -0
- data/lib/brainstem/query_strategies/README.md +107 -0
- data/lib/brainstem/query_strategies/base_strategy.rb +62 -0
- data/lib/brainstem/query_strategies/filter_and_search.rb +50 -0
- data/lib/brainstem/query_strategies/filter_or_search.rb +103 -0
- data/lib/brainstem/test_helpers.rb +5 -1
- data/lib/brainstem/version.rb +1 -1
- data/spec/brainstem/concerns/controller_param_management_spec.rb +42 -0
- data/spec/brainstem/concerns/error_presentation_spec.rb +113 -0
- data/spec/brainstem/concerns/inheritable_configuration_spec.rb +210 -0
- data/spec/brainstem/concerns/presenter_dsl_spec.rb +412 -0
- data/spec/brainstem/controller_methods_spec.rb +15 -27
- data/spec/brainstem/dsl/association_spec.rb +123 -0
- data/spec/brainstem/dsl/conditional_spec.rb +93 -0
- data/spec/brainstem/dsl/configuration_spec.rb +1 -0
- data/spec/brainstem/dsl/field_spec.rb +212 -0
- data/spec/brainstem/preloader_spec.rb +137 -0
- data/spec/brainstem/presenter_collection_spec.rb +565 -244
- data/spec/brainstem/presenter_spec.rb +726 -167
- data/spec/brainstem/presenter_validator_spec.rb +209 -0
- data/spec/brainstem/query_strategies/filter_and_search_spec.rb +46 -0
- data/spec/brainstem/query_strategies/filter_or_search_spec.rb +45 -0
- data/spec/spec_helper.rb +11 -3
- data/spec/spec_helpers/db.rb +32 -65
- data/spec/spec_helpers/presenters.rb +124 -29
- data/spec/spec_helpers/rr.rb +11 -0
- data/spec/spec_helpers/schema.rb +115 -0
- metadata +126 -30
- data/lib/brainstem/association_field.rb +0 -53
- data/lib/brainstem/engine.rb +0 -4
- data/pkg/brainstem-0.2.5.gem +0 -0
- data/pkg/brainstem-0.2.6.gem +0 -0
- data/spec/spec_helpers/cleanup.rb +0 -23
@@ -0,0 +1,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
|
data/lib/brainstem/presenter.rb
CHANGED
@@ -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
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
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
|
-
#
|
32
|
-
#
|
33
|
-
#
|
34
|
-
|
35
|
-
|
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
|
-
|
46
|
-
|
47
|
-
@sort_orders
|
48
|
-
end
|
34
|
+
def self.merged_helper_class
|
35
|
+
@helper_classes ||= {}
|
49
36
|
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
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
|
-
|
64
|
-
|
65
|
-
@
|
46
|
+
def self.reset!
|
47
|
+
clear_configuration!
|
48
|
+
@helper_classes = @presents = nil
|
66
49
|
end
|
67
50
|
|
68
|
-
|
69
|
-
|
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
|
-
|
73
|
-
|
56
|
+
# Instance methods
|
57
|
+
|
58
|
+
# @deprecated
|
59
|
+
def present(model)
|
60
|
+
raise "#present is now deprecated"
|
74
61
|
end
|
75
62
|
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
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
|
-
|
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
|
-
|
88
|
-
|
89
|
-
|
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
|
-
|
93
|
-
|
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
|
-
#
|
101
|
-
#
|
102
|
-
def
|
103
|
-
|
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
|
-
#
|
110
|
-
|
111
|
-
|
112
|
-
|
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
|
-
#
|
118
|
-
|
119
|
-
|
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
|
-
|
122
|
-
|
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
|
-
|
127
|
-
|
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
|
-
#
|
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
|
150
|
-
#
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
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
|
-
|
173
|
-
result
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
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
|
-
#
|
189
|
-
#
|
190
|
-
def
|
191
|
-
|
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
|
-
#
|
195
|
-
#
|
196
|
-
|
197
|
-
|
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
|
-
#
|
201
|
-
#
|
202
|
-
def
|
203
|
-
|
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
|
-
|
207
|
-
|
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
|
-
#
|
211
|
-
|
212
|
-
|
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
|
-
|
216
|
-
|
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
|