brainstem 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,4 @@
1
+ module Brainstem
2
+ class Engine < ::Rails::Engine
3
+ end
4
+ end
@@ -0,0 +1,210 @@
1
+ require 'date'
2
+ require 'brainstem/association_field'
3
+ require 'brainstem/time_classes'
4
+
5
+ module Brainstem
6
+ # @abstract Subclass and override {#present} to implement a presenter.
7
+ class Presenter
8
+
9
+ # Class methods
10
+
11
+ # Accepts a list of classes this presenter knows how to present.
12
+ # @param [String, [String]] klasses Any number of names of classes this presenter presents.
13
+ def self.presents(*klasses)
14
+ Brainstem.add_presenter_class(self, *klasses)
15
+ end
16
+
17
+ # @overload default_sort_order(sort_string)
18
+ # Sets a default sort order.
19
+ # @param [String] sort_string The sort order to apply by default while presenting. The string must contain the name of a sort order that has explicitly been declared using {sort_order}. The string may end in +:asc+ or +:desc+ to indicate the default order's direction.
20
+ # @return [String] The new default sort order.
21
+ # @overload default_sort_order
22
+ # @return [String] The default sort order, or nil if one is not set.
23
+ def self.default_sort_order(sort_string = nil)
24
+ if sort_string
25
+ @default_sort_order = sort_string
26
+ else
27
+ @default_sort_order
28
+ end
29
+ end
30
+
31
+ # @overload sort_order(name, order)
32
+ # @param [Symbol] name The name of the sort order.
33
+ # @param [String] order The SQL string to use to sort the presented data.
34
+ # @overload sort_order(name, &block)
35
+ # @yieldparam scope [ActiveRecord::Relation] The scope representing the data being presented.
36
+ # @yieldreturn [ActiveRecord::Relation] A new scope that adds ordering requirements to the scope that was yielded.
37
+ # Create a named sort order, either containing a string to use as ORDER in a query, or with a block that adds an order Arel predicate to a scope.
38
+ # @raise [ArgumentError] if neither an order string or block is given.
39
+ def self.sort_order(name, order = nil, &block)
40
+ raise ArgumentError, "A sort order must be given" unless block_given? || order
41
+ @sort_orders ||= {}
42
+ @sort_orders[name] = (block_given? ? block : order)
43
+ end
44
+
45
+ # @return [Hash] All defined sort orders, keyed by their name.
46
+ def self.sort_orders
47
+ @sort_orders
48
+ end
49
+
50
+ # @overload filter(name, options = {})
51
+ # @param [Symbol] name The name of the scope that may be applied as a filter.
52
+ # @option options [Object] :default If set, causes this filter to be applied to every request. If the filter accepts parameters, the value given here will be passed to the filter when it is applied.
53
+ # @overload filter(name, options = {}, &block)
54
+ # @param [Symbol] name The filter can be requested using this name.
55
+ # @yieldparam scope [ActiveRecord::Relation] The scope that the filter should use as a base.
56
+ # @yieldparam arg [Object] The argument passed when the filter was requested.
57
+ # @yieldreturn [ActiveRecord::Relation] A new scope that filters the scope that was yielded.
58
+ def self.filter(name, options = {}, &block)
59
+ @filters ||= {}
60
+ @filters[name] = [options, (block_given? ? block : nil)]
61
+ end
62
+
63
+ # @return [Hash] All defined filters, keyed by their name.
64
+ def self.filters
65
+ @filters
66
+ end
67
+
68
+ def self.search(&block)
69
+ @search_block = block
70
+ end
71
+
72
+ def self.search_block
73
+ @search_block
74
+ end
75
+
76
+ # Declares a helper module whose methods will be available in instances of the presenter class and available inside sort and filter blocks.
77
+ # @param [Module] mod A module whose methods will be made available to filter and sort blocks, as well as inside the {#present} method.
78
+ # @return [self]
79
+ def self.helper(mod)
80
+ include mod
81
+ extend mod
82
+ end
83
+
84
+
85
+ # Instance methods
86
+
87
+ # @raise [RuntimeError] if this method has not been overridden in the presenter subclass.
88
+ def present(model)
89
+ raise "Please override #present(model) in your subclass of Brainstem::Presenter"
90
+ end
91
+
92
+ # @api private
93
+ # Calls {#post_process} on the output from {#present}.
94
+ # @return (see #post_process)
95
+ def present_and_post_process(model, associations = [])
96
+ post_process(present(model), model, associations)
97
+ end
98
+
99
+ # @api private
100
+ # Loads associations and converts dates to epoch strings.
101
+ # @return [Hash] The hash representing the models and associations, ready to be converted to JSON.
102
+ def post_process(struct, model, associations = [])
103
+ add_id(model, struct)
104
+ load_associations!(model, struct, associations)
105
+ datetimes_to_json(struct)
106
+ end
107
+
108
+ # @api private
109
+ # Adds :id as a string from the given model.
110
+ def add_id(model, struct)
111
+ if model.class.respond_to?(:primary_key)
112
+ struct[:id] = model[model.class.primary_key].to_s
113
+ end
114
+ end
115
+
116
+ # @api private
117
+ # Calls {#custom_preload}, and then {#present} and {#post_process}, for each model.
118
+ def group_present(models, associations = [])
119
+ custom_preload models, associations
120
+
121
+ models.map do |model|
122
+ present_and_post_process model, associations
123
+ end
124
+ end
125
+
126
+ # Subclasses can define this if they wish. This method will be called before {#present}.
127
+ def custom_preload(models, associations = [])
128
+ end
129
+
130
+ # @api private
131
+ # Recurses through any nested Hash/Array data structure, converting dates and times to JSON standard values.
132
+ def datetimes_to_json(struct)
133
+ case struct
134
+ when Array
135
+ struct.map { |value| datetimes_to_json(value) }
136
+ when Hash
137
+ processed = {}
138
+ struct.each { |k,v| processed[k] = datetimes_to_json(v) }
139
+ processed
140
+ when Date
141
+ struct.strftime('%F')
142
+ when *TIME_CLASSES # Time, ActiveSupport::TimeWithZone
143
+ struct.iso8601
144
+ else
145
+ struct
146
+ end
147
+ end
148
+
149
+ # @api private
150
+ # Makes sure that associations are loaded and converted into ids.
151
+ def load_associations!(model, struct, associations)
152
+ struct.to_a.each do |key, value|
153
+ if value.is_a?(AssociationField)
154
+ struct.delete key
155
+ id_attr = value.method_name ? "#{value.method_name}_id" : nil
156
+
157
+ if id_attr && model.class.columns_hash.has_key?(id_attr)
158
+ struct["#{key}_id".to_sym] = to_s_except_nil(model.send(id_attr))
159
+ reflection = value.method_name && model.reflections[value.method_name.to_sym]
160
+ if reflection && reflection.options[:polymorphic]
161
+ struct["#{key.to_s.singularize}_type".to_sym] = model.send("#{value.method_name}_type")
162
+ end
163
+ elsif associations.include?(key)
164
+ result = value.call(model)
165
+ if result.is_a?(Array)
166
+ struct["#{key.to_s.singularize}_ids".to_sym] = result.map {|a| to_s_except_nil(a.is_a?(ActiveRecord::Base) ? a.id : a) }
167
+ else
168
+ if result.is_a?(ActiveRecord::Base)
169
+ struct["#{key.to_s.singularize}_id".to_sym] = to_s_except_nil(result.id)
170
+ else
171
+ struct["#{key.to_s.singularize}_id".to_sym] = to_s_except_nil(result)
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
178
+
179
+ # @!attribute [r] default_sort_order
180
+ # The default sort order set on this presenter's class.
181
+ def default_sort_order
182
+ self.class.default_sort_order
183
+ end
184
+
185
+ # @!attribute [r] sort_orders
186
+ # The sort orders that were declared in the definition of this presenter.
187
+ def sort_orders
188
+ self.class.sort_orders
189
+ end
190
+
191
+ # @!attribute [r] filters
192
+ # The filters that were declared in the definition of this presenter.
193
+ def filters
194
+ self.class.filters
195
+ end
196
+
197
+ def search_block
198
+ self.class.search_block
199
+ end
200
+
201
+ # An association on the object being presented that should be included in the presented data.
202
+ def association(method_name = nil, options = {}, &block)
203
+ AssociationField.new method_name, options, &block
204
+ end
205
+
206
+ def to_s_except_nil(thing)
207
+ thing.nil? ? nil : thing.to_s
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,279 @@
1
+ require 'brainstem/association_field'
2
+
3
+ module Brainstem
4
+ class PresenterCollection
5
+
6
+ # @!attribute default_max_per_page
7
+ # @return [Integer] The maximum number of objects that can be requested in a single presented hash.
8
+ attr_accessor :default_max_per_page
9
+
10
+ # @!attribute default_per_page
11
+ # @return [Integer] The default number of objects that will be returned in the presented hash.
12
+ attr_accessor :default_per_page
13
+
14
+ # @!visibility private
15
+ def initialize
16
+ @default_per_page = 20
17
+ @default_max_per_page = 200
18
+ end
19
+
20
+ # The main presentation method, converting a model name and an optional scope into a hash structure, ready to be converted into JSON.
21
+ # @param [Class, String] name The class of the objects to be presented.
22
+ # @param [Hash] options The options that will be applied as the objects are converted.
23
+ # @option options [Hash] :params The +params+ hash included in a request for the presented object.
24
+ # @option options [ActiveRecord::Base] :model The model that is being presented (if different from +name+).
25
+ # @option options [String] :as The top-level key the presented objects will be assigned to (if different from +name.tableize+)
26
+ # @option options [Integer] :max_per_page The maximum number of items that can be requested by <code>params[:per_page]</code>.
27
+ # @option options [Integer] :per_page The number of items that will be returned if <code>params[:per_page]</code> is not set.
28
+ # @option options [Boolean] :apply_default_filters Determine if Presenter's filter defaults should be applied. On by default.
29
+ # @yield Must return a scope on the model +name+, which will then be presented.
30
+ # @return [Hash] A hash of arrays of hashes. Top-level hash keys are pluralized model names, with values of arrays containing one hash per object that was found by the given given options.
31
+ def presenting(name, options = {}, &block)
32
+ options[:params] ||= {}
33
+ presented_class = (options[:model] || name)
34
+ presented_class = presented_class.classify.constantize if presented_class.is_a?(String)
35
+ scope = presented_class.instance_eval(&block)
36
+
37
+ # grab the presenter that knows about filters and sorting etc.
38
+ options[:presenter] = for!(presented_class)
39
+
40
+ # table name will be used to query the database for the filtered data
41
+ options[:table_name] = presented_class.table_name
42
+
43
+ # key these models will use in the struct that is output
44
+ options[:as] = (options[:as] || name.to_s.tableize).to_sym
45
+
46
+ # Filter
47
+ scope = run_filters scope, options
48
+
49
+ # Search
50
+ scope = run_search scope, options
51
+
52
+ if options[:params][:only].present?
53
+ # Handle Only
54
+ scope, count = handle_only(scope, options[:params][:only])
55
+ else
56
+ # Paginate
57
+ scope, count = paginate scope, options
58
+ end
59
+
60
+ # Ordering
61
+ scope = handle_ordering scope, options
62
+
63
+ # Load Includes
64
+ records = scope.to_a
65
+ model = records.first
66
+
67
+ allowed_includes = calculate_allowed_includes options[:presenter], presented_class, records
68
+ includes_hash = filter_includes options[:params][:include], allowed_includes
69
+ models = perform_preloading records, includes_hash
70
+ primary_models, associated_models = gather_associations(models, includes_hash)
71
+ struct = { :count => count, options[:as] => [], :results => [] }
72
+
73
+ associated_models.each do |json_name, models|
74
+ models.flatten!
75
+ models.uniq!
76
+
77
+ if models.length > 0
78
+ presenter = for!(models.first.class)
79
+ assoc = includes_hash.to_a.find { |k, v| v[:json_name] == json_name }
80
+ struct[json_name] = presenter.group_present(models, [])
81
+ else
82
+ struct[json_name] = []
83
+ end
84
+ end
85
+
86
+ if primary_models.length > 0
87
+ presented_primary_models = options[:presenter].group_present(models, includes_hash.keys)
88
+ struct[options[:as]] += presented_primary_models
89
+ struct[:results] = presented_primary_models.map { |model| { :key => options[:as].to_s, :id => model[:id] } }
90
+ end
91
+
92
+ rewrite_keys_as_objects!(struct)
93
+
94
+ struct
95
+ end
96
+
97
+ # @return [Hash] The presenters this collection knows about, keyed on the names of the classes that can be presented.
98
+ def presenters
99
+ @presenters ||= {}
100
+ end
101
+
102
+ # @param [String, Class] presenter_class The presenter class that knows how to present all of the classes given in +klasses+.
103
+ # @param [*Class] klasses One or more classes that can be presented by +presenter_class+.
104
+ def add_presenter_class(presenter_class, *klasses)
105
+ klasses.each do |klass|
106
+ presenters[klass.to_s] = presenter_class.new
107
+ end
108
+ end
109
+
110
+ # @return [Brainstem::Presenter, nil] The presenter that knows how to present the class +klass+, or +nil+ if there isn't one.
111
+ def for(klass)
112
+ presenters[klass.to_s]
113
+ end
114
+
115
+ # @return [Brainstem::Presenter] The presenter that knows how to present the class +klass+.
116
+ # @raise [ArgumentError] if there is no known presenter for +klass+.
117
+ def for!(klass)
118
+ self.for(klass) || raise(ArgumentError, "Unable to find a presenter for class #{klass}")
119
+ end
120
+
121
+ private
122
+
123
+ def paginate(scope, options)
124
+ max_per_page = (options[:max_per_page] || default_max_per_page).to_i
125
+ per_page = (options[:params][:per_page] || options[:per_page] || default_per_page).to_i
126
+ per_page = max_per_page if per_page > max_per_page
127
+ per_page = (options[:per_page] || default_per_page).to_i if per_page < 1
128
+
129
+ page = (options[:params][:page] || 1).to_i
130
+ page = 1 if page < 1
131
+
132
+ [scope.limit(per_page).offset(per_page * (page - 1)).uniq, scope.select("distinct `#{options[:table_name]}`.id").count] # as of Rails 3.2.5, uniq.count generates the wrong SQL.
133
+ end
134
+
135
+ # Gather allowed includes by inspecting the presented hash. For now, this requires that a new instance of the
136
+ # presented class always be presentable.
137
+ def calculate_allowed_includes(presenter, presented_class, records)
138
+ allowed_includes = {}
139
+ model = records.first || presented_class.new
140
+ presenter.present(model).each do |k, v|
141
+ next unless v.is_a?(AssociationField)
142
+
143
+ if v.json_name
144
+ v.json_name = v.json_name.tableize
145
+ else
146
+ association = model.class.reflections[v.method_name]
147
+ if !association.options[:polymorphic]
148
+ v.json_name = association && association.table_name
149
+ if v.json_name.nil?
150
+ raise ":json_name is a required option for method-based associations (#{presented_class}##{v.method_name})"
151
+ end
152
+ end
153
+ end
154
+ allowed_includes[k.to_s] = v
155
+ end
156
+ allowed_includes
157
+ end
158
+
159
+ def filter_includes(user_includes, allowed_includes)
160
+ filtered_includes = {}
161
+ (user_includes || "").split(',').each do |k|
162
+ allowed = allowed_includes[k]
163
+ if allowed
164
+ filtered_includes[k.to_sym] = {
165
+ :association => allowed.method_name.to_sym,
166
+ :json_name => allowed.json_name.try(:to_sym)
167
+ }
168
+ end
169
+ end
170
+
171
+ filtered_includes
172
+ end
173
+
174
+ def handle_only(scope, only)
175
+ ids = (only || "").split(",").select {|id| id =~ /\A\d+\Z/}.uniq
176
+ [scope.where(:id => ids), scope.where(:id => ids).count]
177
+ end
178
+
179
+ def run_filters(scope, options)
180
+ run_defaults = options.has_key?(:apply_default_filters) ? options[:apply_default_filters] : true
181
+
182
+ (options[:presenter].filters || {}).each do |filter_name, filter|
183
+ requested = options[:params][filter_name]
184
+ requested = requested.present? ? requested.to_s : nil
185
+ requested = requested == "true" ? true : (requested == "false" ? false : requested)
186
+
187
+ filter_options, filter_lambda = filter
188
+ args = run_defaults ? (requested || filter_options[:default]) : requested
189
+ next if args.nil?
190
+
191
+ if filter_lambda
192
+ scope = filter_lambda.call(scope, *args)
193
+ else
194
+ scope = scope.send(filter_name, *args)
195
+ end
196
+ end
197
+
198
+ scope
199
+ end
200
+
201
+ def run_search(scope, options)
202
+ return scope unless options[:params][:search] && options[:presenter].search_block.present?
203
+
204
+ result_ids = options[:presenter].search_block.call(options[:params][:search])
205
+ scope.where(:id => result_ids )
206
+ end
207
+
208
+ def handle_ordering(scope, options)
209
+ default_column, default_direction = (options[:presenter].default_sort_order || "updated_at:desc").split(":")
210
+ sort_name, direction = (options[:params][:order] || "").split(":")
211
+ sort_orders = (options[:presenter].sort_orders || {})
212
+
213
+ if sort_name.present? && sort_orders[sort_name.to_sym]
214
+ order = sort_orders[sort_name.to_sym]
215
+ else
216
+ order = sort_orders[default_column.to_sym]
217
+ direction = default_direction
218
+ end
219
+
220
+ case order
221
+ when Proc
222
+ order.call(scope, direction == "desc" ? "desc" : "asc")
223
+ when nil
224
+ scope
225
+ else
226
+ scope.order(order.to_s + " " + (direction == "desc" ? "desc" : "asc"))
227
+ end
228
+ end
229
+
230
+ def perform_preloading(records, includes_hash)
231
+ records.tap do |models|
232
+ association_names_to_preload = includes_hash.values.map {|i| i[:association] }
233
+ if models.first
234
+ reflections = models.first.reflections
235
+ association_names_to_preload.reject! { |association| !reflections.has_key?(association) }
236
+ end
237
+ if association_names_to_preload.any?
238
+ ActiveRecord::Associations::Preloader.new(models, association_names_to_preload).run
239
+ Brainstem.logger.info "Eager loaded #{association_names_to_preload.join(", ")}."
240
+ end
241
+ end
242
+ end
243
+
244
+ def gather_associations(models, includes_hash)
245
+ record_hash = {}
246
+ primary_models = []
247
+
248
+ includes_hash.each do |include, include_data|
249
+ record_hash[include_data[:json_name]] ||= [] if include_data[:json_name]
250
+ end
251
+
252
+ models.each do |model|
253
+ primary_models << model
254
+
255
+ includes_hash.each do |include, include_data|
256
+ models = Array(model.send(include_data[:association]))
257
+ if include_data[:json_name]
258
+ record_hash[include_data[:json_name]] += models
259
+ else
260
+ # polymorphic associations' tables must be figured out now
261
+ models.each do |record|
262
+ json_name = record.class.table_name.to_sym
263
+ record_hash[json_name] ||= []
264
+ record_hash[json_name] << record
265
+ end
266
+ end
267
+ end
268
+ end
269
+
270
+ [primary_models, record_hash]
271
+ end
272
+
273
+ def rewrite_keys_as_objects!(struct)
274
+ (struct.keys - [:count, :results]).each do |key|
275
+ struct[key] = struct[key].inject({}) {|memo, obj| memo[obj[:id] || obj["id"] || "unknown_id"] = obj; memo }
276
+ end
277
+ end
278
+ end
279
+ end