Статьи

Метод цепочек и ленивых вычислений в Ruby

В последнее время все большее распространение получают цепочки методов, и похоже, что каждая оболочка базы данных или что-то еще, использующее запросы, делает это. Но как это работает? Чтобы понять это, мы напишем библиотеку, которая может связывать вызовы методов для создания запроса MongoDB в этой статье. Давайте начнем!

Содержание этой статьи было первоначально размещено на Jeff Kreeftmeijer .

 

Да, и не волнуйтесь, если вы раньше не использовали MongoDB, я просто использую его как пример для запроса. Если вы используете это руководство для создания библиотеки запросов для чего-то другого, часть MongoDB должна легко заменяться.

Допустим, мы работаем с коллекцией пользователей и хотим иметь возможность запрашивать ее примерно так:

User.where(:name => 'Jeff').limit(5)

<span class="no"></span><span class="p"></span>

Мы создадим класс Criteria для построения запросов. Как вы уже догадались, ему нужны два метода экземпляра с именами where и limit.

При вызове одного из этих методов все, что нужно нашему объекту, — это помнить критерии, которые были переданы, поэтому нам нужно установить переменную экземпляра — с именем @criteria — для их хранения.

Наш метод where используется для указания условий, и мы хотим, чтобы он возвращал пустой массив, если еще не было указано ни одного, поэтому мы добавим пустой массив в наш хэш критериев по умолчанию:

class Criteria

  def criteria
    @criteria ||= {:conditions => {}}
  end

end
<code class="ruby"><span class="k"></span><span class="k"></span>
</code>

https://gist.github.com/1397738/946ce0…

Теперь мы можем запомнить условия, нам нужен способ их установить. Мы создадим метод where, который добавляет свои аргументы в массив условий:

class Criteria

  def criteria
    @criteria ||= {:conditions => {}}
  end

  def where(args)
    criteria[:conditions].merge!(args)
  end

end

https://gist.github.com/1397738/dacc04…

Большой! Давайте попробуем:

ruby-1.9.3-p0 :001 > require File.expand_path 'criteria'
 => true
ruby-1.9.3-p0 :002 > c = Criteria.new
 => #<Criteria:0x007ff9db8bf1f0>
ruby-1.9.3-p0 :003 > c.where(:name => 'Jeff')
 => {:name=>"Jeff"}
ruby-1.9.3-p0 :004 > c
 => #<Criteria:0x007ff9db8bf1f0 @criteria={:conditions=>{:name=>"Jeff"}}>

<code class="irb"><span class="go"></span><span class="go"></span>
</code>

Как видите, наш объект Criteria успешно сохраняет наше условие в переменной @criteria. Давайте попробуем связать другой, где вызов:

ruby-1.9.3-p0 :001 > require File.expand_path 'criteria'
 => true
ruby-1.9.3-p0 :002 > c = Criteria.new
 => #<Criteria:0x007fbf5296d098>
ruby-1.9.3-p0 :003 > c.where(:name => 'Jeff').where(:login => 'jkreeftmeijer')
NoMethodError: undefined method `where' for {:name=>"Jeff"}:Hash
    from (irb):3
    from /Users/jeff/.rvm/rubies/ruby-1.9.3-p0/bin/irb:16:in `<main>'

<span class="go"></span><span class="go"></span>

Гектометр Это не сработало, потому что метод where возвращает хеш, а метод Hash не имеет метода where. Нам нужно убедиться, что метод where возвращает объект Criteria. Давайте обновим метод where, чтобы он возвращал себя вместо переменной условий:

class Criteria

  def criteria
    @criteria ||= {:conditions => {}}
  end

  def where(args)
    criteria[:conditions].merge!(args)
    self
  end

end

<span class="k"></span><span class="k"></span>

https://gist.github.com/1397738/c5d222…

Хорошо, давайте попробуем это снова:

ruby-1.9.3-p0 :001 > require File.expand_path 'criteria'
 => true
