but that’s not sufficient, because the provided time_zone might be the same as before, triggering the lookup anyway.
Ideally, there’d be an attribute method in DirtyTracking like [attribute]_assigned?, which returns true even if assigned value is the same.
Is there a way to do this cleanly inside the model, that doesn’t involve overwriting an attribute setter, or doing this in the caller instead? Could this be a welcome new feature in ActiveModel?
Is there a way to do this cleanly inside the model, that doesn’t involve overwriting an attribute setter
Why not use attribute setters to track the assignments?
def zip_code= new_code
@zip_code_assigned = true
super
end
def time_zone= new_zone
@time_zone_assigned = true
super
end
before_save do
self[:time_zone] = TimeZone.find_by_zip zip_code if @zip_code_assigned && !@time_zone_assigned
remove_instance_variable :@zip_code_assigned
remove_instance_variable :@time_zone_assigned
end
Haven’t tested this, but seems like this should work.
Yeah this would definitely work, but it stresses me out a little.
Maybe it’s because every time I see this kind of code, I worry that assignment is not what it seems anymore. It has some extra logic, some potentially fragile order of operations. Every time I see the @*_assigned ivar, I also have to go double-check where it’s used, to make sure assigning is safe.
And if instead there was a built-in [attribute]_assigned?, then everyone would be familiar with this pattern, and it wouldn’t be something custom/weird you have to pay extra attention to.
I worry that assignment is not what it seems anymore. It has some extra logic, some potentially fragile order of operations.
I get that and you do want to be careful to remove any order of operation assumptions (putting the evaluation in before_save vs the accessor does that). I also feel the same way about AR hooks sometimes. I go to save a record and next thing I know e-mails are being fired off or whatever.
My general rule is these sort of adjustments to the standard persistence in models should be limited to internal state of the object. I think your case would qualify as you are updating the time zone to match the postal code (unless time zone was explicitly updated). If the accessor (or AR callback) where updating other objects or carrying out other workflow processes then I would lean towards that process more explicit to the caller. But for internal state management of an object I think it’s fine to hide functionality behind accessors and AR callbacks.
A couple years ago, I came up with a somewhat radical “rule”, but haven’t figured out how to phrase well yet, something like “always split abstractions on IO”: always split network calls, database calls, filesystem access, ENV access, and similar actions into standalone calls directly from your entry points (i.e. “shell” code). (This of course covers sending emails, etc). Do not tangle them into core logic. Examples of entry points are: controller actions, rake tasks, background job actions.
Been trying to apply it ever since, and it seems to hold up pretty well. It became the subject of this post contrasting this against DHH’s favorite approach, which seems quite the opposite: embrace vast ripple effects, and suppress them as needed.
This example is indeed borderline “probably should be in the caller”, because the timezone lookup uses an obscure dataset lookup, not unlike filesystem or db access.
@hakunin You could also pretty easily package this up yourself as an extension on ApplicationRecord. I get it’s not as clean and “conforming” as having Rails core team adding it to AR, but if it works well for you you could then submit it as a PR.
class ApplicationRecord
def self.track_assigned_attributes(*args)
args.each do |attribute_name|
define_method("#{attribute_name}=") do
instance_varbiable_set("@_#{__METHOD__.split('=').first}_assigned", true)
super
end
define_method("#{attribute_name}_assigned?") do
instance_variable_get("@_#{__METHOD__.split('_assigned?').first}_assigned")
end
end
end
end
class MyModel < ApplicationRecord
track_assigned_attributes :zip_code, :timezone
end
This is not the answer you want but this feels like business logic that might be better lived in a controller or a helper method. Adding this kind of logic to the model creates hard to debug edge cases.