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 arg** — ‘arg` proves the element is a Hash (AR’s ‘[]` is rarely called with literal symbol keys).

  • **Single-char string indexing on the block arg** — ‘c == ’+‘` 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