diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cdb79b..a3d89c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ # main +* Added cop `Rails/EnumStartingValue` ([#57](https://github.com/petalmd/rubocop-petal/pull/57)) + * Added cop `Migration/StandaloneAddReference` ([#54](https://github.com/petalmd/rubocop-petal/pull/54)) * Update `Migration/ChangeTableReferences` on send alias and message to handle removing references. ([#55](https://github.com/petalmd/rubocop-petal/pull/55)) diff --git a/config/default.yml b/config/default.yml index a83ff96..6e5359a 100644 --- a/config/default.yml +++ b/config/default.yml @@ -81,6 +81,13 @@ Rails/EnumPrefix: Include: - app/models/**/* +Rails/EnumStartingValue: + Description: 'Prevent starting from zero with an enum.' + Enabled: true + StyleGuide: https://github.com/petalmd/rubocop-petal/issues/56 + Include: + - app/models/**/* + Rails/RiskyActiverecordInvocation: Description: 'Interpolation, use hash or parameterized syntax.' Enabled: true diff --git a/lib/rubocop/cop/rails/enum_starting_value.rb b/lib/rubocop/cop/rails/enum_starting_value.rb new file mode 100644 index 0000000..19dc4e6 --- /dev/null +++ b/lib/rubocop/cop/rails/enum_starting_value.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + module Rails + # Prevent the user to start from Zero with an enum. + # When using a wrong value like .where(state: :patato) let Rails + MySQL do a WHERE state = 0. + # It will match nothing since no record will have a 0 value. + # + # # bad + # enum my_enum: {apple: 0, bannana: 1} + # + # # good + # enum my_enum: {apple: 1, banana: 2} + + class EnumStartingValue < Base + MSG = 'Prefer starting from `1` instead of `0` with `enum`.' + + def_node_matcher :enum?, <<~PATTERN + (send nil? :enum (hash ...)) + PATTERN + + def_node_matcher :enum_attributes, <<~PATTERN + (send nil? :enum (:hash (:pair (...)$(...) )...)) + PATTERN + + def on_send(node) + return unless enum? node + + add_offense(node) if start_with_zero?(enum_attributes(node)) + end + + def start_with_zero?(node) + return unless node.type == :hash + + node.children.any? do |child| + value = child.value + value.type == :int && value.value.zero? + end + end + end + end + end +end diff --git a/spec/rubocop/cop/rails/enum_starting_value_spec.rb b/spec/rubocop/cop/rails/enum_starting_value_spec.rb new file mode 100644 index 0000000..23f44c2 --- /dev/null +++ b/spec/rubocop/cop/rails/enum_starting_value_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +RSpec.describe RuboCop::Cop::Rails::EnumStartingValue, :config do + context 'without an enum' do + it 'expects no offense' do + expect_no_offenses(<<~RUBY) + puts 'some stuff' + + # Declares a model without enum + class MyModel + has_many :some_other_thing + end + RUBY + end + end + + context 'when starts from zero' do + context 'without prefix' do + it 'expects an offense' do + expect_offense(<<~RUBY) + class MyModel + enum my_enum: { state1: 0, state2: 2 } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Prefer starting from `1` instead of `0` with `enum`. + end + RUBY + + expect_offense(<<~RUBY) + class MyModel + enum my_enum: { + ^^^^^^^^^^^^^^^ Prefer starting from `1` instead of `0` with `enum`. + state1: 0, + state2: 2 + } + end + RUBY + + expect_offense(<<~RUBY) + class MyModel + enum my_enum: { state1: 1, state2: 0 } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Prefer starting from `1` instead of `0` with `enum`. + end + RUBY + end + end + + context 'with prefix' do + it 'expects an offense' do + expect_offense(<<~RUBY) + class MyModel + enum my_enum: { state1: 0, state2: 2 }, _suffix: false + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Prefer starting from `1` instead of `0` with `enum`. + end + RUBY + + expect_offense(<<~RUBY) + class MyModel + enum my_enum: { + ^^^^^^^^^^^^^^^ Prefer starting from `1` instead of `0` with `enum`. + state1: 0, + state2: 2 + }, _prefix: true + end + RUBY + end + end + end + + context 'when starts from a non zero' do + it 'expects no offense' do + expect_no_offenses(<<~RUBY) + class MyModel + enum my_enum: { state1: 1, state2: 2 }, _prefix: true + end + RUBY + + expect_no_offenses(<<~RUBY) + class MyModel + enum my_enum: { + state1: 2, + state2: 3 + } + end + RUBY + end + end + + context 'when enum values are strings' do + it 'expects no offense' do + expect_no_offenses(<<~RUBY) + class MyModel + enum my_enum: { default: 'default', console: 'console', scheduling: 'scheduling', booking: 'booking' }, _prefix: true + end + RUBY + end + end + + context 'when enum contains comments' do + it 'expects no offense' do + expect_no_offenses(<<~RUBY) + class MyModel + enum action: { + state_one: 1, # related to state one + state_two: 2 # related to state two + } + end + RUBY + end + end + + context 'when enum values are defined in a constant' do + before do + stub_const('ENUM_VALUES', { state_one: 0, state_two: 1 }.freeze) + end + + it 'expects no offense' do + expect_no_offenses(<<~RUBY) + class MyModel + enum action: ENUM_VALUES + end + RUBY + end + end +end