Use synvert to upgrade Dirty API to ActiveRecord 5.1
TL;DR you can run
synvert-ruby -r rails/convert_active_record_dirty_5_0_to_5_1 <project_path>
to upgrade active_record dirty api.
Rails 5.1 introduced a new ActiveRecord dirty API, we need to use some longer but less ambiguity methods.
before callbacks | after callbacks | |
<attribute>_changed? | will_save_change_to_<attribute>? | saved_change_to_<attribute>? |
<attribute>_change | <attribute>_change_to_be_saved | saved_change_to_<attribute> |
<attribute>_was | <attribute>_in_database | <attribute>_before_last_save |
changes | changes_to_save | saved_changes |
changed? | has_changes_to_save? | saved_changes? |
changed | changed_attribute_names_to_save | saved_changes.keys |
changed_attributes | attributes_in_database | saved_changes.transform_values(&:first) |
As you can see, the dirty apis start to use different method names for before and after callbacks,
it’s hard to use simple text find and replace, so I use synvert-ruby
to make the conversions.
Use the synvert snippet
The snippet name is rails/convert_active_record_dirty_5_0_to_5_1
, so you can run the following command to automatically convert the dirty api.
$ synvert-ruby -r rails/convert_active_record_dirty_5_0_to_5_1 <project_path>
Here are a small part of the long git diff.
before_save do
- edit if title_changed? || content_changed?
+ edit if will_save_change_to_title? || will_save_change_to_content?
end
after_save do
- self.just_ended = (ended? && ended_at_changed?)
+ self.just_ended = (ended? && saved_change_to_ended_at?)
end
- after_update :send_email, :if => :email_changed?
+ after_update :send_email, :if => :saved_change_to_email?
- tag_changes = node.changes['tag']
+ tag_changes = node.saved_changes['tag']
Write the synvert snippet
So how did I write the snippet?
First, I defined the files to match, rails model and observer files.
within_files Synvert::RAILS_MODEL_FILES + Synvert::RAILS_OBSERVER_FILES do
...
end
1. let’s try to convert <attribute>_changed?
to saved_change_to_<attribute>
for after_save
callback in if
/unless
parameter.
# after_save :invalidate_cache, if: -> { title_changed? || summary_chagned? }
with_node type: 'send', receiver: nil, message: 'after_save' do
with_node type: 'hash' do
with_node type: 'sym', to_value: /(\w+)_changed\?$/ do
if node.to_value =~ /(\w+)_changed\?$/ # match regexp again to get the last match
replace_with ":saved_change_to_#{Regexp.last_match(1)}?"
end
end
end
end
It uses with_node
to match the after_save callback first, then hash
parameters, then sym
value to match <attribute>_changed?
, and replace the symbol with :saved_change_to_<attribute>
2. match all after callbacks
after_callback_names = %i[after_create after_update after_save after_commit after_create_commit after_update_commit after_save_commit]
with_node type: 'send', receiver: nil, message: { in: after_callback_names } do
end
3. do the conversion in after_save
block.
# before_save do
# if status_chagned?
# end
# end
with_node type: 'block',
caller: { type: 'send', receiver: nil,
message: { in: after_callback_names } } do
with_node type: 'send', message: /(\w+)_changed\?$/ do
if node.to_value =~ /(\w+)_changed\?$/ # match regexp again to get the last match
replace_with ":saved_change_to_#{Regexp.last_match(1)}?"
end
end
end
4. do the conversion in after_save
method.
# def after_save
# if status_chagned?
# end
# end
with_node type: 'def', name: { in: after_callback_names } do
with_node type: 'send', message: /(\w+)_changed\?$/ do
if node.to_value =~ /(\w+)_changed\?$/ # match regexp again to get the last match
replace_with ":saved_change_to_#{Regexp.last_match(1)}?"
end
end
end
5. do the conversion in after_save
custom callback method.
# after_save :invalidate_cache
# def invalidate_cache
# if status_chagned?
# end
# end
custom_callback_names = []
with_node type: 'send',
receiver: nil,
message: { in: after_callback_names },
arguments: { size: { gt: 0 }, first: { type: 'sym' }} do
custom_callback_names << node.arguments[0].to_value
end
with_node type: 'def', name: { in: custom_callback_names } do
with_node type: 'send', message: /(\w+)_changed\?$/ do
if node.to_value =~ /(\w+)_changed\?$/ # match regexp again to get the last match
replace_with ":saved_change_to_#{Regexp.last_match(1)}?"
end
end
It finds all custom callback methods first, then find <attribute>_changed?
in those custom methods.
6. match all after callbacks api changes
after_callback_changes = {
/(\w+)_changed\?$/ => 'saved_change_to_?',
/(\w+)_change$/ => 'saved_change_to_',
/(\w+)_was$/ => '_before_last_save',
'changes' => 'saved_changes',
'changed?' => 'saved_changes?',
'changed' => 'saved_changes.keys',
'changed_attributes' => 'saved_changes.transform_values(&:first)'
}
after_callback_changes.each do |before_name, after_name|
with_node type: 'send', message: before_name do
if before_name.is_a?(Regexp)
if node.message.to_s =~ before_name # match regexp again to get the last match
replace :message, with: after_name.sub('', Regexp.last_match(1))
end
else
replace :message, with: after_name
end
end
end
7. what about before callbacks? That’s easy, just abstract the above conversions and reuse for before callbacks
before_callback_changes = {
/(\w+)_changed\?$/ => 'will_save_change_to_?',
/(\w+)_change$/ => '_change_to_be_saved',
/(\w+)_was$/ => '_in_database',
'changes' => 'changes_to_save',
'changed?' => 'has_changes_to_save?',
'changed' => 'changed_attribute_names_to_save',
'changed_attributes' => 'attributes_in_database'
}
before_callback_names = %i[before_create before_update before_save]
helper_method :find_callbacks_and_convert do |callback_names, callback_changes|
...
end
within_files Synvert::RAILS_MODEL_FILES + Synvert::RAILS_OBSERVER_FILES do
find_callbacks_and_convert(before_callback_names, before_callback_changes)
find_callbacks_and_convert(after_callback_names, after_callback_changes)
end
There are some other edge cases that I didn’t cover here, e.g. if user defined a method password_changed?
, I won’t convert it to saved_change_to_password?
or will_save_change_to_password?
. To get the full snippet code, please check it out here.