ruby-1.9.3-p0 :002 > c = Criteria.new
 => #<Criteria:0x007fe91117c738>
ruby-1.9.3-p0 :003 > c.where(:name => 'Jeff').where(:login => 'jkreeftmeijer')
 => #<Criteria:0x007fe91117c738 @criteria={:conditions=>{:name=>"Jeff", :login=>"jkreeftmeijer"}}>

<span class="go"></span><span class="go"></span>

Ха! Теперь мы можем связать столько условий, сколько захотим. Давайте сразу приступим к реализации этого метода limit, чтобы мы могли ограничить результаты нашего запроса.

Конечно, нам нужен только один предел, так как несколько ограничений не имеют смысла. Это означает, что нам не нужен массив, мы можем просто установить критерии [: limit] вместо слияния хэшей, как мы делали с условиями ранее:

class Criteria

  def criteria
    @criteria ||= {:conditions => {}}
  end

  def where(args)
    criteria[:conditions].merge!(args)
    self
  end

  def limit(limit)
    criteria[:limit] = limit
    self
  end

end

<span class="k"></span><span class="k"></span>

https://gist.github.com/1397738/d28969…

Теперь мы можем связать условия и даже добавить ограничение:

ruby-1.9.3-p0 :001 > require File.expand_path 'criteria'
 => true
ruby-1.9.3-p0 :002 > c = Criteria.new
 => #<Criteria:0x007fdb1b0ca528>
ruby-1.9.3-p0 :003 > c.where(:name => 'Jeff').limit(5)
 => #<Criteria:0x007fdb1b0ca528 @criteria={:conditions=>{:name=>"Jeff"}, :limit=>5}>

<span class="go"></span><span class="go"></span>

Модель

Там. Мы можем собрать критерии запроса сейчас, но нам понадобится модель для запроса. Для этого примера давайте создадим модель с именем User.

Поскольку мы создаем библиотеку, которая может запрашивать базу данных MongoDB, я установил драйвер mongo-ruby и добавил метод сбора в модель User:

require 'mongo'

class User

  def self.collection
    @collection ||= Mongo::Connection.new['criteria']['users']
  end

end

<span class="nb"></span><span class="k"></span>

https://gist.github.com/1397738/2b9bd0…

Метод collection подключается к базе данных «критериев», ищет коллекцию «users» и возвращает экземпляр Mongo :: Collection, который мы будем использовать для запроса позже.

Помните, когда я сказал, что хочу сделать что-то вроде User.where (: name => ‘Jeff’). Limit (5)? Что ж, сейчас наша модель не реализует где и не ограничивает, поскольку мы помещаем их в класс Criteria. Давайте исправим это, создав два метода для User, которые делегируют Criteria.

require 'mongo'
require File.expand_path 'criteria'

class User

  def self.collection
    @collection ||= Mongo::Connection.new['mongo_chain']['users']
  end

  def self.limit(*args)
    Criteria.new.limit(*args)
  end

  def self.where(*args)
    Criteria.new.where(*args)
  end

end

<span class="nb"></span><span class="k"></span>

https://gist.github.com/1397738/6035ba…

Это позволяет нам вызывать методы наших критериев непосредственно в нашей модели:

ruby-1.9.3-p0 :001 > require File.expand_path 'user'
 => true
ruby-1.9.3-p0 :002 > User.where(:name => 'Jeff').limit(5)
 => #<Criteria:0x007fca1c8b0bd0 @criteria={:conditions=>{:name=>"Jeff"}, :limit=>5}>

Отлично. Вызов критериев в модели User теперь возвращает объект Criteria. Но, может быть, вы уже заметили, что возвращаемый объект не знает, к чему обращаться. Нам нужно сообщить, что мы хотим искать в коллекции пользователей. Для этого нам нужно переписать метод инициализации Criteria:

