Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new Rails/ActiveRecordCallbacksOrder cop #285

Merged
merged 1 commit into from
Jul 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### New features

* [#283](https://github.com/rubocop-hq/rubocop-rails/pull/283): Add new `Rails/FindById` cop. ([@fatkodima][])
* [#285](https://github.com/rubocop-hq/rubocop-rails/pull/285): Add new `Rails/ActiveRecordCallbacksOrder` cop. ([@fatkodima][])
* [#276](https://github.com/rubocop-hq/rubocop-rails/pull/276): Add new `Rails/RenderPlainText` cop. ([@fatkodima][])
* [#76](https://github.com/rubocop-hq/rubocop-rails/issues/76): Add new `Rails/DefaultScope` cop. ([@fatkodima][])
* [#275](https://github.com/rubocop-hq/rubocop-rails/pull/275): Add new `Rails/MatchRoute` cop. ([@fatkodima][])
Expand Down
8 changes: 8 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ Rails/ActiveRecordAliases:
VersionAdded: '0.53'
SafeAutoCorrect: false

Rails/ActiveRecordCallbacksOrder:
Description: 'Order callback declarations in the order in which they will be executed.'
StyleGuide: 'https://rails.rubystyle.guide/#callbacks-order'
Enabled: 'pending'
VersionAdded: '2.7'
Include:
- app/models/**/*.rb

Rails/ActiveRecordOverride:
Description: >-
Check for overriding Active Record methods instead of using
Expand Down
1 change: 1 addition & 0 deletions docs/modules/ROOT/pages/cops.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

* xref:cops_rails.adoc#railsactionfilter[Rails/ActionFilter]
* xref:cops_rails.adoc#railsactiverecordaliases[Rails/ActiveRecordAliases]
* xref:cops_rails.adoc#railsactiverecordcallbacksorder[Rails/ActiveRecordCallbacksOrder]
* xref:cops_rails.adoc#railsactiverecordoverride[Rails/ActiveRecordOverride]
* xref:cops_rails.adoc#railsactivesupportaliases[Rails/ActiveSupportAliases]
* xref:cops_rails.adoc#railsapplicationcontroller[Rails/ApplicationController]
Expand Down
46 changes: 46 additions & 0 deletions docs/modules/ROOT/pages/cops_rails.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,52 @@ Book.update_attributes!(author: 'Alice')
Book.update!(author: 'Alice')
----

== Rails/ActiveRecordCallbacksOrder

|===
| Enabled by default | Safe | Supports autocorrection | VersionAdded | VersionChanged

| Pending
| Yes
| Yes
| 2.7
| -
|===

This cop checks that Active Record callbacks are declared
in the order in which they will be executed.

=== Examples

[source,ruby]
----
# bad
class Person < ApplicationRecord
after_commit :after_commit_callback
before_validation :before_validation_callback
end

# good
class Person < ApplicationRecord
before_validation :before_validation_callback
after_commit :after_commit_callback
end
----

=== Configurable attributes

|===
| Name | Default value | Configurable values

| Include
| `app/models/**/*.rb`
| Array
|===

=== References

* https://rails.rubystyle.guide/#callbacks-order

== Rails/ActiveRecordOverride

|===
Expand Down
145 changes: 145 additions & 0 deletions lib/rubocop/cop/rails/active_record_callbacks_order.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# frozen_string_literal: true

module RuboCop
module Cop
module Rails
# This cop checks that Active Record callbacks are declared
# in the order in which they will be executed.
#
# @example
# # bad
# class Person < ApplicationRecord
# after_commit :after_commit_callback
# before_validation :before_validation_callback
# end
#
# # good
# class Person < ApplicationRecord
# before_validation :before_validation_callback
# after_commit :after_commit_callback
# end
#
class ActiveRecordCallbacksOrder < Cop
MSG = '`%<current>s` is supposed to appear before `%<previous>s`.'

CALLBACKS_IN_ORDER = %i[
after_initialize
before_validation
after_validation
before_save
around_save
before_create
around_create
after_create
before_update
around_update
after_update
before_destroy
around_destroy
after_destroy
after_save
after_commit
after_rollback
after_find
after_touch
].freeze

CALLBACKS_ORDER_MAP = Hash[
CALLBACKS_IN_ORDER.map.with_index { |name, index| [name, index] }
].freeze

def on_class(class_node)
previous_index = -1
previous_callback = nil

defined_callbacks(class_node).each do |node|
callback = node.method_name
index = CALLBACKS_ORDER_MAP[callback]

if index < previous_index
message = format(MSG, current: callback,
previous: previous_callback)
add_offense(node, message: message)
end
previous_index = index
previous_callback = callback
end
end

# Autocorrect by swapping between two nodes autocorrecting them
def autocorrect(node)
previous = left_siblings_of(node).find do |sibling|
callback?(sibling)
end

current_range = source_range_with_comment(node)
previous_range = source_range_with_comment(previous)

lambda do |corrector|
corrector.insert_before(previous_range, current_range.source)
corrector.remove(current_range)
end
end

private

def defined_callbacks(class_node)
class_def = class_node.body

if class_def
class_def.each_child_node.select { |c| callback?(c) }
else
[]
end
end

def callback?(node)
node.send_type? && CALLBACKS_ORDER_MAP.key?(node.method_name)
end

def left_siblings_of(node)
siblings_of(node)[0, node.sibling_index]
end

def siblings_of(node)
node.parent.children
end

def source_range_with_comment(node)
begin_pos = begin_pos_with_comment(node)
end_pos = end_position_for(node)

Parser::Source::Range.new(buffer, begin_pos, end_pos)
end

def end_position_for(node)
end_line = buffer.line_for_position(node.loc.expression.end_pos)
buffer.line_range(end_line).end_pos
end

def begin_pos_with_comment(node)
annotation_line = node.first_line - 1
first_comment = nil

processed_source.comments_before_line(annotation_line)
.reverse_each do |comment|
if comment.location.line == annotation_line
first_comment = comment
annotation_line -= 1
end
end

start_line_position(first_comment || node)
end

def start_line_position(node)
buffer.line_range(node.loc.line).begin_pos - 1
end

def buffer
processed_source.buffer
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/rubocop/cop/rails_cops.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

require_relative 'rails/action_filter'
require_relative 'rails/active_record_aliases'
require_relative 'rails/active_record_callbacks_order'
require_relative 'rails/active_record_override'
require_relative 'rails/active_support_aliases'
require_relative 'rails/application_controller'
Expand Down
110 changes: 110 additions & 0 deletions spec/rubocop/cop/rails/active_record_callbacks_order_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# frozen_string_literal: true

RSpec.describe RuboCop::Cop::Rails::ActiveRecordCallbacksOrder do
subject(:cop) { described_class.new }

it 'registers an offense and corrects when declared callbacks are not correctly ordered' do
expect_offense(<<~RUBY)
class User < ApplicationRecord
scope :admins, -> { where(admin: true) }

after_commit :after_commit_callback
after_save :after_save_callback
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `after_save` is supposed to appear before `after_commit`.

def some_method
end

before_validation :before_validation_callback
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `before_validation` is supposed to appear before `after_save`.
some_other_macros :foo
end
RUBY

expect_correction(<<~RUBY)
class User < ApplicationRecord
scope :admins, -> { where(admin: true) }

before_validation :before_validation_callback
after_save :after_save_callback
after_commit :after_commit_callback

def some_method
end

some_other_macros :foo
end
RUBY
end

it 'correcly autocorrects when there is a comment for callback method' do
new_source = autocorrect_source(<<~RUBY)
class User < ApplicationRecord
# This is a
# multiline
# comment for after_commit.
after_commit :after_commit_callback
# This is another
# multiline
# comment for after_save.
after_save :after_save_callback
end
RUBY

expect(new_source).to eq(<<~RUBY)
class User < ApplicationRecord
# This is another
# multiline
# comment for after_save.
after_save :after_save_callback
# This is a
# multiline
# comment for after_commit.
after_commit :after_commit_callback
fatkodima marked this conversation as resolved.
Show resolved Hide resolved
end
RUBY
end

it 'correcly autocorrects when there are multiple callbacks of the same type' do
new_source = autocorrect_source(<<~RUBY)
class User < ApplicationRecord
after_commit :after_commit_callback1
after_save :after_save_callback
after_commit :after_commit_callback2
end
RUBY

expect(new_source).to eq(<<~RUBY)
class User < ApplicationRecord
after_save :after_save_callback
after_commit :after_commit_callback1
after_commit :after_commit_callback2
end
RUBY
end

it 'does not register an offense when declared callbacks are correctly ordered' do
expect_no_offenses(<<~RUBY)
class User < ApplicationRecord
scope :admins, -> { where(admin: true) }

before_validation :before_validation_callback
after_save :after_save_callback

def some_method
end

after_commit :after_commit_callback
end
RUBY
end
fatkodima marked this conversation as resolved.
Show resolved Hide resolved

it 'does not register an offense when there are no callbacks' do
expect_no_offenses(<<~RUBY)
class User < ApplicationRecord
def some_method
end
end
RUBY
end
end