Class: RuboCop::Cop::DevDoc::Rails::NoBlockPredicateOnRelation

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

Overview

Avoid block-form predicates on ActiveRecord relations.

Rationale

.count { }, .reject { }, .select { }, .any? { }, and .find { } accept a block, which silently converts the relation to an Array — every row is loaded into Ruby memory and filtered there, defeating database indexes and pagination.

Push the predicate into SQL with .where(...) or a model scope so the database does the filtering.

❌ Loads every pending record into memory before counting
pending_subscriptions.count { |s| !s.expired_for_context? }

✔️ Becomes a SQL COUNT via a scope
class Subscription < ApplicationRecord
scope :not_expired_for_context, ->(context) { ... }
end

pending_subscriptions.not_expired_for_context(context).count

Exception

Some predicates genuinely can't be expressed in SQL (decrypted attributes, non-trivial Ruby logic). For those, add a # rubocop:disable with a brief reason.

Excluded receivers

To keep false positives low, the cop skips receivers that clearly aren't AR relations: array literals ([...]), hash literals ({...}), screaming-case constants (e.g. PRICING_PLANS), and send-chains ending in a method known to return a non-Relation:

  • Array-returning — pluck, to_a, map, flatten, compact, uniq, sort, sort_by, reduce, inject, each_with_object, zip, take, drop, group_by, partition, tally, chunk_while, slice_when, etc.
  • Hash-returning — slice, except, merge, transform_values, transform_keys, to_h, compact_blank, with_indifferent_access.
  • Enumerator-returning — each_with_index, each_slice, each_cons, lazy, with_index, with_object. Calling these on a Relation forces eager loading; the next .select/etc. then operates on an in-memory Enumerator, so pushing into SQL is no longer possible.

Parenthesised receivers ((expr).select { ... }) are looked through to expr so the rules above still apply.

Excluded block shapes

Even when the receiver is opaque (a local/instance variable, method parameter, etc.), the block itself sometimes proves the element can't be an AR record:

  • 2+ block arguments|k, v|, |item, _index|, etc. AR relations only yield single records; multi-arg destructuring means the iterator is Hash#each, zip, or similar.
  • Symbol-key indexing on the block argarg[:foo] proves the element is a Hash (AR's [] is rarely called with literal symbol keys).
  • Single-char string indexing on the block argc[0] == '+' proves the element is a String.

Configuration

AdditionalNonRelationMethods (default []): per-project list of method names that return non-Relation collections. Useful when a codebase has its own helper methods returning plain Arrays / Hashes (e.g. a presenter factory) — adding them here lets the cop skip send-chains ending in those methods. Example:

DevDoc/Rails/NoBlockPredicateOnRelation:
AdditionalNonRelationMethods:
  - all_items
  - for_account
  - gst_registration_ranges

NOTE: The cop cannot determine whether a local variable, instance variable, or method parameter is an AR relation or a plain collection. When the receiver is in fact a plain Array/Hash, add # rubocop:disable DevDoc/Rails/NoBlockPredicateOnRelation with a brief reason — the friction is intentional and ensures the choice is reviewed.

Examples:

# bad
pending_memberships.count { |m| !m.expired? }

# bad
user.posts.reject { |post| post.archived? }

# bad
Model.where(active: true).any? { |r| r.flagged? }

# good
pending_memberships.not_expired.count

# good (receiver returns Array — excluded)
user.posts.pluck(:title).reject(&:blank?)

# good (Hash#values — excluded)
PRICING_PLANS.reject { |_k, v| v.archived? }

Constant Summary collapse

MSG =
'`%<method>s` with a block loads every row into Ruby. ' \
'Push the predicate into SQL with `.where(...)` or a model scope.'.freeze
RESTRICT_ON_SEND =
%i[count reject select find any?].freeze
NON_RELATION_RETURNING_METHODS =

Methods whose return value is known to be a non-Relation collection (Array, Hash, or Enumerator). When a .select/.reject/etc. with a block is chained onto a call to one of these, the block runs over the materialised collection — pushing into SQL isn't possible.

%i[
  pluck pluck_to_hash
  to_a to_ary
  values keys
  map flat_map collect collect_concat filter_map
  flatten compact uniq sort sort_by reverse
  reduce inject each_with_object
  split lines chars bytes
  zip take drop drop_while take_while
  group_by partition tally tally_by chunk_while slice_when

  slice except merge transform_values transform_keys to_h
  compact_blank with_indifferent_access index_by index_with

  each_with_index each_slice each_cons each_entry each_key each_value each_pair
  chunk slice_before slice_after lazy with_index with_object
].freeze

Instance Method Summary collapse

Instance Method Details

#on_send(node) ⇒ Object



131
132
133
134
135
136
137
138
# File 'lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb', line 131

def on_send(node)
  return unless node.block_literal?
  return if node.receiver.nil?
  return if excluded_receiver?(node.receiver)
  return if excluded_block?(node)

  add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
end