Search code examples
arraysrubyactiverecordcompositionenumerable

How to create composable scopes for plain Ruby arrays


One of the things that I really like about Active Record is its named scopes and being able to chain scopes together to build expressive queries.

What would be a similar way to achieve this with plain Ruby Enumerables/Arrays, ideally without monkey-patching Enumerable or Array in any dangerous way?

For example:

### ActiveRecord Model
class User < ActiveRecord::Base
  scope :customers, -> { where(:role => 'customer') }
  scope :speaking, ->(lang) { where(:language => lang) }
end

# querying
User.customers.language('English')  # all English customers

### Plain-Ruby Array
module User
  class << self
    def customers
      users.select { |u| u[:role] == 'customer' }
    end

    def speaking(lang)
      users.select { |u| u[:language] == lang }
    end

    private

    def users
      [
        {:name => 'John', :language => 'English', :role => 'customer'},
        {:name => 'Jean', :language => 'French', :role => 'customer'},
        {:name => 'Hans', :language => 'German', :role => 'user'},
        {:name => 'Max', :language => 'English', :role => 'user'}
      ]
    end
  end
end

User.customers  # all customers
User.language('English')  # all English speakers
# how do I achieve something similar to User.customers.language('English') ...?

I know I can build a method customers_with_language inside the module, but I'm looking for a general way to solve this with any number of "scopes".


Solution

  • Here is a crude implementation of a ScopableArray, which inherits an Array:

    class ScopableArray < Array
      def method_missing(method_sym, *args)
        ScopableArray.new(select { |u| u[method_sym] == args[0] } )
      end
    end
    

    When this class receives a method it does not identify, it assumes you want to filter it according to a field of the method's name with the argument's value:

    users = ScopableArray.new([
        {:name => 'John', :language => 'English', :role => 'customer'},
        {:name => 'Jean', :language => 'French', :role => 'customer'},
        {:name => 'Hans', :language => 'German', :role => 'user'},
        {:name => 'Max', :language => 'English', :role => 'user'}
    ])
    
    users.role('customer')
    # => [{:name=>"John", :language=>"English", :role=>"customer"}, {:name=>"Jean", :language=>"French", :role=>"customer"}]
    users.role('customer').language('English')
    # => [{:name=>"John", :language=>"English", :role=>"customer"}]
    

    You can also look at ActiveRecord's implementation pattern for a more elaborate scheme where you can define scopes by passing a name and a callable block, something like this:

    class ScopableArray2 < Array
      class << self
        def scope(name, body)
          unless body.respond_to?(:call)
            raise ArgumentError, 'The scope body needs to be callable.'
          end
    
          define_method(name) do |*args|
            dup.select! { |x| body.call(x, *args) }
          end
        end
      end
    end
    

    Then you can do something like this:

    class Users < ScopableArray2
      scope :customers, ->(x) { x[:role] == 'customer' }
      scope :speaking, ->(x, lang) { x[:language] == lang }
    end
    
    users = Users.new([
            {:name => 'John', :language => 'English', :role => 'customer'},
            {:name => 'Jean', :language => 'French', :role => 'customer'},
            {:name => 'Hans', :language => 'German', :role => 'user'},
            {:name => 'Max', :language => 'English', :role => 'user'}
        ])
    
    users.customers.speaking('English')
    # => [{:name=>"John", :language=>"English", :role=>"customer"}]