Class: RuboCop::Cop::DevDoc::Migration::BooleanColumnNotNull

Inherits:
Base
  • Object
show all
Defined in:
lib/rubocop/cop/dev_doc/migration/boolean_column_not_null.rb

Overview

Boolean columns must carry null: false.

Rationale

null: false is reserved for cases where NULL has no meaningful interpretation — and a justified boolean is one of those cases. NULL is outside the column's stated domain of false: it is neither value, and every read site has to silently coerce it (nil || false == false) or risk a footgun.

The line is drawn here for standardization and non-subjectivity. Whether a regular column should be present is a business decision open to debate (see DevDoc/Migration/AvoidNonNull), but once the developer has justified a boolean via DevDoc/Migration/AvoidBooleanColumn's escape hatch, the boolean's non-null-ness is objective — it does not depend on any further business decision — so it is enforced mechanically rather than argued column-by-column.

This cop is the inverse of DevDoc/Migration/AvoidNonNull for the boolean case: that cop flags null: false on regular columns (including booleans, which it cannot distinguish from other types); this cop REQUIRES null: false on booleans. The contradiction is resolved at the disable site: when the developer disables AvoidNonNull on a boolean with -- boolean (alongside the AvoidBooleanColumn disable), this cop fires if null: false is missing.

Adding null: false to a table with existing rows

Postgres refuses to add a NOT NULL column to a non-empty table without a backfill. Use the model-validations backfill pattern (sanctioned by DevDoc/Migration/AvoidColumnDefault):

def change
add_column :table, :flag, :boolean

reversible do |dir|
  dir.up do
    Table.reset_column_information
    Table.where(flag: nil).find_each do |t|
      t.flag = false
      t.save!
    end
  end
end

# rubocop:disable DevDoc/Migration/AvoidNonNull -- boolean
change_column_null :table, :flag, false
# rubocop:enable DevDoc/Migration/AvoidNonNull
end

The application layer is the source of truth for the default value on new records (e.g. via after_initialize or a factory). default: in the migration is still disallowed by DevDoc/Migration/AvoidColumnDefault.

Interaction with AvoidBooleanColumn

These two cops fire together on every boolean addition:

  • AvoidBooleanColumn: "reconsider using a boolean at all"
  • BooleanColumnNotNull: "if you do, it must be NOT NULL"

Satisfy both by either changing the column type (timestamp / enum / model method) or, for a justified boolean, disabling AvoidBooleanColumn with -- boolean AND ensuring null: false is present so this cop does not fire.

NOTE: This cop catches t.boolean, add_column ..., :boolean, and change_column ..., :boolean (a type change to boolean) without null: false. It does NOT scan db/schema.rb for legacy debt — it only enforces the invariant on declarations it can see. Existing nullable booleans are surfaced by inspection, not by this cop.

Examples:

# bad - nullable boolean
add_column :users, :active, :boolean

# bad - nullable boolean in a create_table block
create_table :things do |t|
  t.boolean :flag
end

# good - boolean carries null: false
# rubocop:disable DevDoc/Migration/AvoidBooleanColumn -- true binary preference
add_column :things, :flag, :boolean, null: false
# rubocop:enable DevDoc/Migration/AvoidBooleanColumn

Constant Summary collapse

MSG =
'Boolean columns must carry `null: false` — NULL is outside {true, false}. ' \
'Backfill existing rows via the model, then `change_column_null`.'.freeze

Instance Method Summary collapse

Instance Method Details

#on_send(node) ⇒ Object



110
111
112
113
114
115
116
117
# File 'lib/rubocop/cop/dev_doc/migration/boolean_column_not_null.rb', line 110

def on_send(node)
  return unless boolean_column?(node) || add_column_boolean?(node) || change_column_to_boolean?(node)

  options = node.arguments.find(&:hash_type?)
  return if options && null_false_pair(options)

  add_offense(node)
end