Class: RuboCop::Cop::DevDoc::Rails::NoCollectionIdsWriterInController
- Inherits:
-
Base
- Object
- Base
- RuboCop::Cop::DevDoc::Rails::NoCollectionIdsWriterInController
- Defined in:
- lib/rubocop/cop/dev_doc/rails/no_collection_ids_writer_in_controller.rb
Overview
Controllers must not call association *_ids= writers; the write
belongs behind the model method or form-object PORO that owns the
operation's validations.
Rationale
collection_ids= looks like plain attribute assignment but is not:
on a persisted record it writes the join table IMMEDIATELY — no save
call, no validations, no surrounding transaction. In a controller
that produces two failure modes:
- Validation asymmetry. The create path routes through a form
object whose validations guard the association (say, "at least one
tag"); a later-added update action assigns
record.tag_ids = ...directly, and every one of those guards silently stops existing on the second write path. Nothing fails loudly — the update simply never consults the rules that create enforces. - Partial writes. The association mutates even when the
surrounding operation fails afterwards: clear
record.tag_ids = []and then fail to save a status flag, and the two halves are left inconsistent with no transaction to roll the join rows back.
Both are the data-saving-orchestration placement rule (see the orchestration best-practice doc, category 1) wearing an assignment costume: which rows must land together, and under which guards, is domain knowledge. Move the write into a model method or the form-object PORO that owns the operation's validations, so updates travel the same validation path as creates.
❌ Hand-rolled update path — join rows written before save!, guards skipped
def update
@article.category = update_params[:category]
@article.tag_ids = checked_tag_ids
@article.save!
end
✔️ The model method owns the write and its guards
def update
@article.update_with_tags!(update_params, tag_ids: checked_tag_ids)
end
Relationship with DevDoc/Rails/AvoidBypassingValidation
Same bypass family, different signal: that cop matches a fixed list
of ActiveRecord method names repo-wide, while *_ids= is a NAME
PATTERN that is perfectly legitimate on form objects (common under
app/models/). The pattern is only dependable where a direct ids
write is either a validation bypass or misplaced orchestration —
controllers — so it lives in this scoped cop instead.
Exception
The receiver's type is statically invisible, so this cop flags every
*_ids= spelling, including ones that persist nothing: an ActiveModel
form object, a not-yet-saved record (its ids apply at save time, under
validations), or a non-AR object such as a presenter or OpenStruct.
Those are ordinary params→object binding (orchestration category 2) —
disable inline, naming why nothing persists:
# rubocop:disable DevDoc/Rails/NoCollectionIdsWriterInController
# -- ArticleImport is an ActiveModel PORO; nothing persists
# rubocop:disable DevDoc/Rails/NoCollectionIdsWriterInController
# -- @article is built in this action and unsaved; ids apply at save,
# under validations
NOTE: Spellings with the same immediate-write behavior that this cop
cannot see, for reviewers: the plain collection writer
(record.tags = [...]) — indistinguishable from an attribute write —
a STANDALONE assign_attributes(tag_ids: [...]) on a persisted record
(mass assignment invokes the same writer, with no transaction around
it), and dynamic dispatch (record.send(:tag_ids=, x)). By contrast,
update/update! with an ids key is NOT in this class: Rails wraps
assign_attributes + save in a transaction precisely so those join-row
writes roll back when validations fail (see the comment inside
ActiveRecord::Persistence#update!).
Constant Summary collapse
- MSG =
'`%<method>s` in a controller — on a persisted record a collection `_ids=` writer ' \ 'updates the join table immediately, skipping the owner\'s save and validations. Move the ' \ 'write into the model method or form-object PORO that owns the operation\'s validations.'.freeze
- IDS_WRITER =
Dynamic name pattern, so RESTRICT_ON_SEND cannot apply (it is static); the filter runs inside on_send instead.
/\A\w+_ids=\z/
Instance Method Summary collapse
-
#on_op_asgn(node) ⇒ Object
(also: #on_or_asgn, #on_and_asgn)
Op-assigns (
record.tag_ids += [x],||=,&&=) call the same writer but parse as op_asgn/or_asgn/and_asgn nodes whose inner send is the READER (tag_ids, no=), so on_send never sees a match. - #on_send(node) ⇒ Object (also: #on_csend)
Instance Method Details
#on_op_asgn(node) ⇒ Object Also known as: on_or_asgn, on_and_asgn
Op-assigns (record.tag_ids += [x], ||=, &&=) call the same
writer but parse as op_asgn/or_asgn/and_asgn nodes whose inner send
is the READER (tag_ids, no =), so on_send never sees a match.
114 115 116 117 118 119 120 121 122 |
# File 'lib/rubocop/cop/dev_doc/rails/no_collection_ids_writer_in_controller.rb', line 114 def on_op_asgn(node) lhs = node.children.first return unless lhs.type?(:send, :csend) writer = "#{lhs.method_name}=" return unless IDS_WRITER.match?(writer) add_offense(lhs.loc.selector, message: format(MSG, method: writer)) end |
#on_send(node) ⇒ Object Also known as: on_csend
100 101 102 103 104 105 |
# File 'lib/rubocop/cop/dev_doc/rails/no_collection_ids_writer_in_controller.rb', line 100 def on_send(node) return unless node.assignment_method? return unless IDS_WRITER.match?(node.method_name.to_s) add_offense(node.loc.selector, message: format(MSG, method: node.method_name)) end |