Skip to content

teach condition_match? to handle ActiveRecord::Relation values #889

@apneadiving

Description

@apneadiving

I ended up patching cancancan for the following usecase: teach condition_match? to handle ActiveRecord::Relation values.

Would it make sense to add it to the gem itself?

CONTEXT

CanCanCan ability conditions accept both arrays and AR relations as condition values:

  can :read, UserContract, team_id: [1, 2, 3]          # Array     → IN list in SQL
  can :read, UserContract, team_id: Team.select(:id)   # Relation  → subquery in SQL

We use AR::Relation to avoid IN lists with hundreds of bind parameters, which degrade query plan quality and prevent plan caching.

THE BUG

CanCanCan's condition_match? is used for two things:

  1. accessible_by(ability) — builds SQL scopes. AR::Relation works perfectly here.
  2. can?(permission, instance) — checks a single object in Ruby. Broken for relations.

The original code in conditions_matcher.rb:

  when Enumerable then value.include?(attribute)

AR::Relation includes Enumerable, so it falls into this branch. But Enumerable#include?
iterates over the relation records and compares each with ==. Since the relation yields
AR objects (e.g., Team instances) and attribute is a primitive (e.g., integer team_id),
Team.new(id: 5) == 5 is always false — every instance-level can? check returns false.

THE FIX

Prepend a module that intercepts condition_match? when value is an AR::Relation,
using exists? with the model's primary key instead of Enumerable#include?.
All our subquery methods select(:id) from the canonical model, so primary_key is
always 'id' and value.where(pk => attribute).exists? is always correct.

module CanCanCanArRelationConditionMatch
  private

  def condition_match?(attribute, value)
    if value.is_a?(ActiveRecord::Relation)
      # See comment above. Must be checked before Enumerable since AR::Relation
      # includes Enumerable but Enumerable#include? compares objects, not IDs.
      value.where(value.primary_key => attribute).exists?
    else
      super
    end
  end
end
CanCan::ConditionsMatcher.prepend(CanCanCanArRelationConditionMatch)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions