Class: RuboCop::Cop::DevDoc::Migration::AvoidBooleanColumn

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

Overview

Avoid boolean columns; prefer an alternative that better models the data.

Rationale

A boolean column collapses a domain into exactly two states, but most "is X?" questions are richer than that in practice. The constraint pushes developers past the path-of-least-resistance add_column :boolean toward a shape that captures what the data actually means.

The question to ask before reaching for t.boolean is: "what does NULL mean here?" — and if the answer is "unset" or "not yet decided", that semantic should be explicit in the schema, not silently smuggled in as a third boolean state.

Consider the alternatives in order:

1. Timestamp — when the flag implies a moment in time

If the boolean answers "did X happen?", a timestamp gives both the answer (approved_at.present?) AND the moment it happened (approved_at), for free. A boolean gives only the first.

❌ `approved` loses the moment of approval
add_column :submissions, :approved, :boolean

✔️ `approved_at` answers both "is it approved?" and "when?"
add_column :submissions, :approved_at, :datetime

This pattern dominates the codebase (published_at, deleted_at, archived_at, completed_at, etc.) for good reason.

2. Enum — when "unset" is a meaningful state, or a third

value is plausible An enum with an explicit "no_X_needed" value models "unset" honestly. NULL is outside an enum's domain (a type violation), so the column can carry null: false without controversy — see DevDoc/Rails/EnumColumnNotNull.

❌ nullable boolean; nil is a silent third state
add_column :checklists, :approval_required, :boolean

✔️ enum; "unset" is explicit, null: false is justified
# rubocop:disable DevDoc/Migration/AvoidNonNull -- enum
add_column :checklists, :approval_requirement, :integer, null: false
# rubocop:enable DevDoc/Migration/AvoidNonNull

class Checklist < ApplicationRecord
enum :approval_requirement, { no_approval_needed: 0, approval_by_admins: 1 }
end

3. Model method — when the flag is derived from other data

If the answer can be computed from existing columns or associations, do not store it. A method keeps the source of truth singular.

 denormalises role into a column
add_column :users, :is_admin, :boolean

✔️ derived from `role`
class User < ApplicationRecord
def admin?
  role == "admin"
end
end

4. Boolean — only when none of the above apply

A genuine binary preference with no meaningful third state (e.g. auto_renew, communication_allowed). Inline-disable this cop with a brief -- boolean reason. The column MUST also carry null: false — enforced by the sibling cop DevDoc/Migration/BooleanColumnNotNull.

✔️ true binary preference, justified
# rubocop:disable DevDoc/Migration/AvoidBooleanColumn -- true binary preference; user opt-in
add_column :service_subscriptions, :auto_renew, :boolean, null: false
# rubocop:enable DevDoc/Migration/AvoidBooleanColumn

NOTE: This cop catches t.boolean, add_column ..., :boolean, and change_column ..., :boolean (a type change to boolean). change_column_type migrations to a non-boolean type are not flagged — that is the cleanup direction.

NOTE: This cop is deliberately NOT timestamp-aware. It cannot tell whether a :datetime column would have been a better fit; that judgment is the developer's at review time. The role of this cop is to force the moment of judgment, not to make it.

Examples:

# bad
add_column :users, :is_admin, :boolean

# bad
t.boolean :approved

# good (timestamp alternative)
add_column :submissions, :approved_at, :datetime

# good (enum alternative)
add_column :checklists, :approval_requirement, :integer

# good (justified boolean)
# rubocop:disable DevDoc/Migration/AvoidBooleanColumn -- true binary preference
add_column :service_subscriptions, :auto_renew, :boolean, null: false
# rubocop:enable DevDoc/Migration/AvoidBooleanColumn

Constant Summary collapse

MSG =
'Avoid `boolean` columns; consider a timestamp (approved_at), enum, or model method. ' \
'If a boolean is genuinely the right shape, disable this cop with a brief `-- boolean` reason ' \
'and ensure the column carries `null: false` (see DevDoc/Migration/BooleanColumnNotNull).'.freeze

Instance Method Summary collapse

Instance Method Details

#on_send(node) ⇒ Object



126
127
128
129
130
# File 'lib/rubocop/cop/dev_doc/migration/avoid_boolean_column.rb', line 126

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

  add_offense(node)
end