This is Fundbase Nerds, written by the team behind Fundbase.

Go to Fundbase  

Making an Array Quack Like a Duck

Posted by Marek Stanczyk on

We have this large, complicated blackbox module that calculates various performances of funds (monthly, yearly, since inception, annualized, etc.). It gets the data (prices) from the database as it needs, depending on what we ask it to calculate. When developing something completely different, I found myself in the need to calculate same kind of performances, but for a different model, which is similar enough in nature to make it pretend it’s the other model. Except that the base data needed for the calculations are not prices stored in the database, but an in-memory Array of price-like objects…

Now what? We could discuss whether our object model is correct, whether these models should belong to a common hierarchy, or other ideological questions. But let’s take a look at it from a different angle… We have Ruby with all its dynamic features, so how about we pretend our Array actually is a Mongoid queryable collection.

Let’s say our price objects have value and date attributes, and the module runs queries which look like

returns
  .where(:date.gte => start_date, :date.lt => end_date)
  .order_by(:date.desc)

and we want it to work on an Array. We don’t need to support all possible query options (yet… :-)), just those that our module needs. And what exactly is that? where is like an Array#select, and order_by is like #sort, right?

Let’s wrap this array-querying functionality in a new module which can be plugged into arrays as a class extension:

module ArrayQueries
  def where(options = {})
    self
  end

  def order_by(*args)
    self
  end
end

array_of_returns.extend(ArrayQueries)
array_of_returns.where(:date.gte => start_date).order_by(:date.asc)

We’re half way there… almost. The query works, except that it doesn’t do anything but returns the same array. Let’s first take a look at the order_by (it looks easier :-)). What are the args that the method receives?

[1] pry(main)> :date.asc
=> #<Origin::Key:0x007fd7cf1532a0 @block=nil, @expanded=nil, @name=:date, @operator=1, @strategy=:__override__>
[2] pry(main)> :date.desc
=> #<Origin::Key:0x007fd7eb3cb458 @block=nil, @expanded=nil, @name=:date, @operator=-1, @strategy=:__override__>

This tells us that (where key is an item of args) key.name is the attribute to sort on, and key.operator is 1 for ascending and -1 for descending. So let’s rewrite that in Ruby (for now, just one (first) sorting attribute):

def order_by(*args)
  key = args.first
  sort do |a, b|
    (a.send(key.name) <=> b.send(key.name)) * key.operator
  end
end

What exactly did we do? For order_by(:date.desc), we did

sort { |a, b| a.date <=> b.date * -1 }

Neat, isn’t it? Adding support for additional sorting attributes can be left as an exercise for the reader :-)

Now let’s take a look at the where method. What do the options look like?

[3] pry(main)> :date.gte
=> #<Origin::Key:0x007fd7e5f38440 @block=nil, @expanded=nil, @name=:date, @operator="$gte", @strategy=:__override__>

Again, name for the attribute name, but the operator is somewhat more complicated, $gte stands for “greater than or equal”, i.e. >=. So we’ll need a mapping of Mongoid operator names to Ruby operators. Let’s implement that in a helper method that translates the operator name to a Ruby Symbol:

def operator_for(operator)
  case operator
  when '$gte' then :>=
  when '$gt'  then :>
  when '$lte' then :<=
  when '$lt'  then :<
  when '$ne'  then :!=
  when '$in'  then :in?
  # maybe a few more...
  end
end

Now that’s great, but assuming we have a query like the following, how do we translate that?

where(:date.gte => start_date, :date.lt => end_date, :date.nin => except_dates)

We receive these options in a Hash of Origin::Key => value pairs, which represent conditions that have to match (all of them). So let’s translate each option pair to a condition, using procs, because we can call them easily later (option and value are one pair from the Hash):

condition = proc do |item|
  operator = operator_for(option.operator)
  item.send(option.name.to_sym).send(operator, value)
end

Wait a moment, what did we just do? Well, having e.g.

:date.gte => start_date

we translated it to

condition = proc do |item|
  item.send(:date).send(:=>, start_date)
  # which is actually
  item.date.>=(start_date)
  # and that's just
  item.date >= start_date
end

All that because operators are just methods, too. Thank you Ruby! Now we can test the array items against this condition by calling the proc:

condition.call(item)

We just apply this on the entire array (each item must satisfy all conditions, so we’ll use all?) and we’re done:

def where(options = {})
  # translate each option to a condition proc
  conditions = options.map do |option, value|
    proc do |item|
      operator = operator_for(option.operator)
      item.send(option.name.to_sym).send(operator, value)
    end
  end
  # select array items that satisfy all conditions
  select do |item|
    conditions.all? { |condition| condition.call(item) }
  end
end

Wrapped up, our code looks like this:

module ArrayQueries
  def where(options = {})
    # translate each option to a condition proc
    conditions = options.map do |option, value|
      proc do |item|
        operator = operator_for(option.operator)
        item.send(option.name.to_sym).send(operator, value)
      end
    end
    # filter array items that satisfy all conditions
    select do |item|
      conditions.all? { |condition| condition.call(item) }
    end
  end

  def order_by(*args)
    key = args.flatten.first
    sort do |a, b|
      (a.send(key.name) <=> b.send(key.name)) * key.operator
    end
  end

  private

  def operator_for(operator)
    case operator
    when '$gte' then :>=
    when '$gt'  then :>
    when '$lte' then :<=
    when '$lt'  then :<
    when '$ne'  then :!=
    when '$in'  then :in?
    # maybe a few more...
    end
  end
end

array_of_returns.extend(ArrayQueries)
array_of_returns.where(:date.gte => start_date).order_by(:date.asc)

Quite simple eventually, isn’t it? As said earlier, this is just a foundation for various possible additions, which can give this module a lot of power (and fun for the developer). Btw, if it wasn’t clear yet, this was duck typing.

Thanks for reading.


Marek Stanczyk

Marek is a Fullstack developer at Fundbase.
Loves beautiful code and enjoys developing with Ruby and Rails the most.