forked from jamesgolick/active_presenter
/
base.rb
245 lines (205 loc) · 8.19 KB
/
base.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
module ActivePresenter
# Base class for presenters. See README for usage.
#
class Base
include ActiveSupport::Callbacks
define_callbacks :before_validation, :before_save, :after_save
class_inheritable_accessor :presented
self.presented = {}
# Indicates which models are to be presented by this presenter.
# i.e.
#
# class SignupPresenter < ActivePresenter::Base
# presents :user, :account
# end
#
# In the above example, :user will (predictably) become User. If you want to override this behaviour, specify the desired types in a hash, as so:
#
# class PresenterWithTwoAddresses < ActivePresenter::Base
# presents :primary_address => Address, :secondary_address => Address
# end
#
def self.presents(*types)
types_and_classes = types.extract_options!
types.each { |t| types_and_classes[t] = t.to_s.tableize.classify.constantize }
attr_accessor *types_and_classes.keys
types_and_classes.keys.each do |t|
define_method("#{t}_errors") do
send(t).errors
end
presented[t] = types_and_classes[t]
end
end
def self.human_attribute_name(attribute_name)
presentable_type = presented.keys.detect do |type|
attribute_name.to_s.starts_with?("#{type}_")
end
attribute_name.to_s.gsub("#{presentable_type}_", "").humanize
end
# Accepts arguments in two forms. For example, if you had a SignupPresenter that presented User, and Account, you could specify arguments in the following two forms:
#
# 1. SignupPresenter.new(:user_login => 'james', :user_password => 'swordfish', :user_password_confirmation => 'swordfish', :account_subdomain => 'giraffesoft')
# - This form is useful for initializing a new presenter from the params hash: i.e. SignupPresenter.new(params[:signup_presenter])
# 2. SignupPresenter.new(:user => User.find(1), :account => Account.find(2))
# - This form is useful if you have instances that you'd like to edit using the presenter. You can subsequently call presenter.update_attributes(params[:signup_presenter]) just like with a regular AR instance.
#
# Both forms can also be mixed together: SignupPresenter.new(:user => User.find(1), :user_login => 'james')
# In this case, the login attribute will be updated on the user instance provided.
#
# If you don't specify an instance, one will be created by calling Model.new
#
def initialize(args = {})
args ||= {}
presented.each do |type, klass|
value = args.delete(type)
send("#{type}=", value.is_a?(klass) ? value : klass.new)
end
self.attributes = args
end
# Set the attributes of the presentable instances using
# the type_attribute form (i.e. user_login => 'james'), or
# the multiparameter attribute form (i.e. {user_birthday(1i) => "1980", user_birthday(2i) => "3"})
#
def attributes=(attrs)
multi_parameter_attributes = {}
attrs.each do |k,v|
if (base_attribute = k.to_s.split("(").first) != k.to_s
presentable = presentable_for(base_attribute)
multi_parameter_attributes[presentable] ||= {}
multi_parameter_attributes[presentable].merge!(flatten_attribute_name(k,presentable).to_sym => v)
else
send("#{k}=", v) unless attribute_protected?(k)
end
end
multi_parameter_attributes.each do |presentable,multi_attrs|
send(presentable).send(:attributes=, multi_attrs)
end
end
# Makes sure that the presenter is accurate about responding to presentable's attributes, even though they are handled by method_missing.
#
def respond_to?(method, include_private = false)
presented_attribute?(method) || super
end
# Handles the decision about whether to delegate getters and setters to presentable instances.
#
def method_missing(method_name, *args, &block)
presented_attribute?(method_name) ? delegate_message(method_name, *args, &block) : super
end
# Returns an instance of ActiveRecord::Errors with all the errors from the presentables merged in using the type_attribute form (i.e. user_login).
#
def errors
@errors ||= ActiveRecord::Errors.new(self)
end
# Returns boolean based on the validity of the presentables by calling valid? on each of them.
#
def valid?
errors.clear
if run_callbacks_with_halt(:before_validation)
presented.keys.each do |type|
presented_inst = send(type)
next unless save?(type, presented_inst)
merge_errors(presented_inst, type) unless presented_inst.valid?
end
errors.empty?
end
end
# Save all of the presentables, wrapped in a transaction.
#
# Returns true or false based on success.
#
def save
saved = false
ActiveRecord::Base.transaction do
if valid? && run_callbacks_with_halt(:before_save)
saved = presented.keys.select {|key| save?(key, send(key))}.all? {|key| send(key).save}
raise ActiveRecord::Rollback unless saved # TODO: Does this happen implicitly?
end
run_callbacks_with_halt(:after_save) if saved
end
saved
end
# Save all of the presentables wrapped in a transaction.
#
# Returns true on success, will raise otherwise.
#
def save!
raise ActiveRecord::RecordInvalid.new(self) unless valid?
raise ActiveRecord::RecordNotSaved unless run_callbacks_with_halt(:before_save)
ActiveRecord::Base.transaction do
presented.keys.select {|key| save?(key, send(key))}.each {|key| send(key).save!}
run_callbacks_with_halt(:after_save)
end
true
end
# Update attributes, and save the presentables
#
# Returns true or false based on success.
#
def update_attributes(attrs)
self.attributes = attrs
save
end
# Should this presented instance be saved? By default, this returns true
# Called from #save and #save!
#
# For
# class SignupPresenter < ActivePresenter::Base
# presents :account, :user
# end
#
# #save? will be called twice:
# save?(:account, #<Account:0x1234dead>)
# save?(:user, #<User:0xdeadbeef>)
def save?(presented_key, presented_instance)
true
end
# We define #id and #new_record? to play nice with form_for(@presenter) in Rails
def id # :nodoc:
nil
end
def new_record?
true
end
protected
def presented_instances
presented.keys.map { |key| send(key) }
end
def delegate_message(method_name, *args, &block)
presentable = presentable_for(method_name)
send(presentable).send(flatten_attribute_name(method_name, presentable), *args, &block)
end
def presentable_for(method_name)
presented.keys.sort_by { |k| k.to_s.size }.reverse.detect do |type|
method_name.to_s.starts_with?(attribute_prefix(type))
end
end
def presented_attribute?(method_name)
p = presentable_for(method_name)
!p.nil? && send(p).respond_to?(flatten_attribute_name(method_name,p))
end
def flatten_attribute_name(name, type)
name.to_s.gsub(/^#{attribute_prefix(type)}/, '')
end
def attribute_prefix(type)
"#{type}_"
end
def merge_errors(presented_inst, type)
presented_inst.errors.each do |att,msg|
if att == 'base'
errors.add(type, msg)
else
errors.add(attribute_prefix(type)+att, msg)
end
end
end
def attribute_protected?(name)
presentable = presentable_for(name)
return false unless presentable
flat_attribute = {flatten_attribute_name(name, presentable) => ''} #remove_att... normally takes a hash, so we use a ''
presented[presentable].new.send(:remove_attributes_protected_from_mass_assignment, flat_attribute).empty?
end
def run_callbacks_with_halt(callback)
run_callbacks(callback) { |result, object| result == false }
end
end
end