diff options
-rw-r--r-- | rubocop/cop/migration/safer_boolean_column.rb | 94 | ||||
-rw-r--r-- | rubocop/rubocop.rb | 1 | ||||
-rw-r--r-- | spec/rubocop/cop/migration/safer_boolean_column_spec.rb | 85 |
3 files changed, 180 insertions, 0 deletions
diff --git a/rubocop/cop/migration/safer_boolean_column.rb b/rubocop/cop/migration/safer_boolean_column.rb new file mode 100644 index 00000000000..0335c25d85d --- /dev/null +++ b/rubocop/cop/migration/safer_boolean_column.rb @@ -0,0 +1,94 @@ +require_relative '../../migration_helpers' + +module RuboCop + module Cop + module Migration + # This cop requires a default value and disallows nulls for boolean + # columns on small tables. + # + # In general, this prevents 3-state-booleans. + # https://robots.thoughtbot.com/avoid-the-threestate-boolean-problem + # + # In particular, for the `application_settings` table, this ensures that + # upgraded installations get a proper default for the new boolean setting. + # A developer might otherwise mistakenly assume that a value in + # `ApplicationSetting.defaults` is sufficient. + # + # See https://gitlab.com/gitlab-org/gitlab-ee/issues/2750 for more + # information. + class SaferBooleanColumn < RuboCop::Cop::Cop + include MigrationHelpers + + DEFAULT_OFFENSE = 'Boolean columns on the `%s` table should have a default. You may wish to use `add_column_with_default`.'.freeze + NULL_OFFENSE = 'Boolean columns on the `%s` table should disallow nulls.'.freeze + DEFAULT_AND_NULL_OFFENSE = 'Boolean columns on the `%s` table should have a default and should disallow nulls. You may wish to use `add_column_with_default`.'.freeze + + SMALL_TABLES = %i[ + application_settings + ].freeze + + def_node_matcher :add_column?, <<~PATTERN + (send nil :add_column $...) + PATTERN + + def on_send(node) + return unless in_migration?(node) + + matched = add_column?(node) + + return unless matched + + table, _, type = matched.to_a.take(3).map(&:children).map(&:first) + opts = matched[3] + + return unless SMALL_TABLES.include?(table) && type == :boolean + + no_default = no_default?(opts) + nulls_allowed = nulls_allowed?(opts) + + offense = if no_default && nulls_allowed + DEFAULT_AND_NULL_OFFENSE + elsif no_default + DEFAULT_OFFENSE + elsif nulls_allowed + NULL_OFFENSE + end + + add_offense(node, :expression, format(offense, table)) if offense + end + + def no_default?(opts) + return true unless opts + + each_hash_node_pair(opts) do |key, value| + return value == 'nil' if key == :default + end + end + + def nulls_allowed?(opts) + return true unless opts + + each_hash_node_pair(opts) do |key, value| + return value != 'false' if key == :null + end + end + + def each_hash_node_pair(hash_node, &block) + hash_node.each_node(:pair) do |pair| + key = hash_pair_key(pair) + value = hash_pair_value(pair) + yield(key, value) + end + end + + def hash_pair_key(pair) + pair.children[0].children[0] + end + + def hash_pair_value(pair) + pair.children[1].source + end + end + end + end +end diff --git a/rubocop/rubocop.rb b/rubocop/rubocop.rb index 3fbd5b0163c..1b6e8991a17 100644 --- a/rubocop/rubocop.rb +++ b/rubocop/rubocop.rb @@ -13,6 +13,7 @@ require_relative 'cop/migration/add_concurrent_index' require_relative 'cop/migration/add_index' require_relative 'cop/migration/add_timestamps' require_relative 'cop/migration/datetime' +require_relative 'cop/migration/safer_boolean_column' require_relative 'cop/migration/hash_index' require_relative 'cop/migration/remove_concurrent_index' require_relative 'cop/migration/remove_index' diff --git a/spec/rubocop/cop/migration/safer_boolean_column_spec.rb b/spec/rubocop/cop/migration/safer_boolean_column_spec.rb new file mode 100644 index 00000000000..1006594499a --- /dev/null +++ b/spec/rubocop/cop/migration/safer_boolean_column_spec.rb @@ -0,0 +1,85 @@ +require 'spec_helper' + +require 'rubocop' +require 'rubocop/rspec/support' + +require_relative '../../../../rubocop/cop/migration/safer_boolean_column' + +describe RuboCop::Cop::Migration::SaferBooleanColumn do + include CopHelper + + subject(:cop) { described_class.new } + + context 'in migration' do + before do + allow(cop).to receive(:in_migration?).and_return(true) + end + + described_class::SMALL_TABLES.each do |table| + context "for the #{table} table" do + sources_and_offense = [ + ["add_column :#{table}, :column, :boolean, default: true", 'should disallow nulls'], + ["add_column :#{table}, :column, :boolean, default: false", 'should disallow nulls'], + ["add_column :#{table}, :column, :boolean, default: nil", 'should have a default and should disallow nulls'], + ["add_column :#{table}, :column, :boolean, null: false", 'should have a default'], + ["add_column :#{table}, :column, :boolean, null: true", 'should have a default and should disallow nulls'], + ["add_column :#{table}, :column, :boolean", 'should have a default and should disallow nulls'], + ["add_column :#{table}, :column, :boolean, default: nil, null: false", 'should have a default'], + ["add_column :#{table}, :column, :boolean, default: nil, null: true", 'should have a default and should disallow nulls'], + ["add_column :#{table}, :column, :boolean, default: false, null: true", 'should disallow nulls'] + ] + + sources_and_offense.each do |source, offense| + context "given the source \"#{source}\"" do + it "registers the offense matching \"#{offense}\"" do + inspect_source(cop, source) + + aggregate_failures do + expect(cop.offenses.first.message).to match(offense) + end + end + end + end + + inoffensive_sources = [ + "add_column :#{table}, :column, :boolean, default: true, null: false", + "add_column :#{table}, :column, :boolean, default: false, null: false" + ] + + inoffensive_sources.each do |source| + context "given the source \"#{source}\"" do + it "registers no offense" do + inspect_source(cop, source) + + aggregate_failures do + expect(cop.offenses).to be_empty + end + end + end + end + end + end + + it 'registers no offense for tables not listed in SMALL_TABLES' do + inspect_source(cop, "add_column :large_table, :column, :boolean") + + expect(cop.offenses).to be_empty + end + + it 'registers no offense for non-boolean columns' do + table = described_class::SMALL_TABLES.sample + inspect_source(cop, "add_column :#{table}, :column, :string") + + expect(cop.offenses).to be_empty + end + end + + context 'outside of migration' do + it 'registers no offense' do + table = described_class::SMALL_TABLES.sample + inspect_source(cop, "add_column :#{table}, :column, :boolean") + + expect(cop.offenses).to be_empty + end + end +end |