Class: RuboCop::Cop::DevDoc::Migration::AvoidBooleanColumn
- Inherits:
-
Base
- Object
- Base
- RuboCop::Cop::DevDoc::Migration::AvoidBooleanColumn
- 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.
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 |