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