class Criteria

  def initialize(klass)
    @klass = klass
  end

  def criteria
    @criteria ||= {:conditions => {}}
  end

  def where(args)
    criteria[:conditions].merge!(args)
    self
  end

  def limit(limit)
    criteria[:limit] = limit
    self
  end

end

<code class="ruby"><span class="k"></span><span class="k"></span>
</code>

https://gist.github.com/1397738/4e2e0b…

С небольшим изменением нашей модели — передачей себя в Criteria.new — мы можем сообщить классу Criteria, что мы ищем:

require 'mongo'
require File.expand_path 'criteria'

class User

  def self.collection
    @collection ||= Mongo::Connection.new['criteria']['users']
  end

  def self.limit(*args)
    Criteria.new(self).limit(*args)
  end

  def self.where(*args)
    Criteria.new(self).where(*args)
  end

end

<code class="ruby"><span class="nb"></span><span class="k"></span>
</code>

https://gist.github.com/1397738/97652e…

После быстрой проверки мы видим, что экземпляр Criteria успешно запоминает наш класс модели:

ruby-1.9.3-p0 :001 > require File.expand_path 'user'
 => true
ruby-1.9.3-p0 :002 > User.where(:name => 'Jeff')
 => #<Criteria:0x007ffdd30d4d68 @klass=User, @criteria={:conditions=>{:name=>"Jeff"}}>

<span class="go"></span><span class="go"></span>

Получение некоторых результатов

Последнее, что нам нужно сделать, это лениво запрашивать нашу базу данных и получать некоторые результаты. Чтобы убедиться, что наша библиотека не выполняет запросы перед сбором всех критериев, мы подождем, пока каждый из них не будет вызван — чтобы просмотреть результаты запроса — в экземпляре Criteria. Давайте посмотрим, как наша библиотека справляется с этим прямо сейчас:

ruby-1.9.3-p0 :001 > require File.expand_path 'user'
 => true
ruby-1.9.3-p0 :002 > User.where(:name => 'Jeff').each { |u| puts u.inspect }
NoMethodError: undefined method `each' for #<Criteria:0x007fd0540cfea0>
	from (irb):2
	from /Users/jeff/.rvm/rubies/ruby-1.9.3-p0/bin/irb:16:in `<main>'

<span class="go"></span><span class="go"></span>

Of course, there’s no method named each on Criteria, because we don’t have anything to loop over yet. We’ll create Criteria#each, which will execute the query, giving us an array of results. We use that array’s each method to pass our block to:

class Criteria

  def initialize(klass)
    @klass = klass
  end

  def criteria
    @criteria ||= {:conditions => {}}
  end

  def where(args)
    criteria[:conditions].merge!(args)
    self
  end

  def limit(limit)
    criteria[:limit] = limit
    self
  end

  def each(&block)
    @klass.collection.find(
      criteria[:conditions], {:limit => criteria[:limit]}
    ).each(&block)
  end

end

<span class="k"></span><span class="k"></span>

https://gist.github.com/1397738/a1a254…

And now, finally, our query works (don’t forget to add some user documents to your database):

ruby-1.9.3-p0 :001 > require File.expand_path 'user'
 => true
ruby-1.9.3-p0 :002 > User.where(:name => 'Jeff').limit(2).each { |u| puts u.inspect }
{"_id"=>BSON::ObjectId('4ed2603b368ff6d6bc000001'), "name"=>"Jeff"}
{"_id"=>BSON::ObjectId('4ed2603b368ff6d6bc000002'), "name"=>"Jeff"}
 => nil

<span class="go"></span><span class="go"></span>

Awesome! Now what?

Now you have a library that can do chained and lazy-evaluated queries on a MongoDB database. Of course, there’s a lot of stuff you could still add – for example, you could mix in Enumerable and do some metaprogramming magic to remove some of the duplication – but that’s beyond the scope of this article.

If you have any questions, ideas, suggestions or comments, or you just want more articles like this one be sure to let me know in the comments.